Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 25 additions & 11 deletions crates/buzz-acp/src/acp.rs
Original file line number Diff line number Diff line change
Expand Up @@ -298,8 +298,12 @@ impl AcpClient {
/// Send `session/new` and return the full response alongside the session ID.
///
/// `cwd` must be an absolute path. `mcp_servers` may be empty.
/// `system_prompt` is included in the request when `Some` — agents that
/// support the field will use it; others ignore unknown fields per JSON-RPC.
/// `system_prompt`, when `Some`, is attached under ACP's sanctioned
/// extension point — `_meta` — namespaced as `buzz.systemPrompt`. ACP's
/// `NewSessionRequest` defines no `systemPrompt` field; `_meta` is the
/// spec's reserved object for client/agent extensions, so this keeps us
/// compliant rather than riding an undocumented top-level key. Agents that
/// support the field read it from `_meta`; others ignore it.
/// Callers use [`extract_model_config_options`] and [`extract_model_state`]
/// to pull model info from the raw result.
pub async fn session_new_full(
Expand All @@ -313,7 +317,7 @@ impl AcpClient {
"mcpServers": mcp_servers,
});
if let Some(sp) = system_prompt {
params["systemPrompt"] = serde_json::Value::String(sp.to_owned());
params["_meta"] = serde_json::json!({ "buzz.systemPrompt": sp });
}
let result = self.send_request("session/new", params).await?;
let session_id = result["sessionId"]
Expand Down Expand Up @@ -2044,7 +2048,7 @@ mod tests {
);
}

// ── session_new_full systemPrompt serialization ──────────────────────
// ── session_new_full systemPrompt serialization (via _meta) ──────────

#[tokio::test]
async fn session_new_full_includes_system_prompt_when_some() {
Expand All @@ -2068,17 +2072,23 @@ mod tests {
.expect("session_new_full should succeed");

assert_eq!(resp.session_id, "ses_test");
let received = &resp.raw["_receivedRequest"];
let params = &resp.raw["_receivedRequest"]["params"];
// Compliance: carried under ACP's `_meta` extension point, namespaced.
assert_eq!(
received["params"]["systemPrompt"].as_str(),
params["_meta"]["buzz.systemPrompt"].as_str(),
Some("Custom system prompt"),
"systemPrompt should be included in params when Some"
"system prompt should be in _meta.buzz.systemPrompt when Some"
);
// And NOT as a bare top-level key (the old, non-compliant placement).
assert!(
params["systemPrompt"].is_null(),
"system prompt must not be a top-level key"
);
}

#[tokio::test]
async fn session_new_full_omits_system_prompt_when_none() {
// When system_prompt is None, the field should not appear in params.
// When system_prompt is None, neither _meta nor a top-level field appears.
let script = r#"
read -t 2 _init
echo '{"jsonrpc":"2.0","id":0,"result":{"protocolVersion":1,"agentCapabilities":{}}}'
Expand All @@ -2098,10 +2108,14 @@ mod tests {
.expect("session_new_full should succeed");

assert_eq!(resp.session_id, "ses_test");
let received = &resp.raw["_receivedRequest"];
let params = &resp.raw["_receivedRequest"]["params"];
assert!(
params["systemPrompt"].is_null(),
"systemPrompt should NOT be a top-level key when value is None"
);
assert!(
received["params"]["systemPrompt"].is_null(),
"systemPrompt should NOT be in params when value is None"
params["_meta"].is_null(),
"_meta should not be added when there is no system prompt"
);
}
}
62 changes: 40 additions & 22 deletions crates/buzz-acp/src/engram_fetch.rs
Original file line number Diff line number Diff line change
Expand Up @@ -20,37 +20,55 @@ use crate::relay::RestClient;
/// Section header rendered into the prompt.
const SECTION_LABEL: &str = "Agent Memory — core";

/// Onboarding nudge for new agents with no core yet.
/// Fallback system prompt for an agent with neither an operator-configured
/// system prompt nor a core memory. Injected into the system role at session
/// birth (see system-prompt composition in `pool.rs`).
///
/// Wording is from Tyler's brief: "No core memory found. Use `buzz mem`
/// to create a core memory. Ask your user about yourself."
pub const ONBOARDING_NUDGE: &str = "No core memory found. \
Use `buzz mem set core \"…\"` to create one (it will hold your identity, \
rules, and goals across sessions). Ask your user about yourself.";
/// Deliberately tight — orient the agent, show it the one command it needs to
/// speak, and point it at the durable fix (owner-set core). Written as one
/// voice with the rest of the system prompt, not a bolted-on snippet.
pub const FALLBACK_SYSTEM_PROMPT: &str = "You are an agent in Buzz, a \
Nostr-based chat platform where humans and agents collaborate. Humans only see \
what you post — your tool calls and reasoning are invisible, so surface what \
matters in a message.\n\nTo post to a channel: \
`buzz messages send --channel <id> --content '...'`. Mention someone with \
`@Name` in the content.\n\nYou have no identity configured yet. Ask your owner \
to set your `core` memory — it holds who you are, your rules, and your goals \
across every session.";

/// Build the rendered prompt section for the agent's core.
/// Outcome of a core-engram fetch, kept as three distinct states so the
/// caller can compose the system prompt correctly.
///
/// Returns:
/// - `Some(profile_section)` when a valid core exists,
/// - `Some(nudge_section)` when the relay confirmed absence,
/// - `None` when the fetch failed (transport, parse, decrypt) — the caller
/// should inject no section in that case so the agent doesn't conclude
/// memory is empty.
pub async fn build_core_section(
rest: &RestClient,
agent_keys: &Keys,
owner: &PublicKey,
) -> Option<String> {
/// The distinction matters: "confirmed empty" invites a fallback identity,
/// but "unavailable" (relay/decrypt failure) must NOT — otherwise a transient
/// outage would hand an established agent a brand-new identity and tempt it to
/// overwrite real-but-unreachable memory.
pub enum CoreFetch {
/// A valid core exists. Pre-rendered as `[Agent Memory — core]\n<profile>`.
Present(String),
/// The relay confirmed the agent has no core (empty result set).
ConfirmedEmpty,
/// Fetch/decrypt/parse failed, or timed out. We learned nothing — treat as
/// neither present nor empty.
Unavailable,
}

/// Fetch the agent's core engram and classify the result into [`CoreFetch`].
///
/// The `[Agent Memory — core]` framing lives here so the section header is
/// defined in exactly one place; the *empty* and *unavailable* policies are
/// decided by the caller (system-prompt composition), not baked in.
pub async fn fetch_core(rest: &RestClient, agent_keys: &Keys, owner: &PublicKey) -> CoreFetch {
match fetch_core_body(rest, agent_keys, owner).await {
Ok(Some(profile)) => Some(format!("[{SECTION_LABEL}]\n{profile}")),
Ok(None) => Some(format!("[{SECTION_LABEL}]\n{ONBOARDING_NUDGE}")),
Ok(Some(profile)) => CoreFetch::Present(format!("[{SECTION_LABEL}]\n{profile}")),
Ok(None) => CoreFetch::ConfirmedEmpty,
Err(reason) => {
tracing::warn!(
target: "engram::core",
"core fetch failed: {reason} — emitting no section to avoid \
"core fetch failed: {reason} — treating as Unavailable to avoid \
confusing a relay outage with an absent core"
);
None
CoreFetch::Unavailable
}
}
}
Expand Down
Loading