diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 6a16a8242..f577d023d 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -8,6 +8,10 @@ Each channel session has independent context and work. Sessions share your agent Treat the current `[Context]` block as authoritative for this turn's scope, channel, and default reply destination. Work and communicate in that channel unless the user explicitly requests another destination. +For work that needs follow-up tool calls, open a todo before you start and keep it open until the deliverable is verified and you have published a completion or blocker message. Never end a turn with open todo state you have not reported. + +After a context compaction or session restart, resume silently. Rebuild state from your todos, core memory, and the thread; never publish a message announcing the compaction, summarizing what was lost, or asking how to proceed. + The `buzz` CLI is your interface to Buzz. Its command groups cover `messages`, `channels`, `canvas`, `reactions`, `emoji`, `dms`, `users`, `agents`, `workflows`, `feed`, `social`, `notes`, `repos`, `projects`, `patches`, `issues`, `pr`, `media`, `upload`, `mem`, `pack`, and `moderation`. ## Messaging diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 8bed4f421..f9e7bf1ed 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -385,8 +385,8 @@ pub struct CliArgs { /// /// Memory injection is on by default. When enabled, the harness /// fetches the agent's per-session core engram and renders it as an - /// `[Agent Memory — core]` prompt section when a non-empty core exists. - /// Confirmed absence emits no standing section. The `buzz mem` CLI + /// `[Agent Memory — core]` prompt section (or renders the onboarding nudge + /// when the relay confirms no core engram exists). The `buzz mem` CLI /// and the relay's acceptance of kind:30174 engrams are unaffected — this /// flag controls prompt-time injection in the ACP harness only. /// Pass `--no-memory` / `BUZZ_ACP_NO_MEMORY=true` to disable. diff --git a/crates/buzz-acp/src/engram_fetch.rs b/crates/buzz-acp/src/engram_fetch.rs index 63ddf7cf3..534d05837 100644 --- a/crates/buzz-acp/src/engram_fetch.rs +++ b/crates/buzz-acp/src/engram_fetch.rs @@ -4,8 +4,8 @@ //! Scope per Tyler's spec: //! - Fire one synchronous query for the core head when a *new* session is born. //! - If a body is found, emit `[Agent Memory — core]\n`. -//! - If no body is found, return an onboarding nudge. The pool gates that -//! nudge to one channel session per agent process. +//! - If no body is found, emit an onboarding nudge so the agent learns how +//! to set its own core. //! - On any *error* (transport, parse), log and emit nothing. We must not //! mistake a relay outage for "no core" — that would invite the agent to //! overwrite real, just-unreachable memory with a fresh profile. @@ -20,46 +20,19 @@ use crate::relay::RestClient; /// Section header rendered into the prompt. const SECTION_LABEL: &str = "Agent Memory — core"; -/// Onboarding nudge for a new agent with no core yet. +/// Onboarding nudge for new agents with no core yet. +/// +/// 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."; -/// Rendered core context together with the agent-wide delivery policy it needs. -pub(crate) struct CoreSection { - rendered: String, - onboarding: bool, -} - -impl CoreSection { - pub(crate) fn profile(profile: String) -> Self { - Self { - rendered: format!("[{SECTION_LABEL}]\n{profile}"), - onboarding: false, - } - } - - pub(crate) fn onboarding() -> Self { - Self { - rendered: format!("[{SECTION_LABEL}]\n{ONBOARDING_NUDGE}"), - onboarding: true, - } - } - - pub(crate) fn is_onboarding(&self) -> bool { - self.onboarding - } - - pub(crate) fn into_rendered(self) -> String { - self.rendered - } -} - /// Build the rendered prompt section for the agent's core. /// /// Returns: /// - `Some(profile_section)` when a valid core exists, -/// - `Some(onboarding_section)` when the relay confirmed absence, +/// - `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. @@ -67,10 +40,10 @@ pub async fn build_core_section( rest: &RestClient, agent_keys: &Keys, owner: &PublicKey, -) -> Option { +) -> Option { match fetch_core_body(rest, agent_keys, owner).await { - Ok(Some(profile)) => Some(CoreSection::profile(profile)), - Ok(None) => Some(CoreSection::onboarding()), + Ok(Some(profile)) => Some(format!("[{SECTION_LABEL}]\n{profile}")), + Ok(None) => Some(format!("[{SECTION_LABEL}]\n{ONBOARDING_NUDGE}")), Err(reason) => { tracing::warn!( target: "engram::core", @@ -87,7 +60,7 @@ pub async fn build_core_section( /// - `Ok(None)` only if the relay confirmed absence (empty result set), /// - `Err(reason)` if the relay returned candidates we could not parse, /// verify, or decrypt — those are NOT treated as absence (would let an -/// unreadable but real core be silently overwritten after onboarding), +/// unreadable but real core be silently overwritten by the onboarding nudge), /// - `Err` for transport / parse errors. async fn fetch_core_body( rest: &RestClient, @@ -120,7 +93,7 @@ async fn fetch_core_body( /// Pure decoder: given the relay's JSON array, decide whether we have a /// readable core, confirmed absence, or an ambiguous unreadable-state. /// -/// - Empty array → `Ok(None)` (confirmed absence; caller renders onboarding). +/// - Empty array → `Ok(None)` (confirmed absence; caller renders the nudge). /// - At least one event decrypts → use the winning head's body. /// * Body::Core → `Ok(Some(profile))` /// * Body::Tombstone or unexpected shape → `Ok(None)` (treat as absent). @@ -196,8 +169,8 @@ mod tests { use buzz_core::engram::{build_event, Body}; use serde_json::json; - /// Empty array → confirmed absence → Ok(None), so the caller can render - /// onboarding. This is the only path that maps to "no core." + /// Empty array → confirmed absence → Ok(None), so the caller emits the + /// onboarding nudge. This is the only path that maps to "no core." #[test] fn decode_empty_array_is_confirmed_absence() { let agent = Keys::generate(); @@ -223,8 +196,9 @@ mod tests { /// Regression: when the relay returns a kind:30174 event addressed to /// this agent that we cannot decrypt (here: encrypted to a *different* /// owner's key, so the MAC fails for this agent↔owner pair), we MUST - /// return Err and NOT Ok(None). Returning Ok(None) would invite the agent - /// to overwrite a real-but-unreadable core after seeing onboarding. + /// return Err and NOT Ok(None). Returning Ok(None) would cause the + /// harness to emit the onboarding nudge, inviting the agent to overwrite + /// a real-but-unreadable core. #[test] fn decode_undecryptable_candidate_is_err_not_absent() { let agent = Keys::generate(); @@ -271,15 +245,4 @@ mod tests { let result = decode_core_body(&arr, &agent, &owner.public_key()); assert!(result.is_err(), "expected Err, got: {result:?}"); } - - #[test] - fn core_sections_distinguish_profile_from_onboarding() { - let profile = CoreSection::profile("I am Sami.".to_string()); - assert!(!profile.is_onboarding()); - assert_eq!(profile.into_rendered(), "[Agent Memory — core]\nI am Sami."); - - let onboarding = CoreSection::onboarding(); - assert!(onboarding.is_onboarding()); - assert!(onboarding.into_rendered().contains(ONBOARDING_NUDGE)); - } } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index f6e708365..2efacce2b 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -35,7 +35,6 @@ use crate::acp::{ StopReason, SystemPromptTransport, }; use crate::config::{compose_session_title, DedupMode, PermissionMode}; -use crate::engram_fetch::CoreSection; use crate::observer; use crate::queue::{ CancelReason, ContextMessage, ConversationContext, FlushBatch, PromptChannelInfo, @@ -120,10 +119,6 @@ pub struct SessionState { /// channel_id → rendered NIP-AE core prompt section, populated once at /// session creation per Tyler's spec (no mid-session refresh). pub core_sections: HashMap, - /// Agent-process-wide latch for the no-core onboarding nudge. It survives - /// channel and session invalidation so the same agent is not reminded in - /// every channel; a newly spawned agent process starts with `false`. - pub core_onboarding_emitted: bool, /// channel_id → rendered `[Channel Canvas]` metadata section. /// /// Populated once before session creation (same lifecycle as `core_sections`). @@ -1407,30 +1402,6 @@ fn with_core(framed: Option, core: Option<&str>) -> Option { } } -/// Cache a fetched core section for one channel, suppressing repeat onboarding. -/// -/// Profiles are always cached because a core may be created after the one-time -/// onboarding nudge. The onboarding latch is agent-wide and intentionally is -/// not part of channel invalidation. -fn cache_core_section( - state: &mut SessionState, - channel_id: Uuid, - section: CoreSection, -) -> Option<(usize, bool)> { - let is_onboarding = section.is_onboarding(); - if is_onboarding && state.core_onboarding_emitted { - return None; - } - - let rendered = section.into_rendered(); - let section_len = rendered.len(); - state.core_sections.insert(channel_id, rendered); - if is_onboarding { - state.core_onboarding_emitted = true; - } - Some((section_len, is_onboarding)) -} - /// Append owner-signed huddle instructions to this channel session's system prompt. fn with_huddle_instructions(prompt: Option, instructions: Option<&str>) -> Option { let instructions = instructions @@ -1604,10 +1575,11 @@ pub async fn run_prompt_task( // // Failure modes (all fail open — no crash, no block): // * no owner configured → skip (no NIP-AE namespace exists) - // * confirmed absence → inject onboarding once per agent process, not - // once per channel session. + // * confirmed absence → cache the onboarding nudge so the agent + // learns how to bootstrap itself. // * transport / decrypt / parse error → inject nothing. We never - // mistake "relay slow or broken" for "no core". + // mistake "relay slow or broken" for "no core" — that would invite + // the agent to overwrite real, just-unreachable memory. // * fetch exceeds CORE_FETCH_TIMEOUT → inject nothing, same reason. // // Per Tyler's locked spec: NO mid-session refreshes. Re-fetch only @@ -1621,7 +1593,7 @@ pub async fn run_prompt_task( { let is_new_channel_session = !agent.state.sessions.contains_key(cid); if is_new_channel_session && !agent.state.core_sections.contains_key(cid) { - // Bounded — we'd rather start the session with no core context + // Bounded — we'd rather start the session with no core hint // than block session creation on a stalled relay. const CORE_FETCH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(3); let fetch = crate::engram_fetch::build_core_section( @@ -1641,17 +1613,14 @@ pub async fn run_prompt_task( None } }; - if let Some(section) = section { - let cached = cache_core_section(&mut agent.state, *cid, section); - if let Some((section_len, is_onboarding)) = cached { - tracing::info!( - target: "engram::core", - channel = %cid, - section_len, - is_onboarding, - "injected NIP-AE core section into system prompt" - ); - } + if let Some(rendered) = section { + tracing::info!( + target: "engram::core", + channel = %cid, + section_len = rendered.len(), + "injected NIP-AE core section into system prompt" + ); + agent.state.core_sections.insert(*cid, rendered); } } } @@ -4745,44 +4714,6 @@ mod tests { assert_eq!(framed, "[Agent Memory — core]\nbe helpful"); } - #[test] - fn test_cache_core_onboarding_only_once_per_agent() { - let mut state = SessionState::default(); - let first_channel = Uuid::new_v4(); - let second_channel = Uuid::new_v4(); - - assert!( - cache_core_section(&mut state, first_channel, CoreSection::onboarding(),).is_some() - ); - assert!(state.core_onboarding_emitted); - assert!(state.core_sections.contains_key(&first_channel)); - - assert!( - cache_core_section(&mut state, second_channel, CoreSection::onboarding(),).is_none() - ); - assert!(!state.core_sections.contains_key(&second_channel)); - } - - #[test] - fn test_cache_core_profile_after_onboarding_is_not_suppressed() { - let mut state = SessionState { - core_onboarding_emitted: true, - ..SessionState::default() - }; - let channel = Uuid::new_v4(); - - assert!(cache_core_section( - &mut state, - channel, - CoreSection::profile("durable profile".to_string()), - ) - .is_some()); - assert_eq!( - state.core_sections.get(&channel).map(String::as_str), - Some("[Agent Memory — core]\ndurable profile") - ); - } - #[test] fn test_with_core_neither_is_none() { assert!(with_core(None, None).is_none()); @@ -6365,7 +6296,6 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" s.turn_counts.insert(ch_b, 3); s.core_sections.insert(ch_a, "core-a".into()); s.core_sections.insert(ch_b, "core-b".into()); - s.core_onboarding_emitted = true; s.deliveries.insert( ch_a, ChannelDeliveryState { @@ -6405,7 +6335,6 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert_eq!(s.core_sections.get(&ch_b).unwrap(), "core-b"); assert_eq!(s.heartbeat_session.as_deref(), Some("sess-hb")); assert_eq!(s.heartbeat_turn_count, 7); - assert!(s.core_onboarding_emitted); } #[test] @@ -6459,7 +6388,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" } #[test] - fn test_invalidate_all_clears_session_state_but_preserves_onboarding_latch() { + fn test_invalidate_all_clears_everything() { let (mut s, _ch_a, _ch_b) = make_state(); s.invalidate_all(); @@ -6469,7 +6398,6 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(s.heartbeat_session.is_none()); assert_eq!(s.heartbeat_turn_count, 0); assert!(!s.heartbeat_standing_context_sent); - assert!(s.core_onboarding_emitted); } #[test]