diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 5244ef5537a..4a82cf6306d 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -411,7 +411,7 @@ pub struct CliArgs { pub no_memory: bool, /// Disable the [Base] platform-context section prepended to every prompt. - /// When set, agents receive only the persona [System] prompt with no Buzz orientation. + /// When set, agents receive only the persona `[Agent Instructions]` 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 `[System]` and before agent memory. + /// Team-owned instructions layered after `[Agent Instructions]` and before agent memory. #[arg(long, env = "BUZZ_ACP_TEAM_INSTRUCTIONS")] pub team_instructions: Option, diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 1352b31cad8..146214197a8 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -19,7 +19,7 @@ use std::sync::Arc; use std::time::Duration; use acp::{AcpClient, EnvVar, McpServer}; -use anyhow::Result; +use anyhow::{ensure, Context, Result}; use buzz_core::kind::{ KIND_MEMBER_ADDED_NOTIFICATION, KIND_MEMBER_REMOVED_NOTIFICATION, KIND_STREAM_MESSAGE, KIND_STREAM_REMINDER, KIND_WORKFLOW_APPROVAL_REQUESTED, @@ -66,6 +66,22 @@ const MODELS_TIMEOUT: Duration = Duration::from_secs(10); /// human interaction, so it must not share the short probe timeout. const AUTHENTICATE_TIMEOUT: Duration = Duration::from_secs(10 * 60); +/// Resolve the process working directory for ACP session metadata and prompts. +/// +/// `std::env::current_dir()` returns an absolute path on every supported +/// platform. Keep the explicit invariant check so a future source cannot +/// silently introduce a relative path, and surface resolution failures instead +/// of substituting a misleading Unix-specific fallback. +fn current_working_directory() -> Result { + let cwd = std::env::current_dir().context("failed to resolve current working directory")?; + ensure!( + cwd.is_absolute(), + "current working directory is not absolute: {}", + cwd.display() + ); + Ok(cwd.to_string_lossy().into_owned()) +} + /// Publish a kind:20001 presence update event via the WebSocket connection. /// /// Ephemeral kinds (20000-29999) are rejected by the HTTP bridge, so presence @@ -2173,6 +2189,7 @@ async fn tokio_main() -> Result<()> { } let base_prompt_content = config.base_prompt_content.take(); + let cwd = current_working_directory()?; let ctx = Arc::new(PromptContext { mcp_servers: build_mcp_servers(&config), initial_message: config.initial_message.clone(), @@ -2191,10 +2208,7 @@ async fn tokio_main() -> Result<()> { Some(include_str!("base_prompt.md")) }, heartbeat_prompt: config.heartbeat_prompt.clone(), - cwd: std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from("/")) - .to_string_lossy() - .to_string(), + cwd, rest_client: relay.rest_client(), channel_info: pool::ChannelInfoResolver::new(channel_info_map, relay.rest_client()), context_message_limit: config.context_message_limit, @@ -4887,10 +4901,7 @@ async fn run_models(args: ModelsArgs) -> Result<()> { use acp::{extract_model_config_options, extract_model_state}; let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args); - let cwd = std::env::current_dir() - .unwrap_or_else(|_| std::path::PathBuf::from("/")) - .to_string_lossy() - .to_string(); + let cwd = current_working_directory()?; // Spawn outside the timeout so we always own the child for cleanup. // `models` subcommand doesn't use persona packs — no extra env, no codex config. @@ -8535,7 +8546,7 @@ mod observer_payload_trim_tests { // to 1). let sections = [ "[Base]\nyou are a helpful agent".to_string(), - "[System]\npersona text".to_string(), + "[Agent Instructions]\npersona text".to_string(), "[Agent Memory — core]\nremember this".to_string(), "[Context]\nScope: thread".to_string(), // The triggering event body, oversized on its own. @@ -8572,7 +8583,7 @@ mod observer_payload_trim_tests { let texts: Vec<&str> = blocks.iter().map(|b| b["text"].as_str().unwrap()).collect(); for header in [ "[Base]", - "[System]", + "[Agent Instructions]", "[Agent Memory — core]", "[Context]", "[Buzz event: @mention]", diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index f3916b5fdab..38749577398 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1608,64 +1608,39 @@ pub(crate) fn prepend_standing_for_legacy( } /// Frame the `session/new` `systemPrompt` so each present prompt carries its own -/// header, keeping the base/persona boundary recoverable downstream. +/// header, keeping the base/workspace/persona boundaries recoverable downstream. /// -/// The header framing matches the legacy per-turn path (`queue::base_section` -/// for `[Base]`, `[System]\n{...}` for the persona) so the desktop observer can -/// split the combined value into labeled sub-sections. Each prompt is wrapped -/// only when present, so a persona-only agent yields `[System]\n{persona}` -/// rather than an unlabeled blob that would be mislabeled as `[Base]`. -/// -/// Prepends a `[Workspace]` section naming the agent's absolute working -/// directory. The base prompt describes the workspace layout but never its -/// absolute root, so without this anchor a model fills the gap by searching -/// `$HOME` (triggering macOS TCC prompts) or by inventing its own workspace -/// directory. The line is emitted only when a real base prompt is present and -/// `cwd` is an absolute path other than the `/` fallback — naming `/` as the -/// workspace would itself invite a `$HOME`-wide scan. +/// 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]`. fn framed_system_prompt( cwd: &str, base_prompt: Option<&str>, system_prompt: Option<&str>, ) -> Option { - let body = match (base_prompt, system_prompt) { + match (base_prompt, system_prompt) { (Some(bp), Some(sp)) => Some(format!( - "{}\n\n[System]\n{sp}", - crate::queue::base_section(bp) + "{}\n\n{}\n\n[Agent Instructions]\n{sp}", + crate::queue::base_section(bp), + workspace_section(cwd) + )), + (Some(bp), None) => Some(format!( + "{}\n\n{}", + crate::queue::base_section(bp), + workspace_section(cwd) )), - (Some(bp), None) => Some(crate::queue::base_section(bp)), - (None, Some(sp)) => Some(format!("[System]\n{sp}")), + (None, Some(sp)) => Some(format!("[Agent Instructions]\n{sp}")), (None, None) => None, - }?; - // Anchor the workspace only when a base prompt is present — the workspace - // section grounds the base prompt's layout description, so it is meaningless - // for a persona-only (`[System]`-only) agent that never received that layout. - match (base_prompt, workspace_section(cwd)) { - (Some(_), Some(workspace)) => Some(format!("{workspace}\n\n{body}")), - _ => Some(body), } } -/// Render the `[Workspace]` grounding section, or `None` when `cwd` is unusable. -/// -/// Skips relative paths and the `/` fallback (`std::env::current_dir()` resolves -/// to `/` on failure): a `/`-rooted workspace line would actively encourage the -/// `$HOME`-wide scan this section exists to prevent. -fn workspace_section(cwd: &str) -> Option { - if cwd != "/" && cwd.starts_with('/') { - Some(format!( - "[Workspace]\nYour absolute working directory is `{cwd}`. All workspace \ - files — `AGENTS.md`, `RESEARCH/`, `PLANS/`, `GUIDES/`, `WORK_LOGS/`, \ - `OUTBOX/` — and any repositories you clone (under `{cwd}/REPOS/`) live \ - here. This is where you already are, so start here rather than scanning \ - `$HOME`. Any specific path the user names is fine to read." - )) - } else { - None - } +fn workspace_section(cwd: &str) -> String { + format!("[Workspace]\nCurrent working directory: {cwd}") } -/// Append the team-owned instruction section after `[System]` and before core memory. +/// Append the team-owned instruction section after `[Agent Instructions]` and before core memory. fn with_team(prompt: Option, instructions: Option<&str>) -> Option { let instructions = instructions .map(str::trim) @@ -1856,7 +1831,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 `[System]` user-message + // builds (system role for protocol >= 2, the `[Agent Instructions]` 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`. @@ -4858,7 +4833,7 @@ mod tests { fn test_heartbeat_standing_block_is_base_only() { // A heartbeat has no channel, so core and canvas are absent by // construction — and it has never carried the persona. Pin that the - // shared helper does not start handing heartbeats [System]. + // 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"); } @@ -4942,7 +4917,7 @@ mod tests { let composed = prepend_standing_for_legacy(1, &full_standing(), "do the thing"); let positions: Vec = [ "[Base]", - "[System]", + "[Agent Instructions]", "[Team Instructions]", "[Agent Memory — core]", "[Huddle Instructions]", @@ -4998,86 +4973,64 @@ mod tests { // Also the regression guard against #2372: the session title travels // out of band in `_meta.sessionTitle`, so this exact-bytes assertion is // what pins the framing against a `[Session]` section reappearing here. - let framed = framed_system_prompt("/", Some("base text"), Some("persona text")) + let framed = framed_system_prompt("/workspace", Some("base text"), Some("persona text")) .expect("both present yields Some"); - assert_eq!(framed, "[Base]\nbase text\n\n[System]\npersona text"); + assert_eq!( + framed, + "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace\n\n[Agent Instructions]\npersona text" + ); } #[test] fn test_framed_system_prompt_base_only_labels_base() { - let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some"); - assert_eq!(framed, "[Base]\nbase text"); - } - - #[test] - fn test_framed_system_prompt_persona_only_labels_system() { - // A bare persona would be mislabeled "Base" downstream — it must carry - // its own [System] header even when no base prompt exists. let framed = - framed_system_prompt("/", None, Some("persona text")).expect("persona yields Some"); - assert_eq!(framed, "[System]\npersona text"); - } - - #[test] - fn test_framed_system_prompt_neither_is_none() { - assert!(framed_system_prompt("/", None, None).is_none()); - } - - #[test] - fn test_framed_system_prompt_absolute_cwd_prepends_workspace_before_base() { - let framed = framed_system_prompt("/Users/me/.buzz", Some("base text"), None) - .expect("base yields Some"); - assert!( - framed.starts_with("[Workspace]\n"), - "workspace section must lead: {framed}" - ); - assert!(framed.contains("`/Users/me/.buzz`")); - assert!( - framed.contains("\n\n[Base]\nbase text"), - "base must follow the workspace section: {framed}" + 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" ); } #[test] - fn test_framed_system_prompt_persona_only_omits_workspace() { - // The workspace section grounds the base prompt's layout; a persona-only - // agent never received that layout, so no [Workspace] anchor is emitted. - let framed = framed_system_prompt("/Users/me/.buzz", None, Some("persona text")) + 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. + let framed = framed_system_prompt("/workspace", None, Some("persona text")) .expect("persona yields Some"); - assert_eq!(framed, "[System]\npersona text"); + assert_eq!(framed, "[Agent Instructions]\npersona text"); } #[test] - fn test_framed_system_prompt_root_cwd_omits_workspace() { - // The "/" fallback must never be named — it would invite a $HOME scan. - let framed = framed_system_prompt("/", Some("base text"), None).expect("base yields Some"); - assert_eq!(framed, "[Base]\nbase text"); + fn test_framed_system_prompt_neither_is_none() { + assert!(framed_system_prompt("/workspace", None, None).is_none()); } #[test] - fn test_workspace_section_relative_cwd_is_none() { - assert!(workspace_section("relative/path").is_none()); - assert!(workspace_section("").is_none()); + fn test_workspace_section_preserves_windows_cwd() { + assert_eq!( + workspace_section(r"C:\Users\me\buzz"), + "[Workspace]\nCurrent working directory: C:\\Users\\me\\buzz" + ); } #[test] fn test_with_core_appends_below_framed() { let framed = with_core( - Some("[System]\npersona".to_string()), + Some("[Agent Instructions]\npersona".to_string()), Some("[Agent Memory — core]\nbe helpful"), ) .expect("both present yields Some"); assert_eq!( framed, - "[System]\npersona\n\n[Agent Memory — core]\nbe helpful" + "[Agent Instructions]\npersona\n\n[Agent Memory — core]\nbe helpful" ); } #[test] fn test_with_core_framed_only_passes_through() { - let framed = with_core(Some("[System]\npersona".to_string()), None) + let framed = with_core(Some("[Agent Instructions]\npersona".to_string()), None) .expect("framed-only yields Some"); - assert_eq!(framed, "[System]\npersona"); + assert_eq!(framed, "[Agent Instructions]\npersona"); } #[test] diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index b50f926d8b7..60866518bad 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1441,13 +1441,13 @@ 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 `[System]` sections. + /// (legacy agents), they are injected as `[Base]` and `[Agent Instructions]` 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 `[System]`. + /// Team instructions for legacy agents, rendered after `[Agent Instructions]`. pub team_instructions: Option<&'a str>, /// Rendered `[Channel Canvas]` metadata section for legacy agents. /// @@ -1493,7 +1493,7 @@ impl StandingContext<'_> { sections.push(base_section(bp)); } if let Some(sp) = self.system_prompt { - sections.push(format!("[System]\n{sp}")); + sections.push(format!("[Agent Instructions]\n{sp}")); } if let Some(team) = self .team_instructions @@ -1531,7 +1531,7 @@ pub(crate) fn base_section(base_prompt: &str) -> String { /// Format a [`FlushBatch`] into the per-section prompt blocks for the agent. /// /// Produces a stable prompt with these sections (in order): -/// 0. [`StandingContext`] — `[Base]`, `[System]`, `[Team Instructions]`, +/// 0. [`StandingContext`] — `[Base]`, `[Agent Instructions]`, `[Team Instructions]`, /// `[Agent Memory — core]`, `[Channel Canvas]`. 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 @@ -2440,7 +2440,7 @@ mod tests { let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); // system_prompt and base_prompt are delivered via session/new system role, // so they must NOT appear in the user message. - assert!(!prompt.contains("[System]")); + assert!(!prompt.contains("[Agent Instructions]")); assert!(!prompt.contains("[Base]")); assert!(prompt.starts_with("[Context]")); } @@ -2553,12 +2553,12 @@ mod tests { // They are delivered via session/new system role instead. let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!(!prompt.contains("[Base]")); - assert!(!prompt.contains("[System]")); + assert!(!prompt.contains("[Agent Instructions]")); assert!(prompt.starts_with("[Context]")); } #[test] - fn test_format_prompt_legacy_agent_emits_base_and_system() { + fn test_format_prompt_legacy_agent_emits_base_and_agent_instructions() { let ch = Uuid::new_v4(); let event = make_event("hello"); @@ -2592,20 +2592,23 @@ mod tests { "missing [Base] section" ); assert!( - prompt.contains("[System]\ntest system prompt"), - "missing [System] section" + prompt.contains("[Agent Instructions]\ntest system prompt"), + "missing [Agent Instructions] section" ); - // [Base] and [System] must appear BEFORE [Agent Memory] and [Context] + // [Base] and [Agent Instructions] must appear BEFORE [Agent Memory] and [Context] let base_pos = prompt.find("[Base]").unwrap(); - let system_pos = prompt.find("[System]").unwrap(); + let system_pos = prompt.find("[Agent Instructions]").unwrap(); let core_pos = prompt.find("[Agent Memory").unwrap(); let context_pos = prompt.find("[Context]").unwrap(); - assert!(base_pos < system_pos, "[Base] should come before [System]"); + assert!( + base_pos < system_pos, + "[Base] should come before [Agent Instructions]" + ); assert!( system_pos < core_pos, - "[System] should come before [Agent Memory]" + "[Agent Instructions] should come before [Agent Memory]" ); assert!( core_pos < context_pos, @@ -2649,7 +2652,7 @@ mod tests { for section in [ "[Base]", - "[System]", + "[Agent Instructions]", "[Team Instructions]", "[Agent Memory — core]", "[Channel Canvas]", @@ -2669,7 +2672,7 @@ mod tests { } #[test] - fn test_format_prompt_modern_agent_suppresses_base_and_system() { + fn test_format_prompt_modern_agent_suppresses_base_and_agent_instructions() { let ch = Uuid::new_v4(); let event = make_event("hello"); @@ -2701,8 +2704,8 @@ mod tests { "[Base] should be suppressed for modern agents" ); assert!( - !prompt.contains("[System]"), - "[System] should be suppressed for modern agents" + !prompt.contains("[Agent Instructions]"), + "[Agent Instructions] should be suppressed for modern agents" ); assert!(prompt.starts_with("[Context]")); } @@ -2761,9 +2764,9 @@ mod tests { context_pos < thread_pos, "[Context] must come before [Thread Context]" ); - // No [Base] or [System] in user message + // No [Base] or [Agent Instructions] in user message assert!(!prompt.contains("[Base]")); - assert!(!prompt.contains("[System]")); + assert!(!prompt.contains("[Agent Instructions]")); } #[test] diff --git a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx index f78f9d327ef..d2791b480a0 100644 --- a/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaCatalogDialog.tsx @@ -592,7 +592,7 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {

- Agent instruction + Agent instructions

diff --git a/desktop/src/features/agents/ui/agentSessionTranscript.ts b/desktop/src/features/agents/ui/agentSessionTranscript.ts index e371bf5fc30..dfb8eb22fbd 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscript.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscript.ts @@ -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]/[System]/[Agent Memory — core]/[Channel Canvas]. + // the harness as [Base]/[Agent Instructions]/[Agent Memory — core]/[Channel Canvas]. // 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. diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index f0f4cbf36da..23df1e5f2b2 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -201,6 +201,45 @@ test("parseSystemPromptSections splits both prompts into Base and System", () => ]); }); +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"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "base text" }, + { title: "Workspace", body: "Current working directory: /workspace" }, + { title: "Agent Instructions", body: "persona text" }, + ]); +}); + +test("parseSystemPromptSections preserves a Windows workspace path", () => { + const framed = + "[Base]\nbase text\n\n[Workspace]\nCurrent working directory: C:\\Users\\me\\buzz\n\n[Agent Instructions]\npersona text"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Base", body: "base text" }, + { + title: "Workspace", + body: "Current working directory: C:\\Users\\me\\buzz", + }, + { title: "Agent Instructions", body: "persona text" }, + ]); +}); + +test("parseSystemPromptSections preserves the former Workspace-before-Base framing", () => { + const framed = + "[Workspace]\nYour absolute working directory is `/workspace`.\n\n[Base]\nbase text\n\n[System]\npersona text"; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { + title: "Workspace", + body: "Your absolute working directory is `/workspace`.", + }, + { title: "Base", body: "base text" }, + { title: "System", body: "persona text" }, + ]); +}); + test("parseSystemPromptSections yields one Base section for a base-only frame", () => { const sections = parseSystemPromptSections("[Base]\nbase text"); assert.deepEqual(sections, [{ title: "Base", body: "base text" }]); @@ -211,6 +250,15 @@ test("parseSystemPromptSections yields one System section for a persona-only fra assert.deepEqual(sections, [{ title: "System", body: "persona text" }]); }); +test("parseSystemPromptSections yields Agent Instructions for a current persona-only frame", () => { + const sections = parseSystemPromptSections( + "[Agent Instructions]\npersona text", + ); + assert.deepEqual(sections, [ + { title: "Agent Instructions", body: "persona text" }, + ]); +}); + test("parseSystemPromptSections keeps embedded bracket lines literal in bodies", () => { // A persona that itself contains a [Context]-like line must NOT split into a // spurious sub-section — the body is read literally after the first boundary. @@ -323,18 +371,15 @@ test("parseSystemPromptSections keeps exact core header literal when only a sing ]); }); -test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core harness shape", () => { - // The real Buzz harness emits [Workspace] content before [Base]. The parser - // folds [Workspace] into the Base section (existing unchanged behavior); - // core is extracted as a distinct "Core Memory" section last. +test("parseSystemPromptSections pins the current Base+Workspace+Agent Instructions+Core harness shape", () => { const framed = [ - "[Workspace]", - "You are operating inside the Buzz platform.", - "", "[Base]", "You are an assistant.", "", - "[System]", + "[Workspace]", + "Current working directory: /workspace", + "", + "[Agent Instructions]", "Custom persona instructions.", "", "[Agent Memory — core]", @@ -344,11 +389,9 @@ test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core ha ].join("\n"); const sections = parseSystemPromptSections(framed); assert.deepEqual(sections, [ - { - title: "Base", - body: "[Workspace]\nYou are operating inside the Buzz platform.\n\n[Base]\nYou are an assistant.", - }, - { title: "System", body: "Custom persona instructions." }, + { title: "Base", body: "You are an assistant." }, + { title: "Workspace", body: "Current working directory: /workspace" }, + { title: "Agent Instructions", body: "Custom persona instructions." }, { title: "Core Memory", body: "I am Duncan.\n## Lessons Learned\nAlways tag on handoff.", diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 09a2bb31cf9..87cf8ec2dfa 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -56,12 +56,12 @@ export function parsePromptText(text: string): { } /** - * Split the framed `session/new` `systemPrompt` into its `Base`/`System`/ + * Split the framed `session/new` `systemPrompt` into its `Base`/`Agent Instructions`/ * `Team Instructions`/`Core Memory`/`Channel Canvas` sub-sections * deterministically. * * The harness composes the value in order: - * `[Base]\n{base}\n\n[System]\n{persona}\n\n[Team Instructions]\n{team}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}` + * `[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 * extraction boundary is unambiguous. @@ -80,18 +80,19 @@ export function parsePromptText(text: string): { * 3. **Team Instructions** (`[Team Instructions]`): appended before core by * `with_team()` in `buzz-acp/src/pool.rs`. Same two cases (start-of-string * or `\n\n[Team Instructions]\n` inline), same last-occurrence guard. Output - * position: after System, before Core Memory. + * position: after Agent Instructions, before Core Memory. * - * 4. **Base/System**: remainder after the three top-level section extractions. - * Split on the first `\n[System]\n` boundary; no embedded `[...]` line - * inside a body can start a new section. + * 4. **Base/Agent Instructions**: remainder after the three top-level section + * extractions. Split on the first `\n[Agent Instructions]\n` boundary. + * Archived frames using the former `[System]` header remain supported and + * retain their historical observer label. * - * 5. **Legacy Team Instructions** (backward compat): if the `System` body + * 5. **Legacy Team Instructions** (backward compat): if the agent-instructions body * contains the exact canonical delimiter `\n\n---\n# Team Instructions\n` * (produced by the now-removed `compose_prompt()` in buzz-persona), the body * is split at the **last** occurrence of that boundary. The text before - * becomes the `System` body; the text after becomes a `Team Instructions` - * section inserted immediately after `System`. Non-canonical lookalikes + * becomes the agent-instructions body; the text after becomes a `Team Instructions` + * section inserted immediately after it. Non-canonical lookalikes * (bare `---` without the heading, a `# Team Instructions` on a different * line, or only a single preceding newline) are kept literal inside `System`. */ @@ -137,7 +138,7 @@ export function parseSystemPromptSections( // ── 3. Extract [Team Instructions] (modern runtime framing) ───────────── // with_team() in buzz-acp/src/pool.rs appends "\n\n[Team Instructions]\n{instructions}" - // after [System] and before core/canvas. Same two cases as canvas/core: + // after [Agent Instructions] and before core/canvas. Same two cases as canvas/core: // start-of-string (team-only input) or the inline double-newline marker // (last occurrence guards against embedded lookalikes preceded by a single \n). const TEAM_HEADER = "[Team Instructions]"; @@ -157,48 +158,110 @@ export function parseSystemPromptSections( } } - // ── 4. Parse Base/System from the remaining prefix ──────────────────────── + // ── 4. Parse Base/Workspace/Agent Instructions from the remaining prefix ─ // The canonical team-instructions delimiter produced by compose_prompt() in // buzz-persona/src/resolve.rs: // format!("{persona_prompt}\n\n---\n# Team Instructions\n{instructions}") const TEAM_DELIMITER = "\n\n---\n# Team Instructions\n"; - // splitSystemBody: split a raw [System] body string at the last occurrence - // of the canonical team delimiter, returning { systemBody, teamBody | null }. + // splitInstructionsBody: split a raw agent-instructions body string at the last occurrence + // of the canonical team delimiter, returning { instructionsBody, teamBody | null }. // Using lastIndexOf mirrors the canvas/core last-occurrence guard: a persona // author can embed an exact delimiter-like passage inside the persona body; // only the final occurrence is the producer boundary appended by compose_prompt(). - function splitSystemBody(raw: string): { - systemBody: string; + function splitInstructionsBody(raw: string): { + instructionsBody: string; teamBody: string | null; } { const at = raw.lastIndexOf(TEAM_DELIMITER); - if (at === -1) return { systemBody: raw.trim(), teamBody: null }; + if (at === -1) return { instructionsBody: raw.trim(), teamBody: null }; return { - systemBody: raw.slice(0, at).trim(), + instructionsBody: raw.slice(0, at).trim(), teamBody: raw.slice(at + TEAM_DELIMITER.length).trim() || null, }; } - const baseAndSystem = remainder; - if (baseAndSystem) { - if (baseAndSystem.startsWith("[System]\n")) { - const raw = baseAndSystem.slice("[System]\n".length); - const { systemBody, teamBody } = splitSystemBody(raw); - if (systemBody) sections.push({ title: "System", body: systemBody }); + const instructionFrames = [ + { header: "[Agent Instructions]", title: "Agent Instructions" }, + { header: "[System]", title: "System" }, + ] as const; + + function appendBaseAndWorkspace(raw: string): void { + const BASE_HEADER = "[Base]"; + const WORKSPACE_HEADER = "[Workspace]"; + const workspaceMarker = `\n\n${WORKSPACE_HEADER}\n`; + const baseMarker = `\n\n${BASE_HEADER}\n`; + + // Current framing keeps the static base first, followed by the dynamic cwd. + if (raw.startsWith(`${BASE_HEADER}\n`)) { + const workspaceAt = raw.lastIndexOf(workspaceMarker); + if (workspaceAt !== -1) { + const baseBody = raw + .slice(`${BASE_HEADER}\n`.length, workspaceAt) + .trim(); + const workspaceBody = raw + .slice(workspaceAt + workspaceMarker.length) + .trim(); + if (baseBody) sections.push({ title: "Base", body: baseBody }); + if (workspaceBody) + sections.push({ title: "Workspace", body: workspaceBody }); + return; + } + } + + // Preserve readable transcripts for sessions captured with the former + // Workspace-before-Base framing. + if (raw.startsWith(`${WORKSPACE_HEADER}\n`)) { + const baseAt = raw.lastIndexOf(baseMarker); + if (baseAt !== -1) { + const workspaceBody = raw + .slice(`${WORKSPACE_HEADER}\n`.length, baseAt) + .trim(); + const baseBody = raw.slice(baseAt + baseMarker.length).trim(); + if (workspaceBody) + sections.push({ title: "Workspace", body: workspaceBody }); + if (baseBody) sections.push({ title: "Base", body: baseBody }); + return; + } + } + + const baseBody = raw.replace(/^\[Base]\n/, "").trim(); + if (baseBody) sections.push({ title: "Base", body: baseBody }); + } + + const baseAndInstructions = remainder; + if (baseAndInstructions) { + const leadingFrame = instructionFrames.find(({ header }) => + baseAndInstructions.startsWith(`${header}\n`), + ); + if (leadingFrame) { + const raw = baseAndInstructions.slice(`${leadingFrame.header}\n`.length); + const { instructionsBody, teamBody } = splitInstructionsBody(raw); + if (instructionsBody) + sections.push({ title: leadingFrame.title, body: instructionsBody }); if (teamBody) sections.push({ title: "Team Instructions", body: teamBody }); } else { - const marker = "\n[System]\n"; - const at = baseAndSystem.indexOf(marker); - const head = at === -1 ? baseAndSystem : baseAndSystem.slice(0, at); - const baseBody = head.replace(/^\[Base]\n/, "").trim(); - if (baseBody) sections.push({ title: "Base", body: baseBody }); - - if (at !== -1) { - const raw = baseAndSystem.slice(at + marker.length); - const { systemBody, teamBody } = splitSystemBody(raw); - if (systemBody) sections.push({ title: "System", body: systemBody }); + const boundary = instructionFrames + .map((frame) => ({ + ...frame, + marker: `\n${frame.header}\n`, + at: baseAndInstructions.indexOf(`\n${frame.header}\n`), + })) + .filter(({ at }) => at !== -1) + .sort((a, b) => a.at - b.at)[0]; + const head = boundary + ? baseAndInstructions.slice(0, boundary.at) + : baseAndInstructions; + appendBaseAndWorkspace(head); + + if (boundary) { + const raw = baseAndInstructions.slice( + boundary.at + boundary.marker.length, + ); + const { instructionsBody, teamBody } = splitInstructionsBody(raw); + if (instructionsBody) + sections.push({ title: boundary.title, body: instructionsBody }); if (teamBody) sections.push({ title: "Team Instructions", body: teamBody }); }