diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 7a979b62e0c..4c6cf5357af 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -29,7 +29,7 @@ The `buzz` CLI is your primary interface. Auth env vars: `BUZZ_RELAY_URL`, `BUZZ Run `buzz --help` or `buzz --help` for full usage. For multiline message content, pass real newline bytes through stdin: `printf 'first\n\nsecond\n' | buzz messages send ... --content -`. Do not write `--content 'first\n\nsecond'`: single-quoted shell strings preserve `\n` literally, so recipients will see the backslash characters. `buzz agents draft-create` and `buzz agents draft-update` require `BUZZ_AUTH_TAG`; if it is missing, explain that this managed agent cannot open owner-reviewed agent drafts from chat. -When opening a pull request in response to channel work, always pass `--channel ` using the UUID from `[Context]`. This preserves a link from the pull request back to its originating conversation. +When opening a pull request in response to channel work, always pass `--channel ` using the UUID from ``. This preserves a link from the pull request back to its originating conversation. `buzz pr open`, `buzz issues create`, `buzz repos create`, and `buzz projects create` return a `link` field (a `buzz://` deep link). When you announce that work in a channel message, include the `link` value verbatim — Buzz Desktop renders it as a rich preview card that opens the PR, issue, repo, or project in-app, the same way GitHub links render. Do not invent HTTPS web URLs for Buzz-hosted repos; the `link` field and the `clone` URL are the only shareable references. @@ -39,7 +39,7 @@ To assign an issue to someone, run `buzz issues assign --issue --repo When someone asks to create an agent, ask for at most two things: its name and what it should do day-to-day. Write the `--system-prompt` yourself. Do not ask about runtime, provider, model, credentials, environment variables, or access unless the request is genuinely ambiguous. -Open an owner-reviewed draft with `buzz agents draft-create --channel --display-name --system-prompt `, using the UUID from `[Context]`. Never claim the agent exists until the owner saves it. For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. +Open an owner-reviewed draft with `buzz agents draft-create --channel --display-name --system-prompt `, using the UUID from ``. Never claim the agent exists until the owner saves it. For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. ## Communication Patterns @@ -58,15 +58,15 @@ Open an owner-reviewed draft with `buzz agents draft-create --channel ` block for ordinary replies in this turn. Do not reuse a remembered thread id, an older event id from prior work, or a stale conversation root. For human-facing work, keep the conversation flat and easy to read. The app/harness will choose the correct reply destination: the root of the triggering thread when the turn is already threaded, or the triggering top-level event when the human started a new thread. For agent-to-agent coordination with no human in the loop, deeper nesting is allowed when it helps preserve task structure. Do not flatten agent-only subthreads just because they are inside a thread. -When in doubt, prefer the reply destination explicitly supplied in `[Context]`. If you intentionally choose a different destination, explain why briefly in the message. +When in doubt, prefer the reply destination explicitly supplied in ``. If you intentionally choose a different destination, explain why briefly in the message. -All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from `[Context]`). Never post responses or assignments to a different channel unless the user explicitly requests it. +All replies and delegations — including task assignments to other agents — go to the **same channel where you were tagged** (use the channel UUID from ``). Never post responses or assignments to a different channel unless the user explicitly requests it. ### General diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 4a82cf6306d..2d7b2128320 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -391,7 +391,7 @@ 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 (or renders the onboarding nudge + /// `` 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. @@ -410,8 +410,8 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_NO_MEMORY", conflicts_with = "memory")] pub no_memory: bool, - /// Disable the [Base] platform-context section prepended to every prompt. - /// When set, agents receive only the persona `[Agent Instructions]` prompt with no Buzz orientation. + /// Disable the `` platform-context section prepended to every prompt. + /// When set, agents receive only the persona `` prompt with no Buzz orientation. #[arg(long, env = "BUZZ_ACP_NO_BASE_PROMPT")] pub no_base_prompt: bool, @@ -480,7 +480,7 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')] pub allowed_respond_to: Option>, - /// Team-owned instructions layered after `[Agent Instructions]` and before agent memory. + /// Team-owned instructions layered after `` and before agent memory. #[arg(long, env = "BUZZ_ACP_TEAM_INSTRUCTIONS")] pub team_instructions: Option, @@ -549,7 +549,7 @@ pub struct Config { pub typing_enabled: bool, /// Whether NIP-AE agent core memory injection is enabled. When false, /// the harness skips the per-session core engram fetch and renders no - /// `[Agent Memory — core]` section. On by default; disabled via the + /// `` section. On by default; disabled via the /// `--no-memory` / `BUZZ_ACP_NO_MEMORY` opt-out. pub memory_enabled: bool, /// Desired LLM model ID. Applied after every `session_new_full()`. @@ -593,7 +593,7 @@ pub struct Config { /// Agent owner pubkey (hex). Used for `--respond-to=owner-only` gate. /// Replaces the old REST-based owner lookup. pub agent_owner: Option, - /// Disable the [Base] platform-context section prepended to every prompt. + /// Disable the `` platform-context section prepended to every prompt. pub no_base_prompt: bool, /// Resolved content from `--base-prompt-file`, read and validated in /// `from_cli()`. `None` when using the compiled-in default or when diff --git a/crates/buzz-acp/src/engram_fetch.rs b/crates/buzz-acp/src/engram_fetch.rs index 534d05837c0..d5ae6df0762 100644 --- a/crates/buzz-acp/src/engram_fetch.rs +++ b/crates/buzz-acp/src/engram_fetch.rs @@ -3,7 +3,7 @@ //! //! 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 a body is found, emit ``. //! - 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 @@ -17,9 +17,6 @@ use nostr::{Event, Keys, PublicKey}; 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. /// /// Wording is from Tyler's brief: "No core memory found. Use `buzz mem` @@ -42,8 +39,14 @@ pub async fn build_core_section( owner: &PublicKey, ) -> Option { 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)) => Some(crate::prompt_framing::semantic_section( + "core-memory", + &profile, + )), + Ok(None) => Some(crate::prompt_framing::semantic_section( + "core-memory", + ONBOARDING_NUDGE, + )), Err(reason) => { tracing::warn!( target: "engram::core", diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 146214197a8..a662fccc233 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -7,6 +7,7 @@ mod filter; mod observer; mod pool; mod pool_lifecycle; +mod prompt_framing; mod queue; mod relay; mod setup_mode; @@ -3653,7 +3654,7 @@ fn try_native_steer( // channel context and the actor's profile in the original prompt, // duplicating it here would defeat the point of non-cancelling // steering (which is to inject only what's new). - let (header, closing) = queue::native_steer_framing(); + let (tag, closing) = queue::native_steer_framing(); let event_id_hex = event.id.to_hex(); let be = queue::BatchEvent { event, @@ -3661,7 +3662,13 @@ fn try_native_steer( received_at: std::time::Instant::now(), }; let event_block = queue::format_event_block(channel_id, None, &be, None); - let body = format!("{header}\n\n[Buzz event: {prompt_tag}]\n{event_block}\n\n{closing}"); + let new_message = prompt_framing::semantic_section(tag, ""); + let event_section = prompt_framing::semantic_section_with_attributes( + "buzz-event", + &[("type", prompt_tag.as_str())], + &event_block, + ); + let body = format!("{new_message}\n\n{event_section}\n\n{closing}"); let (ack_tx, ack_rx) = tokio::sync::oneshot::channel::(); let request = pool::SteerRequest { @@ -4454,6 +4461,14 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("Do not ask about runtime, provider, model, credentials")); } + #[test] + fn shared_base_prompt_names_current_context_framing() { + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("UUID from ``")); + assert!(prompt.contains("reply destination supplied in the `` block")); + assert!(!prompt.contains("`[Context]`")); + } + #[test] fn shared_base_prompt_teaches_real_newlines_for_multiline_messages() { let prompt = include_str!("base_prompt.md"); @@ -5092,8 +5107,8 @@ mod heartbeat_base_prompt_tests { use super::*; // Pins the heartbeat dispatch path (dispatch_heartbeat, ~line 2359): a - // legacy agent WITH a base_prompt must get [Base] prepended to the - // heartbeat user message, composed as `[Base]\n{bp}\n\n{prompt}`. This is + // legacy agent WITH a base_prompt must get prepended to the + // heartbeat user message. This is // the second half of the round-2 regression (the first being initial_message). fn heartbeat_standing() -> queue::StandingContext<'static> { @@ -5106,12 +5121,12 @@ mod heartbeat_base_prompt_tests { #[test] fn test_heartbeat_legacy_agent_gets_base_prepended() { // protocol_version 1 + Some(base_prompt): heartbeat prompt is prefixed - // with the [Base] section exactly as the legacy session/new path would. + // with the section exactly as the legacy session/new path would. let prompt = "[System: Heartbeat]\nrun feed get"; let composed = pool::prepend_standing_for_legacy(1, &heartbeat_standing(), prompt); assert_eq!( composed, - "[Base]\nyou are a helpful agent\n\n[System: Heartbeat]\nrun feed get" + "\nyou are a helpful agent\n\n\n[System: Heartbeat]\nrun feed get" ); } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 38749577398..f5176d3f159 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -120,12 +120,12 @@ pub struct SessionState { pub turn_counts: HashMap, /// Turn counter for the heartbeat session. pub heartbeat_turn_count: u32, - /// Whether the live heartbeat session has successfully received `[Base]`. + /// Whether the live heartbeat session has successfully received ``. pub heartbeat_standing_context_sent: bool, /// 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, - /// channel_id → rendered `[Channel Canvas]` metadata section. + /// channel_id → rendered `` metadata section. /// /// Populated once before session creation (same lifecycle as `core_sections`). /// Absent when the channel has no canvas, the canvas content is blank, or the @@ -631,7 +631,7 @@ pub struct PromptContext { /// Whether NIP-AE agent core memory injection is enabled. When false, /// the per-session core engram fetch is skipped and `core_sections` /// remains empty for every channel, so `format_prompt` renders no - /// `[Agent Memory — core]` section. On by default; disabled via + /// `` section. On by default; disabled via /// `--no-memory` / `BUZZ_ACP_NO_MEMORY`. pub memory_enabled: bool, /// Harness identity string for NIP-AM `harness` field. Derived from the @@ -1015,8 +1015,8 @@ async fn create_session_and_apply_model( // single prompt. Standard protocol-v2 agents receive it in `session/new`; // Goose receives it through the custom request below. Legacy agents receive // the same content as user-message sections via `format_prompt`. Core carries - // its own `[Agent Memory — core]` header, and canvas carries its own - // `[Channel Canvas]` header; both are appended with a blank-line separator. + // its own `` boundary, and canvas carries its own + // `` boundary; both are appended with a blank-line separator. let is_goose = agent.agent_name == "goose"; let combined_system_prompt = with_canvas( with_huddle_instructions( @@ -1608,13 +1608,13 @@ pub(crate) fn prepend_standing_for_legacy( } /// Frame the `session/new` `systemPrompt` so each present prompt carries its own -/// header, keeping the base/workspace/persona boundaries recoverable downstream. +/// paired tag, keeping the base/workspace/persona boundaries recoverable downstream. /// /// The static base remains first for prompt-prefix caching. When a base is /// present, the dynamic workspace anchor follows it and precedes the user-owned /// agent instructions. A persona-only agent still yields -/// `[Agent Instructions]\n{persona}` rather than an unlabeled blob that would -/// be mislabeled as `[Base]`. +/// `` rather than an unlabeled blob that would be mistaken +/// for ``. fn framed_system_prompt( cwd: &str, base_prompt: Option<&str>, @@ -1622,34 +1622,42 @@ fn framed_system_prompt( ) -> Option { match (base_prompt, system_prompt) { (Some(bp), Some(sp)) => Some(format!( - "{}\n\n{}\n\n[Agent Instructions]\n{sp}", + "{}\n\n{}\n\n{}", crate::queue::base_section(bp), - workspace_section(cwd) + workspace_section(cwd), + crate::prompt_framing::semantic_section("system", sp), )), (Some(bp), None) => Some(format!( "{}\n\n{}", crate::queue::base_section(bp), workspace_section(cwd) )), - (None, Some(sp)) => Some(format!("[Agent Instructions]\n{sp}")), + (None, Some(sp)) => Some(crate::prompt_framing::semantic_section("system", sp)), (None, None) => None, } } fn workspace_section(cwd: &str) -> String { - format!("[Workspace]\nCurrent working directory: {cwd}") + crate::prompt_framing::semantic_section( + "workspace", + &format!("Current working directory: {cwd}"), + ) } -/// Append the team-owned instruction section after `[Agent Instructions]` and before core memory. +/// Append the team-owned instruction section after `` and before core memory. fn with_team(prompt: Option, instructions: Option<&str>) -> Option { let instructions = instructions .map(str::trim) .filter(|value| !value.is_empty()); match (prompt, instructions) { - (Some(prompt), Some(instructions)) => { - Some(format!("{prompt}\n\n[Team Instructions]\n{instructions}")) - } - (None, Some(instructions)) => Some(format!("[Team Instructions]\n{instructions}")), + (Some(prompt), Some(instructions)) => Some(format!( + "{prompt}\n\n{}", + crate::prompt_framing::semantic_section("team-instructions", instructions) + )), + (None, Some(instructions)) => Some(crate::prompt_framing::semantic_section( + "team-instructions", + instructions, + )), (Some(prompt), None) => Some(prompt), (None, None) => None, } @@ -1657,14 +1665,21 @@ fn with_team(prompt: Option, instructions: Option<&str>) -> Option` boundary from /// `engram_fetch::build_core_section`, so it is joined with a blank-line /// separator and never re-labeled. Either side may be absent. fn with_core(framed: Option, core: Option<&str>) -> Option { + let core = core.map(|core| { + crate::prompt_framing::normalize_semantic_section( + "core-memory", + "Agent Memory — core", + core, + ) + }); match (framed, core) { (Some(framed), Some(core)) => Some(format!("{framed}\n\n{core}")), (Some(framed), None) => Some(framed), - (None, Some(core)) => Some(core.to_string()), + (None, Some(core)) => Some(core), (None, None) => None, } } @@ -1675,25 +1690,36 @@ fn with_huddle_instructions(prompt: Option, instructions: Option<&str>) .map(str::trim) .filter(|value| !value.is_empty()); match (prompt, instructions) { - (Some(prompt), Some(instructions)) => { - Some(format!("{prompt}\n\n[Huddle Instructions]\n{instructions}")) - } - (None, Some(instructions)) => Some(format!("[Huddle Instructions]\n{instructions}")), + (Some(prompt), Some(instructions)) => Some(format!( + "{prompt}\n\n{}", + crate::prompt_framing::semantic_section("huddle-instructions", instructions) + )), + (None, Some(instructions)) => Some(crate::prompt_framing::semantic_section( + "huddle-instructions", + instructions, + )), (Some(prompt), None) => Some(prompt), (None, None) => None, } } -/// Append the `[Channel Canvas]` metadata section onto the accumulated system prompt. +/// Append the `` metadata section onto the accumulated system prompt. /// -/// The canvas section already carries its `[Channel Canvas]` header (from +/// The canvas section already carries its `` boundary (from /// `render_canvas_section`), so it is joined with a blank-line separator. /// Either side may be absent. fn with_canvas(prompt: Option, canvas: Option<&str>) -> Option { + let canvas = canvas.map(|canvas| { + crate::prompt_framing::normalize_semantic_section( + "channel-canvas", + "Channel Canvas", + canvas, + ) + }); match (prompt, canvas) { (Some(prompt), Some(canvas)) => Some(format!("{prompt}\n\n{canvas}")), (Some(prompt), None) => Some(prompt), - (None, Some(canvas)) => Some(canvas.to_string()), + (None, Some(canvas)) => Some(canvas), (None, None) => None, } } @@ -1831,7 +1857,7 @@ pub async fn run_prompt_task( // // Core memory is delivered inside the system prompt the harness already - // builds (system role for protocol >= 2, the `[Agent Instructions]` user-message + // builds (system role for protocol >= 2, the `` user-message // section for legacy agents). To put it on the wire at `session/new` for // modern agents, the fetch must run *before* the session is created — so // we do it here and cache the rendered section in `state.core_sections`. @@ -2288,7 +2314,7 @@ pub async fn run_prompt_task( // Heartbeats create their session before this point, so a Goose method-not-found // probe has already selected the correct framing for this process. // - // Only the first heartbeat of a session carries `[Base]`; later ticks + // Only the first heartbeat of a session carries ``; later ticks // reuse the same session, so the agent already has it. let text = if standing_context_sent { text @@ -3020,7 +3046,7 @@ fn huddle_instructions_from_query_response( } /// Fetch the latest canvas event for `channel_id` and return a rendered -/// `[Channel Canvas]` metadata section, or `None` if absent/blank/error. +/// `` metadata section, or `None` if absent/blank/error. /// /// Failure modes (all fail open — no crash, no block): /// * relay returns no event → `None` @@ -3083,7 +3109,7 @@ async fn fetch_canvas_section(channel_id: Uuid, rest: &RestClient) -> Option` section. /// /// Extracted as a pure function so tests can exercise the parsing/validation /// logic without async machinery or relay connectivity. @@ -3200,16 +3226,18 @@ pub(crate) fn canvas_section_from_query_response( Some(render_canvas_section(&id, ×tamp, channel_uuid)) } -/// Render the `[Channel Canvas]` metadata section string. +/// Render the `` metadata section string. /// /// Pure function — kept separate so unit tests can exercise rendering /// without async machinery or relay connectivity. pub(crate) fn render_canvas_section(event_id: &str, timestamp: &str, channel_uuid: &str) -> String { - format!( - "[Channel Canvas]\n\ - Canvas revision (event ID): {event_id}\n\ - Last modified: {timestamp}\n\ - Fetch current content with: buzz canvas get --channel {channel_uuid}" + crate::prompt_framing::semantic_section( + "channel-canvas", + &format!( + "Canvas revision (event ID): {event_id}\n\ + Last modified: {timestamp}\n\ + Fetch current content with: buzz canvas get --channel {channel_uuid}" + ), ) } @@ -4795,7 +4823,7 @@ mod tests { } // These pin the initial_message dispatch path (run_prompt_task, ~line 855): - // a legacy agent WITH a base_prompt must get [Base] prepended to the user + // a legacy agent WITH a base_prompt must get prepended to the user // message. This is the exact regression that shipped in the round-2 bug. fn base_only(base_prompt: Option<&str>) -> crate::queue::StandingContext<'_> { @@ -4807,14 +4835,17 @@ mod tests { #[test] fn test_initial_message_legacy_agent_gets_base_prepended() { - // protocol_version 1 + Some(base_prompt): [Base] rides along in the - // user message, composed as `[Base]\n{bp}\n\n{initial_msg}`. + // protocol_version 1 + Some(base_prompt): rides along in the + // user message. let composed = prepend_standing_for_legacy( 1, &base_only(Some("you are a helpful agent")), "hello channel", ); - assert_eq!(composed, "[Base]\nyou are a helpful agent\n\nhello channel"); + assert_eq!( + composed, + "\nyou are a helpful agent\n\n\nhello channel" + ); } #[test] @@ -4835,7 +4866,7 @@ mod tests { // construction — and it has never carried the persona. Pin that the // shared helper does not start handing heartbeats [Agent Instructions]. let composed = prepend_standing_for_legacy(1, &base_only(Some("be helpful")), "tick"); - assert_eq!(composed, "[Base]\nbe helpful\n\ntick"); + assert_eq!(composed, "\nbe helpful\n\n\ntick"); } #[test] @@ -4912,16 +4943,16 @@ mod tests { #[test] fn test_initial_message_legacy_agent_gets_whole_standing_block() { // The initial message is the legacy agent's first contact, so it must - // carry every standing section — not just [Base] and the canvas, which + // carry every standing section — not just and the canvas, which // left the agent acting on its first turn with no persona and no memory. let composed = prepend_standing_for_legacy(1, &full_standing(), "do the thing"); let positions: Vec = [ - "[Base]", - "[Agent Instructions]", - "[Team Instructions]", - "[Agent Memory — core]", - "[Huddle Instructions]", - "[Channel Canvas]", + "", + "", + "", + "", + "", + "", "do the thing", ] .iter() @@ -4966,7 +4997,7 @@ mod tests { } // Pin the session/new systemPrompt framing: each present prompt carries its - // own header so the desktop observer can split into labeled sub-sections. + // own paired tag so the desktop observer can split labeled sub-sections. #[test] fn test_framed_system_prompt_both_present_carries_both_headers() { @@ -4977,7 +5008,7 @@ mod tests { .expect("both present yields Some"); assert_eq!( framed, - "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace\n\n[Agent Instructions]\npersona text" + "\nbase text\n\n\n\nCurrent working directory: /workspace\n\n\n\npersona text\n" ); } @@ -4987,17 +5018,25 @@ mod tests { framed_system_prompt("/workspace", Some("base text"), None).expect("base yields Some"); assert_eq!( framed, - "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace" + "\nbase text\n\n\n\nCurrent working directory: /workspace\n" ); } #[test] fn test_framed_system_prompt_persona_only_labels_agent_instructions() { // A bare persona would be mislabeled "Base" downstream — it must carry - // its own [Agent Instructions] header even when no base prompt exists. + // its own boundary even when no base prompt exists. let framed = framed_system_prompt("/workspace", None, Some("persona text")) .expect("persona yields Some"); - assert_eq!(framed, "[Agent Instructions]\npersona text"); + assert_eq!(framed, "\npersona text\n"); + } + + #[test] + fn test_framed_system_prompt_preserves_persona_bytes_verbatim() { + let persona = "literal , , ", & "; + let framed = + framed_system_prompt("/workspace", None, Some(persona)).expect("persona yields Some"); + assert_eq!(framed, format!("\n{persona}\n")); } #[test] @@ -5009,7 +5048,7 @@ mod tests { fn test_workspace_section_preserves_windows_cwd() { assert_eq!( workspace_section(r"C:\Users\me\buzz"), - "[Workspace]\nCurrent working directory: C:\\Users\\me\\buzz" + "\nCurrent working directory: C:\\Users\\me\\buzz\n" ); } @@ -5022,7 +5061,7 @@ mod tests { .expect("both present yields Some"); assert_eq!( framed, - "[Agent Instructions]\npersona\n\n[Agent Memory — core]\nbe helpful" + "[Agent Instructions]\npersona\n\n\nbe helpful\n" ); } @@ -5037,7 +5076,7 @@ mod tests { fn test_with_core_core_only_is_just_core() { let framed = with_core(None, Some("[Agent Memory — core]\nbe helpful")) .expect("core-only yields Some"); - assert_eq!(framed, "[Agent Memory — core]\nbe helpful"); + assert_eq!(framed, "\nbe helpful\n"); } #[test] @@ -6031,10 +6070,13 @@ done"# .as_str() .expect("text prompt") }; - assert_eq!(prompt_text(0), "[Base]\nstanding-once\n\nheartbeat-1"); + assert_eq!( + prompt_text(0), + "\nstanding-once\n\n\nheartbeat-1" + ); assert_eq!( prompt_text(1), - "[Base]\nstanding-once\n\nheartbeat-2", + "\nstanding-once\n\n\nheartbeat-2", "retry after ACP failure must resend standing context" ); assert_eq!( @@ -6154,13 +6196,13 @@ done"# .as_str() .expect("text prompt") }; - assert!(prompt_text(0).contains("[Base]\nstanding-once")); + assert!(prompt_text(0).contains("\nstanding-once\n")); assert!( - prompt_text(1).contains("[Base]\nstanding-once"), + prompt_text(1).contains("\nstanding-once\n"), "retry after channel ACP failure must resend standing context" ); assert!( - !prompt_text(2).contains("[Base]\nstanding-once"), + !prompt_text(2).contains("\nstanding-once\n"), "turn after channel ACP success must omit standing context" ); } @@ -7962,7 +8004,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" fn huddle_instructions_append_as_system_section() { assert_eq!( with_huddle_instructions(Some("base".into()), Some(" reply now ")).as_deref(), - Some("base\n\n[Huddle Instructions]\nreply now") + Some("base\n\n\nreply now\n") ); } @@ -8019,10 +8061,11 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" let section = render_canvas_section(id, ts, uuid); assert_eq!( section, - "[Channel Canvas]\n\ + "\n\ Canvas revision (event ID): a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2c3d4e5f6a1b2\n\ Last modified: 2024-01-15T10:30:00+00:00\n\ - Fetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae" + Fetch current content with: buzz canvas get --channel 00f1ccaf-1506-4dd7-9a0e-fa67e9e486ae\n\ + " ); } @@ -8031,13 +8074,19 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" #[test] fn test_with_canvas_appends_to_existing_prompt() { let result = with_canvas(Some("base content".into()), Some("[Channel Canvas]\nstuff")); - assert_eq!(result.unwrap(), "base content\n\n[Channel Canvas]\nstuff"); + assert_eq!( + result.unwrap(), + "base content\n\n\nstuff\n" + ); } #[test] fn test_with_canvas_returns_canvas_alone_when_no_prompt() { let result = with_canvas(None, Some("[Channel Canvas]\nstuff")); - assert_eq!(result.unwrap(), "[Channel Canvas]\nstuff"); + assert_eq!( + result.unwrap(), + "\nstuff\n" + ); } #[test] @@ -8134,7 +8183,7 @@ printf '%s\n' '{{"jsonrpc":"2.0","id":0,"result":{{"stopReason":"end_turn"}}}}'" assert!(section.contains(&id), "section must contain the event id"); assert!(section.contains("buzz canvas get --channel")); assert!(section.contains(CHANNEL_UUID)); - assert!(section.starts_with("[Channel Canvas]")); + assert!(section.starts_with("")); // Timestamp must use Z suffix, not +00:00 assert!(section.contains('Z'), "timestamp must use Z suffix"); } diff --git a/crates/buzz-acp/src/prompt_framing.rs b/crates/buzz-acp/src/prompt_framing.rs new file mode 100644 index 00000000000..860f364db88 --- /dev/null +++ b/crates/buzz-acp/src/prompt_framing.rs @@ -0,0 +1,92 @@ +//! Shared framing for standing prompt context. + +/// Wrap one standing-context body in an explicit paired boundary. +/// +/// The body is intentionally preserved verbatim: agent-definition review +/// surfaces must show the same instructions that the model executes. +pub(crate) fn semantic_section(tag: &str, content: &str) -> String { + format!("<{tag}>\n{content}\n") +} + +/// Wrap content in a paired semantic boundary carrying existing header metadata. +/// +/// Only attribute values are escaped; the section body remains byte-for-byte +/// model-visible, matching [`semantic_section`]. +pub(crate) fn semantic_section_with_attributes( + tag: &str, + attributes: &[(&str, &str)], + content: &str, +) -> String { + let attributes = attributes + .iter() + .map(|(name, value)| format!(" {name}=\"{}\"", escape_attribute(value))) + .collect::(); + format!("<{tag}{attributes}>\n{content}\n") +} + +fn escape_attribute(value: &str) -> String { + value + .replace('&', "&") + .replace('"', """) + .replace('<', "<") + .replace('>', ">") +} + +/// Normalize an already-rendered or legacy bracket-framed standing section. +pub(crate) fn normalize_semantic_section(tag: &str, legacy_label: &str, content: &str) -> String { + if content.starts_with(&format!("<{tag}>")) && content.ends_with(&format!("")) { + return content.to_string(); + } + let legacy = format!("[{legacy_label}]\n"); + semantic_section(tag, content.strip_prefix(&legacy).unwrap_or(content)) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn semantic_section_preserves_model_visible_body_verbatim() { + assert_eq!( + semantic_section("system", "keep , , ", & "), + "\nkeep , , ", & \n" + ); + } + + #[test] + fn normalize_supports_legacy_and_already_semantic_sections() { + assert_eq!( + normalize_semantic_section( + "core-memory", + "Agent Memory — core", + "[Agent Memory — core]\nremember", + ), + "\nremember\n" + ); + let semantic = semantic_section("core-memory", "remember"); + assert_eq!( + normalize_semantic_section("core-memory", "Agent Memory — core", &semantic), + semantic + ); + } + + #[test] + fn semantic_section_preserves_body_whitespace() { + assert_eq!( + semantic_section("system", "\n keep this \n"), + "\n\n keep this \n\n" + ); + } + + #[test] + fn semantic_section_attributes_do_not_mutate_body() { + assert_eq!( + semantic_section_with_attributes( + "buzz-event", + &[("type", "say \"hi\" & ")], + "keep & ", + ), + "\nkeep & \n" + ); + } +} diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index 60866518bad..8fd0a02fe7e 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1249,17 +1249,17 @@ fn resolve_reply_anchor( ) } -/// Maximum length (in characters) of a channel description rendered into `[Context]`. +/// Maximum length (in characters) of a channel description rendered into ``. /// /// Limits prompt bloat from unusually long descriptions; a raw embedded newline -/// in a description must not be able to spoof another `[Context]` field, so +/// in a description must not be able to spoof another `` field, so /// multiline text is collapsed to single-space-joined lines before truncation. const MAX_DESCRIPTION_LEN: usize = 500; -/// Append a `Description: …` line to a `[Context]` block when non-empty. +/// Append a `Description: …` line to a `` body when non-empty. /// /// Collapses internal newlines (any `\r\n`, `\r`, or `\n`) to a single space -/// so a multi-line description cannot inject a fake `[Context]` field line. +/// so a multi-line description cannot inject a fake `` field line. /// Truncates at [`MAX_DESCRIPTION_LEN`] characters with a `…` marker. fn append_channel_description(s: &mut String, channel_info: Option<&PromptChannelInfo>) { let desc = match channel_info.and_then(|ci| ci.description.as_deref()) { @@ -1291,7 +1291,7 @@ fn append_channel_description(s: &mut String, channel_info: Option<&PromptChanne s.push_str(&format!("\nDescription: {truncated}")); } -/// Format a `[Context]` hints section based on event scope. +/// Format a `` hints section based on event scope. /// /// `reply_anchor` is the pre-resolved `--reply-to` target for this turn (see /// [`resolve_reply_anchor`]). In the thread/DM branches it threads ordinary @@ -1332,8 +1332,7 @@ fn format_context_hints( "Use `buzz messages get --channel ` for conversation context." }; let mut s = format!( - "[Context]\n\ - Scope: dm\n\ + "Scope: dm\n\ Channel: {channel_display}\n\ {ctx_hint}" ); @@ -1349,7 +1348,7 @@ fn format_context_hints( append_reply_instruction(&mut s, event_id); } } - s + crate::prompt_framing::semantic_section("context", &s) } else if let Some(ref root) = thread_tags.root_event_id { let ctx_hint = if has_conversation_context { "Thread context included below. Use `buzz messages thread --channel --event ` for full history if truncated." @@ -1359,8 +1358,7 @@ fn format_context_hints( "Use `buzz messages thread --channel --event ` to fetch thread context." }; let mut s = format!( - "[Context]\n\ - Scope: thread\n\ + "Scope: thread\n\ Channel: {channel_display}" ); append_channel_description(&mut s, channel_info); @@ -1374,11 +1372,10 @@ fn format_context_hints( if let Some(event_id) = reply_anchor { append_reply_instruction(&mut s, event_id); } - s + crate::prompt_framing::semantic_section("context", &s) } else { let mut s = format!( - "[Context]\n\ - Scope: channel\n\ + "Scope: channel\n\ Channel: {channel_display}" ); append_channel_description(&mut s, channel_info); @@ -1388,7 +1385,7 @@ fn format_context_hints( if let Some(event_id) = reply_anchor { append_new_thread_reply_instruction(&mut s, event_id); } - s + crate::prompt_framing::semantic_section("context", &s) } } @@ -1397,34 +1394,44 @@ fn format_conversation_context( ctx: &ConversationContext, profile_lookup: Option<&PromptProfileLookup>, ) -> String { - let (label, messages, total, truncated) = match ctx { + let (tag, messages, total, truncated) = match ctx { ConversationContext::Thread { messages, total, truncated, - } => ("Thread Context", messages, total, truncated), + } => ("thread-context", messages, total, truncated), ConversationContext::Dm { messages, total, truncated, - } => ("Conversation Context", messages, total, truncated), + } => ("conversation-context", messages, total, truncated), }; - let trunc_label = if *truncated { ", truncated" } else { "" }; - let mut s = format!( - "[{label} ({} of {total} messages{trunc_label})]", - messages.len() - ); + let mut body = String::new(); for (i, msg) in messages.iter().enumerate() { - s.push_str(&format!( - "\n[{}] {} ({}): {}", + if !body.is_empty() { + body.push('\n'); + } + body.push_str(&format!( + "[{}] {} ({}): {}", i + 1, format_prompt_actor(&msg.pubkey, profile_lookup), msg.timestamp, msg.content, )); } - s + let included = messages.len().to_string(); + let total = total.to_string(); + let truncated = truncated.to_string(); + crate::prompt_framing::semantic_section_with_attributes( + tag, + &[ + ("included", included.as_str()), + ("total", total.as_str()), + ("truncated", truncated.as_str()), + ], + &body, + ) } /// Arguments for [`format_prompt`] beyond the required [`FlushBatch`]. @@ -1441,15 +1448,15 @@ pub struct FormatPromptArgs<'a> { pub profile_lookup: Option<&'a PromptProfileLookup>, /// When true, base_prompt and system_prompt are delivered via the system /// role (session/new) and omitted from the user message. When false - /// (legacy agents), they are injected as `[Base]` and `[Agent Instructions]` sections. + /// (legacy agents), they are injected as `` and `` sections. pub has_system_prompt_support: bool, /// Base prompt content for legacy agents (protocol_version < 2). pub base_prompt: Option<&'a str>, /// System prompt content for legacy agents (protocol_version < 2). pub system_prompt: Option<&'a str>, - /// Team instructions for legacy agents, rendered after `[Agent Instructions]`. + /// Team instructions for legacy agents, rendered after ``. pub team_instructions: Option<&'a str>, - /// Rendered `[Channel Canvas]` metadata section for legacy agents. + /// Rendered `` metadata section for legacy agents. /// /// For modern agents (protocol_version >= 2) the section is delivered via /// the system role in session/new; omit here to avoid duplication. @@ -1493,50 +1500,64 @@ impl StandingContext<'_> { sections.push(base_section(bp)); } if let Some(sp) = self.system_prompt { - sections.push(format!("[Agent Instructions]\n{sp}")); + sections.push(crate::prompt_framing::semantic_section("system", sp)); } if let Some(team) = self .team_instructions .map(str::trim) .filter(|value| !value.is_empty()) { - sections.push(format!("[Team Instructions]\n{team}")); + sections.push(crate::prompt_framing::semantic_section( + "team-instructions", + team, + )); } if let Some(core) = self.agent_core { - sections.push(core.to_string()); + sections.push(crate::prompt_framing::normalize_semantic_section( + "core-memory", + "Agent Memory — core", + core, + )); } if let Some(instructions) = self .huddle_instructions .map(str::trim) .filter(|value| !value.is_empty()) { - sections.push(format!("[Huddle Instructions]\n{instructions}")); + sections.push(crate::prompt_framing::semantic_section( + "huddle-instructions", + instructions, + )); } if let Some(canvas) = self.agent_canvas { - sections.push(canvas.to_string()); + sections.push(crate::prompt_framing::normalize_semantic_section( + "channel-canvas", + "Channel Canvas", + canvas, + )); } sections } } -/// Format the `[Base]` section for the base prompt. +/// Format the `` section for the base prompt. /// -/// Single source of truth for the `[Base]` framing so the format is defined in +/// Single source of truth for the `` framing so the format is defined in /// exactly one place across all dispatch paths (batch flush, heartbeat, /// initial message). pub(crate) fn base_section(base_prompt: &str) -> String { - format!("[Base]\n{}", base_prompt.trim_end()) + crate::prompt_framing::semantic_section("base", base_prompt.trim_end()) } /// Format a [`FlushBatch`] into the per-section prompt blocks for the agent. /// /// Produces a stable prompt with these sections (in order): -/// 0. [`StandingContext`] — `[Base]`, `[Agent Instructions]`, `[Team Instructions]`, -/// `[Agent Memory — core]`, `[Channel Canvas]`. Legacy agents only, and only +/// 0. [`StandingContext`] — ``, ``, ``, +/// ``, ``, ``. Legacy agents only, and only /// on the session's first message (see `standing_context_sent`) -/// 1. `[Context]` — scope, channel name, and contextual hints for the agent -/// 2. `[Thread Context]` or `[Conversation Context]` — if fetched -/// 3. `[Event]` / `[Buzz events]` — the triggering event(s) +/// 1. `` — scope, channel name, and contextual hints for the agent +/// 2. `` or `` — if fetched +/// 3. `` / `` — the triggering event(s) /// /// Each section is returned as its own block rather than one joined string so /// the observer frame's size trimmer (`fit_observer_event_to_budget`) elides @@ -1634,55 +1655,71 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec) -> Vec MergeFraming { - prior_header: "[Previous request — interrupted before completion]", - new_header_single: "[New request — supersedes previous]", - new_header_multi_prefix: "[New request — supersedes previous", + prior_tag: "previous-request-interrupted-before-completion", + new_tag: "new-request-supersedes-previous", closing_note: "Note: The previous request was interrupted. Please address the new \ request.\nIf the new request is unrelated to the previous one, you may \ briefly acknowledge the interruption.", @@ -1744,7 +1776,7 @@ impl MergeFraming { /// pulled from the same source-of-truth as the cancel+merge fallback /// (`MergeFraming::for_reason(Some(CancelReason::Steer))`). /// -/// Returns `(new_header_single, closing_note)`. Native-steer renders only +/// Returns `(new_tag, closing_note)`. Native-steer renders only /// the new-message header + the single event block + the closing note — /// no `prior_header`, no original-request section, because the in-flight /// goose turn already has all of that in context. The two paths share @@ -1753,7 +1785,7 @@ impl MergeFraming { /// requirement: native and fallback must not diverge in UX). pub(crate) fn native_steer_framing() -> (&'static str, &'static str) { let framing = MergeFraming::for_reason(Some(CancelReason::Steer)); - (framing.new_header_single, framing.closing_note) + (framing.new_tag, framing.closing_note) } #[cfg(test)] @@ -1821,12 +1853,14 @@ mod tests { #[test] fn test_base_section_prepends_header_and_trims_trailing_whitespace() { - // Trailing whitespace/newlines are stripped; the [Base] header is - // prepended exactly once with a single newline separator. - assert_eq!(base_section("hello \n\n"), "[Base]\nhello"); - assert_eq!(base_section("hello"), "[Base]\nhello"); + // Trailing whitespace/newlines are stripped and the boundary is paired. + assert_eq!(base_section("hello \n\n"), "\nhello\n"); + assert_eq!(base_section("hello"), "\nhello\n"); // Internal newlines and leading whitespace are preserved verbatim. - assert_eq!(base_section(" line1\nline2 "), "[Base]\n line1\nline2"); + assert_eq!( + base_section(" line1\nline2 "), + "\n line1\nline2\n" + ); } #[test] @@ -2010,10 +2044,10 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); - // Should contain [Context] section before the event. - assert!(prompt.contains("[Context]")); + // Should contain the context section before the event. + assert!(prompt.contains("")); assert!(prompt.contains("Scope: channel")); - assert!(prompt.contains("[Buzz event: @mention]\n")); + assert!(prompt.contains("\n")); assert!(prompt.contains(&format!("Channel: {}", ch))); assert!(prompt.contains(&format!("From: {}", npub))); assert!(prompt.contains("Content: Hello @agent")); @@ -2074,11 +2108,11 @@ mod tests { // Interrupt framing: the new request supersedes the previous one. assert!( - prompt.contains("supersedes previous"), + prompt.contains(""), "interrupt prompt should use supersede framing: {prompt}" ); assert!( - prompt.contains("interrupted before completion"), + prompt.contains(""), "interrupt prompt should label the prior work as interrupted: {prompt}" ); assert!( @@ -2146,7 +2180,7 @@ mod tests { ); // The honest prior header (no overclaimed partial-work capture). assert!( - prompt.contains("[What you were working on]"), + prompt.contains(""), "steer prior header must be the honest variant: {prompt}" ); // Both the original work and the steering message survive the merge. @@ -2180,7 +2214,7 @@ mod tests { cancel_reason: Some(CancelReason::Steer), }; let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); - assert!(prompt.contains("New messages — arrived while you were working — 2 events]")); + assert!(prompt.contains("")); assert!(!prompt.contains("supersedes")); } @@ -2245,7 +2279,7 @@ mod tests { "reply instruction must NOT target the original thread: {prompt}" ); // Steer framing still frames the original as in-progress work to continue. - assert!(prompt.contains("[What you were working on]")); + assert!(prompt.contains("")); assert!(prompt.contains("arrived while you were working")); assert!(!prompt.contains("supersedes")); } @@ -2411,8 +2445,8 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); - assert!(prompt.contains("[Context]")); - assert!(prompt.contains("[Buzz events — 3 events]")); + assert!(prompt.contains("")); + assert!(prompt.contains("")); assert!(prompt.contains("--- Event 1 (tag-a) ---")); assert!(prompt.contains("--- Event 2 (tag-b) ---")); assert!(prompt.contains("--- Event 3 (tag-c) ---")); @@ -2442,7 +2476,7 @@ mod tests { // so they must NOT appear in the user message. assert!(!prompt.contains("[Agent Instructions]")); assert!(!prompt.contains("[Base]")); - assert!(prompt.starts_with("[Context]")); + assert!(prompt.starts_with("")); } #[test] @@ -2469,8 +2503,8 @@ mod tests { ) .join("\n\n"); assert!( - prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]"), - "expected core block first, then [Context]; got: {prompt}" + prompt.starts_with("\nbe helpful\n\n\n"), + "expected core block first, then ; got: {prompt}" ); } @@ -2504,7 +2538,7 @@ mod tests { !prompt.contains("[Agent Memory — core]"), "modern agents must not get core in the user message; got: {prompt}" ); - assert!(prompt.starts_with("[Context]")); + assert!(prompt.starts_with("")); } #[test] @@ -2530,7 +2564,7 @@ mod tests { }, ) .join("\n\n"); - assert!(prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]")); + assert!(prompt.starts_with("\nbe helpful\n\n\n")); } #[test] @@ -2554,7 +2588,7 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!(!prompt.contains("[Base]")); assert!(!prompt.contains("[Agent Instructions]")); - assert!(prompt.starts_with("[Context]")); + assert!(prompt.starts_with("")); } #[test] @@ -2588,31 +2622,28 @@ mod tests { // Both sections must be present assert!( - prompt.contains("[Base]\ntest base prompt"), - "missing [Base] section" + prompt.contains("\ntest base prompt\n"), + "missing section" ); assert!( - prompt.contains("[Agent Instructions]\ntest system prompt"), - "missing [Agent Instructions] section" + prompt.contains("\ntest system prompt\n"), + "missing section" ); - // [Base] and [Agent Instructions] must appear BEFORE [Agent Memory] and [Context] - let base_pos = prompt.find("[Base]").unwrap(); - let system_pos = prompt.find("[Agent Instructions]").unwrap(); - let core_pos = prompt.find("[Agent Memory").unwrap(); - let context_pos = prompt.find("[Context]").unwrap(); + // and must appear before and . + let base_pos = prompt.find("").unwrap(); + let system_pos = prompt.find("").unwrap(); + let core_pos = prompt.find("").unwrap(); + let context_pos = prompt.find("").unwrap(); - assert!( - base_pos < system_pos, - "[Base] should come before [Agent Instructions]" - ); + assert!(base_pos < system_pos, " should come before "); assert!( system_pos < core_pos, - "[Agent Instructions] should come before [Agent Memory]" + " should come before " ); assert!( core_pos < context_pos, - "[Agent Memory] should come before [Context]" + " should come before " ); } @@ -2651,17 +2682,17 @@ mod tests { let later = format_prompt(&batch, &args(true)).join("\n\n"); for section in [ - "[Base]", - "[Agent Instructions]", - "[Team Instructions]", - "[Agent Memory — core]", - "[Channel Canvas]", + "", + "", + "", + "", + "", ] { assert!(first.contains(section), "first message missing {section}"); assert!(!later.contains(section), "turn 2 repeated {section}"); } // What the turn is actually about survives, and now leads. - assert!(later.starts_with("[Context]"), "got: {later}"); + assert!(later.starts_with(""), "got: {later}"); assert!(later.contains("hello")); assert!( later.len() < first.len(), @@ -2707,7 +2738,7 @@ mod tests { !prompt.contains("[Agent Instructions]"), "[Agent Instructions] should be suppressed for modern agents" ); - assert!(prompt.starts_with("[Context]")); + assert!(prompt.starts_with("")); } #[test] @@ -2747,22 +2778,20 @@ mod tests { ) .join("\n\n"); - // Verify section ordering: [Agent Memory] < [Context] < [Thread Context] - let core_pos = prompt - .find("[Agent Memory") - .expect("[Agent Memory] missing"); - let context_pos = prompt.find("[Context]").expect("[Context] missing"); + // Verify section ordering: core memory < context < thread context. + let core_pos = prompt.find("").expect(" missing"); + let context_pos = prompt.find("").expect(" missing"); let thread_pos = prompt - .find("[Thread Context") - .expect("[Thread Context] missing"); + .find(" missing"); assert!( core_pos < context_pos, - "[Agent Memory] must come before [Context]" + " must come before " ); assert!( context_pos < thread_pos, - "[Context] must come before [Thread Context]" + " must come before " ); // No [Base] or [Agent Instructions] in user message assert!(!prompt.contains("[Base]")); @@ -3394,7 +3423,7 @@ mod tests { }, ) .join("\n\n"); - assert!(prompt.contains("[Thread Context (2 of 5 messages, truncated)]")); + assert!(prompt.contains("")); assert!(prompt.contains("Let's refactor auth")); assert!(prompt.contains("Thread context included below")); } @@ -3439,7 +3468,8 @@ mod tests { ) .join("\n\n"); assert!(prompt.contains("Scope: dm")); - assert!(prompt.contains("[Conversation Context (1 of 1 messages)]")); + assert!(prompt + .contains("")); assert!(prompt.contains("Can you deploy?")); } @@ -3758,7 +3788,7 @@ mod tests { assert!(prompt.contains("Earlier thread context was already delivered in this session")); assert!(prompt.contains("buzz messages thread")); assert!(!prompt.contains("Thread context included below")); - assert!(!prompt.contains("[Thread Context")); + assert!(!prompt.contains(""), "legacy agent prompt must include canvas section; got: {prompt}" ); } @@ -5165,7 +5195,7 @@ mod tests { channel_type: "stream".into(), description: Some("Engineering discussions".into()), }; - let mut s = "[Context]\nScope: channel\nChannel: team (#abc)".to_string(); + let mut s = "Scope: channel\nChannel: team (#abc)".to_string(); append_channel_description(&mut s, Some(&ci)); assert!( s.contains("\nDescription: Engineering discussions"), @@ -5180,7 +5210,7 @@ mod tests { channel_type: "stream".into(), description: None, }; - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); assert!( !s.contains("Description:"), @@ -5190,7 +5220,7 @@ mod tests { #[test] fn test_append_channel_description_absent_when_channel_info_none() { - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, None); assert!( !s.contains("Description:"), @@ -5200,13 +5230,13 @@ mod tests { #[test] fn test_append_channel_description_collapses_newlines_spoof_prevention() { - // A multiline description must not be able to inject a fake [Context] field. + // A multiline description must not be able to inject a fake field. let ci = PromptChannelInfo { name: "team".into(), channel_type: "stream".into(), description: Some("Line one\nScope: injected\nLine two".into()), }; - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); // The whole description is on a single Description line — no injected field. let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); @@ -5229,7 +5259,7 @@ mod tests { channel_type: "stream".into(), description: Some(long_desc), }; - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); assert!( @@ -5254,7 +5284,7 @@ mod tests { channel_type: "stream".into(), description: Some(long_desc), }; - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); let desc_line = s.lines().find(|l| l.starts_with("Description:")).unwrap(); let value = desc_line.strip_prefix("Description: ").unwrap(); @@ -5268,7 +5298,7 @@ mod tests { channel_type: "stream".into(), description: Some("\n \r\n \n".into()), }; - let mut s = "[Context]\nScope: channel".to_string(); + let mut s = "Scope: channel".to_string(); append_channel_description(&mut s, Some(&ci)); assert!( !s.contains("Description:"), @@ -5313,7 +5343,7 @@ mod tests { ); assert!( prompt.contains("Description: Engineering discussions and planning."), - "description must appear in [Context] for channel turns; got: {prompt}" + "description must appear in for channel turns; got: {prompt}" ); } @@ -5350,7 +5380,7 @@ mod tests { ); assert!( prompt.contains("Description: Engineering discussions and planning."), - "description must appear in [Context] for thread turns; got: {prompt}" + "description must appear in for thread turns; got: {prompt}" ); } diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs index b4a139eb0ee..c8cfd30088e 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscript.test.mjs @@ -103,6 +103,54 @@ test("buildTranscript renders Prompt context + user message for a multi-block se assert.equal(userMessage.messageId, PROMPT_EVENT_ID); }); +test("buildTranscript preserves a slash-command preamble before semantic prompt blocks", () => { + const authorPubkey = "a".repeat(64); + const event = { + ...baseEvent, + payload: { + method: "session/prompt", + params: { + sessionId: "sess-1", + prompt: [ + { type: "text", text: "/goal ship it" }, + { + type: "text", + text: "\nScope: channel\n", + }, + { + type: "text", + text: [ + '', + `Event ID: ${PROMPT_EVENT_ID.toUpperCase()}`, + "Channel: agents", + "Kind: 40002", + `From: Eva (hex: ${authorPubkey})`, + "Content: @Eva /goal ship it", + "", + ].join("\n"), + }, + ], + }, + }, + }; + + const items = buildTranscript([event]); + const userMessage = items.find((item) => item.type === "message"); + assert.equal(userMessage?.text, "@Eva /goal ship it"); + assert.equal(userMessage?.title, "@Mention"); + assert.equal(userMessage?.authorPubkey, authorPubkey); + assert.equal(userMessage?.messageId, PROMPT_EVENT_ID); + + const promptContext = items.find( + (item) => item.type === "metadata" && item.title === "Prompt context", + ); + assert.deepEqual( + promptContext?.sections.map((section) => section.title), + ["Prompt", "Context", "Buzz event: @mention"], + ); + assert.equal(promptContext?.sections[0]?.body, "/goal ship it"); +}); + test("buildTranscript falls back to a single turn trigger id for older prompt frames", () => { const promptEvent = { ...baseEvent, diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index dfb8eb22fbd..63bf1597cb8 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -19,12 +19,12 @@ import { extractBlockText, extractContentText, extractPlanText, - extractPromptText, + extractPromptBlocks, extractTriggeringEventIds, extractToolArgs, extractToolIdentity, extractToolResult, - parsePromptText, + parsePromptBlocks, parseSystemPromptSections, } from "./agentSessionTranscriptHelpers"; import { friendlyTurnErrorCopy } from "../lib/friendlyAgentLastError"; @@ -839,9 +839,9 @@ export function processTranscriptEvent( } } } else if (event.kind === "acp_write" && method === "session/prompt") { - const promptText = extractPromptText(payload); - if (promptText) { - const parsedPrompt = parsePromptText(promptText); + const promptBlocks = extractPromptBlocks(payload); + if (promptBlocks.length > 0) { + const parsedPrompt = parsePromptBlocks(promptBlocks); if (parsedPrompt.userText) { upsertMessage( d, @@ -871,7 +871,7 @@ export function processTranscriptEvent( } } else if (event.kind === "acp_write" && method === "session/new") { // The base + persona prompts ride session/new's systemPrompt, framed by - // the harness as [Base]/[Agent Instructions]/[Agent Memory — core]/[Channel Canvas]. + // the harness as ///. // claude-agent-acp uses _meta.systemPrompt.append instead; both paths // produce the same standalone card (turnId: null, acpSource "session/new"); // the bare field takes precedence when both are present. @@ -898,9 +898,9 @@ export function processTranscriptEvent( event.kind === "acp_write" && method === "_goose/unstable/session/steer" ) { - const promptText = extractPromptText(payload); - if (promptText) { - const parsedPrompt = parsePromptText(promptText); + const promptBlocks = extractPromptBlocks(payload); + if (promptBlocks.length > 0) { + const parsedPrompt = parsePromptBlocks(promptBlocks); if (parsedPrompt.userText) { upsertMessage( d, diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index 23df1e5f2b2..aa913919226 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -136,6 +136,111 @@ test("parsePromptText leading text before a header becomes a Prompt section", () ); }); +test("parsePromptText splits a legacy tagged standing prefix from the dynamic turn", () => { + const text = [ + "", + "platform context", + "", + "", + "", + "persona context", + "", + "", + "[Context]", + "Scope: channel", + "", + "[Buzz event: @mention]", + "Event ID: abc123", + "From: Alice (hex: AABBCC)", + "Content: ship it", + ].join("\n"); + + const parsed = parsePromptText(text); + + assert.equal(parsed.userText, "ship it"); + assert.deepEqual( + parsed.sections.map((section) => section.title), + ["Base", "System", "Context", "Buzz event: @mention"], + ); +}); + +test("parsePromptText splits paired top-level turn sections and preserves inner framing", () => { + const text = [ + "", + "Scope: thread", + "", + "", + '', + "[1] Alice (2026-08-25T12:00:00Z): prior message", + "", + "", + '', + "Event ID: abc123", + "From: Alice (hex: AABBCC)", + "Content: ship it", + "", + ].join("\n"); + + const parsed = parsePromptText(text); + + assert.equal(parsed.userText, "ship it"); + assert.deepEqual(parsed.sections, [ + { title: "Context", body: "Scope: thread" }, + { + title: "Thread Context (1 of 3 messages, truncated)", + body: "[1] Alice (2026-08-25T12:00:00Z): prior message", + }, + { + title: "Buzz event: @mention", + body: "Event ID: abc123\nFrom: Alice (hex: AABBCC)\nContent: ship it", + }, + ]); +}); + +test("parsePromptText preserves batched steer and interrupt counts in section titles", () => { + const cases = [ + { + tag: "new-message-arrived-while-you-were-working", + count: "2", + title: "New messages — arrived while you were working — 2 events", + }, + { + tag: "new-request-supersedes-previous", + count: "3", + title: "New request — supersedes previous — 3 events", + }, + ]; + + for (const { tag, count, title } of cases) { + const text = [ + `<${tag} count="${count}">`, + "--- Event 1 (message) ---", + "Content: update", + ``, + ].join("\n"); + + const parsed = parsePromptText(text); + + assert.equal(parsed.sections[0]?.title, title); + } +}); + +test("parsePromptText falls back to the complete prompt for ambiguous turn tags", () => { + const text = [ + "", + "literal authored boundary: ", + "", + '', + "Content: hello", + "", + ].join("\n"); + + const parsed = parsePromptText(text); + + assert.deepEqual(parsed.sections, [{ title: "Prompt", body: text }]); + assert.equal(parsed.userText, ""); +}); + test("extractPromptText joins text blocks from params.prompt", () => { const payload = { params: { @@ -201,6 +306,118 @@ test("parseSystemPromptSections splits both prompts into Base and System", () => ]); }); +test("parseSystemPromptSections reads paired standing-context tags", () => { + const framed = [ + "", + "base text", + "", + "", + "", + "Current working directory: /workspace", + "", + "", + "", + "persona text", + "", + "", + "", + "team text", + "", + "", + "", + "memory text", + "", + "", + "", + "reply now", + "", + "", + "", + "canvas text", + "", + ].join("\n"); + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "Base", body: "base text" }, + { + title: "Workspace", + body: "Current working directory: /workspace", + }, + { title: "System", body: "persona text" }, + { title: "Team Instructions", body: "team text" }, + { title: "Core Memory", body: "memory text" }, + { title: "Huddle Instructions", body: "reply now" }, + { title: "Channel Canvas", body: "canvas text" }, + ]); +}); + +test("parseSystemPromptSections keeps paired-tag examples literal in legacy personas", () => { + const framed = [ + "[Base]", + "platform rules", + "", + "[System]", + "Teach users this example:", + "", + "untrusted text", + "", + "Then continue following the real persona.", + ].join("\n"); + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "Base", body: "platform rules" }, + { + title: "System", + body: [ + "Teach users this example:", + "", + "untrusted text", + "", + "Then continue following the real persona.", + ].join("\n"), + }, + ]); +}); + +test("parseSystemPromptSections shows the complete prompt when semantic framing has trailing text", () => { + const framed = [ + "", + "base text", + "", + "unframed trailing text", + ].join("\n"); + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "Prompt", body: framed }, + ]); +}); + +test("parseSystemPromptSections preserves literal entity text in standing-context bodies", () => { + const framed = + "\nliteral </system> & <policy>\n"; + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "System", body: "literal </system> & <policy>" }, + ]); +}); + +test("parseSystemPromptSections shows the captured prompt literally when paired tags are ambiguous", () => { + const framed = + "\nkeep , , ", & \n"; + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "Prompt", body: framed }, + ]); +}); + +test("parseSystemPromptSections preserves authored boundary whitespace", () => { + const framed = "\n\n keep this \n\n"; + + assert.deepEqual(parseSystemPromptSections(framed), [ + { title: "System", body: "\n keep this \n" }, + ]); +}); + test("parseSystemPromptSections splits current Base and Agent Instructions framing", () => { const framed = "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace\n\n[Agent Instructions]\npersona text"; diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 87cf8ec2dfa..01feb69d82f 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -6,11 +6,46 @@ import { } from "./agentSessionToolCatalog"; import { asRecord, asString, titleCase } from "./agentSessionUtils"; -export function extractPromptText(payload: Record): string { +export function extractPromptBlocks( + payload: Record, +): string[] { const params = asRecord(payload.params); const prompt = params.prompt; - if (!Array.isArray(prompt)) return ""; - return prompt.map(extractBlockText).filter(Boolean).join("\n"); + if (!Array.isArray(prompt)) return []; + return prompt.map(extractBlockText).filter(Boolean); +} + +export function extractPromptText(payload: Record): string { + return extractPromptBlocks(payload).join("\n"); +} + +const SEMANTIC_PROMPT_SECTION_START = + /^\s*<(?:workspace|base|system|team-instructions|core-memory|huddle-instructions|channel-canvas|context|thread-context|conversation-context|buzz-event|buzz-events|what-you-were-working-on|new-message-arrived-while-you-were-working|previous-request-interrupted-before-completion|new-request-supersedes-previous)(?:\s[^>]*)?>/; + +/** + * Parse ACP prompt blocks without losing the connector-facing slash-command + * boundary. The harness emits that command as block zero and semantic prompt + * sections in subsequent blocks; arbitrary leading text remains on the normal + * parsing path. + */ +export function parsePromptBlocks( + blocks: readonly string[], +): ReturnType { + const [firstBlock, ...remainingBlocks] = blocks; + const hasSlashCommandPreamble = + /^\/[A-Za-z0-9]/.test(firstBlock?.trimStart() ?? "") && + remainingBlocks.length > 0 && + SEMANTIC_PROMPT_SECTION_START.test(remainingBlocks[0]); + + if (!hasSlashCommandPreamble) { + return parsePromptText(blocks.join("\n")); + } + + const parsed = parsePromptText(remainingBlocks.join("\n")); + return { + ...parsed, + sections: [{ title: "Prompt", body: firstBlock }, ...parsed.sections], + }; } export function parsePromptText(text: string): { @@ -20,9 +55,13 @@ export function parsePromptText(text: string): { userPubkey: string | null; userEventId: string | null; } { - const sections = parsePromptSections(text).filter( - (s) => s.body.trim().length > 0, - ); + const semanticPrefix = splitSemanticStandingPrefix(text); + const semanticTurn = splitSemanticTurnSections(semanticPrefix.remainder); + const sections = [ + ...semanticPrefix.sections, + ...semanticTurn.sections, + ...parsePromptSections(semanticTurn.remainder), + ].filter((s) => s.body.trim().length > 0); if (sections.length === 0) { return { sections: [], @@ -56,11 +95,11 @@ export function parsePromptText(text: string): { } /** - * Split the framed `session/new` `systemPrompt` into its `Base`/`Agent Instructions`/ - * `Team Instructions`/`Core Memory`/`Channel Canvas` sub-sections - * deterministically. + * Split `session/new`'s paired standing-context tags into transcript sections. + * The bracket parser is retained below for observer history captured before + * the framing experiment. * - * The harness composes the value in order: + * Archived harness versions composed the value in order: * `[Base]\n{base}\n\n[Agent Instructions]\n{persona}\n\n[Team Instructions]\n{team}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}` * with any section omitted when absent. Extraction runs in reverse producer * order so that each `lastIndexOf` search operates on the full input and each @@ -99,6 +138,9 @@ export function parsePromptText(text: string): { export function parseSystemPromptSections( systemPrompt: string, ): PromptSection[] { + const semantic = parseSemanticStandingSections(systemPrompt); + if (semantic) return semantic; + const sections: PromptSection[] = []; // ── 1. Extract [Channel Canvas] ─────────────────────────────────────────── @@ -277,6 +319,183 @@ export function parseSystemPromptSections( return sections; } +/** + * Split current paired-tag standing context while retaining the bracket parser + * below for observer history captured before the framing experiment. + */ +function parseSemanticStandingSections( + systemPrompt: string, +): PromptSection[] | null { + const titles: Record = { + workspace: "Workspace", + base: "Base", + system: "System", + "team-instructions": "Team Instructions", + "core-memory": "Core Memory", + "huddle-instructions": "Huddle Instructions", + "channel-canvas": "Channel Canvas", + }; + const tags = Object.keys(titles).join("|"); + // Archived bracket-framed personas may contain literal balanced tag examples. + // Only classify a capture as semantic when its framing starts at the input boundary. + if (!new RegExp(`^\\s*<(${tags})>`).test(systemPrompt)) return null; + + const parsed = splitSemanticStandingPrefix(systemPrompt); + // Current producers emit only paired sections separated by whitespace. Any + // other text makes the boundary ambiguous, so show the complete capture. + if (parsed.sections.length > 0 && parsed.remainder.trim().length === 0) { + return parsed.sections; + } + + return [{ title: "Prompt", body: systemPrompt }]; +} + +function splitSemanticStandingPrefix(text: string): { + sections: PromptSection[]; + remainder: string; +} { + const sections: PromptSection[] = []; + let remainder = text; + const tags = [ + "workspace", + "base", + "system", + "team-instructions", + "core-memory", + "huddle-instructions", + "channel-canvas", + ].join("|"); + const titles: Record = { + workspace: "Workspace", + base: "Base", + system: "System", + "team-instructions": "Team Instructions", + "core-memory": "Core Memory", + "huddle-instructions": "Huddle Instructions", + "channel-canvas": "Channel Canvas", + }; + if (hasAmbiguousSemanticBoundary(text, Object.keys(titles))) { + return { sections, remainder: text }; + } + const leadingSection = new RegExp(`^\\s*<(${tags})>([\\s\\S]*?)<\\/\\1>\\s*`); + + for (;;) { + const match = remainder.match(leadingSection); + if (!match) break; + sections.push({ + title: titles[match[1]], + body: stripSemanticBoundaryNewlines(match[2]), + }); + remainder = remainder.slice(match[0].length); + } + return { sections, remainder }; +} + +function hasAmbiguousSemanticBoundary(value: string, tags: string[]): boolean { + return tags.some((tag) => { + const openingCount = Array.from( + value.matchAll(new RegExp(`<${tag}(?:\\s[^>]*)?>`, "g")), + ).length; + const closingCount = value.split(``).length - 1; + return openingCount !== closingCount || openingCount > 1; + }); +} + +function splitSemanticTurnSections(text: string): { + sections: PromptSection[]; + remainder: string; +} { + const sections: PromptSection[] = []; + let remainder = text; + const tags = [ + "context", + "thread-context", + "conversation-context", + "buzz-event", + "buzz-events", + "what-you-were-working-on", + "new-message-arrived-while-you-were-working", + "previous-request-interrupted-before-completion", + "new-request-supersedes-previous", + ]; + if (hasAmbiguousSemanticBoundary(text, tags)) { + return { sections, remainder: text }; + } + const leadingSection = new RegExp( + `^\\s*<(${tags.join("|")})([^>]*)>([\\s\\S]*?)<\\/\\1>\\s*`, + ); + + for (;;) { + const match = remainder.match(leadingSection); + if (!match) break; + sections.push({ + title: semanticTurnTitle(match[1], parseSemanticAttributes(match[2])), + body: stripSemanticBoundaryNewlines(match[3]), + }); + remainder = remainder.slice(match[0].length); + } + return { sections, remainder }; +} + +function parseSemanticAttributes(raw: string): Record { + return Object.fromEntries( + Array.from(raw.matchAll(/([a-z-]+)="([^"]*)"/g), ([, name, value]) => [ + name, + decodeSemanticAttribute(value), + ]), + ); +} + +function decodeSemanticAttribute(value: string): string { + return value + .replaceAll(""", '"') + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll("&", "&"); +} + +function semanticTurnTitle( + tag: string, + attributes: Record, +): string { + switch (tag) { + case "context": + return "Context"; + case "thread-context": + case "conversation-context": { + const label = + tag === "thread-context" ? "Thread Context" : "Conversation Context"; + const truncated = attributes.truncated === "true" ? ", truncated" : ""; + return `${label} (${attributes.included} of ${attributes.total} messages${truncated})`; + } + case "buzz-event": + return attributes.type ? `Buzz event: ${attributes.type}` : "Buzz event"; + case "buzz-events": + return `Buzz events — ${attributes.count} events`; + case "what-you-were-working-on": + return "What you were working on"; + case "new-message-arrived-while-you-were-working": + return attributes.count + ? `New messages — arrived while you were working — ${attributes.count} events` + : "New message — arrived while you were working"; + case "previous-request-interrupted-before-completion": + return "Previous request — interrupted before completion"; + case "new-request-supersedes-previous": + return attributes.count + ? `New request — supersedes previous — ${attributes.count} events` + : "New request — supersedes previous"; + default: + return tag; + } +} + +function stripSemanticBoundaryNewlines(value: string): string { + const withoutOpeningNewline = value.startsWith("\n") ? value.slice(1) : value; + return withoutOpeningNewline.endsWith("\n") + ? withoutOpeningNewline.slice(0, -1) + : withoutOpeningNewline; +} + function parsePromptSections(text: string): PromptSection[] { const sections: PromptSection[] = []; let current: PromptSection | null = null;