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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,

Expand Down Expand Up @@ -480,7 +480,7 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')]
pub allowed_respond_to: Option<Vec<String>>,

/// 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<String>,

Expand Down
33 changes: 22 additions & 11 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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<String> {
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
Expand Down Expand Up @@ -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(),
Expand All @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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]",
Expand Down
141 changes: 47 additions & 94 deletions crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<String> {
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<String> {
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<String>, instructions: Option<&str>) -> Option<String> {
let instructions = instructions
.map(str::trim)
Expand Down Expand Up @@ -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`.
Expand Down Expand Up @@ -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");
}
Expand Down Expand Up @@ -4942,7 +4917,7 @@ mod tests {
let composed = prepend_standing_for_legacy(1, &full_standing(), "do the thing");
let positions: Vec<usize> = [
"[Base]",
"[System]",
"[Agent Instructions]",
"[Team Instructions]",
"[Agent Memory — core]",
"[Huddle Instructions]",
Expand Down Expand Up @@ -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]
Expand Down
Loading
Loading