diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index e6164b02dd3..0213b97691f 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -129,6 +129,20 @@ All configuration is via environment variables (or CLI flags — every env var h | `--heartbeat-prompt` | `BUZZ_ACP_HEARTBEAT_PROMPT` | (built-in) | Custom heartbeat prompt text. Conflicts with `--heartbeat-prompt-file`. | | `--heartbeat-prompt-file` | `BUZZ_ACP_HEARTBEAT_PROMPT_FILE` | — | Read heartbeat prompt from a file. Conflicts with `--heartbeat-prompt`. | +### Prompts + +Every prompt the harness sends is assembled from a `[Base]` section (Buzz platform orientation, compiled in from `src/base_prompt.md`) followed by an optional `[System]` section (the agent's persona). + +| Flag | Env Var | Default | Description | +|------|---------|---------|-------------| +| `--system-prompt` | `BUZZ_ACP_SYSTEM_PROMPT` | — | Persona `[System]` prompt text. Conflicts with `--system-prompt-file`. | +| `--system-prompt-file` | `BUZZ_ACP_SYSTEM_PROMPT_FILE` | — | Read the persona prompt from a file. Conflicts with `--system-prompt`. | +| `--base-prompt` | `BUZZ_ACP_BASE_PROMPT` | (built-in) | Replace the shipped `[Base]` prompt with custom text (max 1 MB). Conflicts with `--no-base-prompt` and `--base-prompt-file`. | +| `--base-prompt-file` | `BUZZ_ACP_BASE_PROMPT_FILE` | (built-in) | Replace the shipped `[Base]` prompt with the contents of a file (max 1 MB). Conflicts with `--no-base-prompt`. | +| `--no-base-prompt` | `BUZZ_ACP_NO_BASE_PROMPT` | `false` | Drop the `[Base]` section entirely — agents receive only the persona `[System]` prompt with no Buzz orientation. | + +A custom base prompt fully replaces the shipped one — the harness does not merge them. Agents relying on the shipped orientation (CLI usage, mention rules, threading) lose it unless your replacement covers the same ground; start from [`src/base_prompt.md`](src/base_prompt.md) and edit. + ### Inbound Author Gate Controls which authors' events the harness forwards to the agent. Events from disallowed authors are silently dropped before reaching subscription rules. diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index 5244ef5537a..4f0f4ea20ba 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -415,6 +415,15 @@ pub struct CliArgs { #[arg(long, env = "BUZZ_ACP_NO_BASE_PROMPT")] pub no_base_prompt: bool, + /// Custom base prompt text. Overrides the compiled-in default. + /// Mutually exclusive with --no-base-prompt and --base-prompt-file. + #[arg( + long, + env = "BUZZ_ACP_BASE_PROMPT", + conflicts_with_all = ["no_base_prompt", "base_prompt_file"] + )] + pub base_prompt: Option, + /// Path to a custom base prompt file. Overrides the compiled-in default. /// Mutually exclusive with --no-base-prompt. #[arg( @@ -909,6 +918,14 @@ impl Config { let base_prompt_content = if args.no_base_prompt { None + } else if let Some(text) = args.base_prompt { + if text.len() > 1_048_576 { + return Err(ConfigError::ConfigFile(format!( + "base prompt exceeds 1 MB limit ({} bytes)", + text.len() + ))); + } + Some(text) } else if let Some(ref path) = args.base_prompt_file { let content = std::fs::read_to_string(path)?; if content.len() > 1_048_576 { @@ -2991,6 +3008,78 @@ channels = "ALL" assert_eq!(compose_session_title(&agent, Some("buzz-dev")), agent); } + // --- inline base prompt override --- + + #[test] + fn base_prompt_inline_resolves_into_content() { + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--base-prompt", + "custom orientation", + ]) + .expect("clap should parse args"); + let config = Config::from_args(args).expect("config should build"); + + assert_eq!( + config.base_prompt_content.as_deref(), + Some("custom orientation") + ); + assert!(!config.no_base_prompt); + } + + #[test] + fn base_prompt_inline_conflicts_with_no_base_prompt() { + let result = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--base-prompt", + "custom orientation", + "--no-base-prompt", + ]); + assert!(result.is_err(), "clap should reject the conflicting flags"); + } + + #[test] + fn base_prompt_inline_conflicts_with_base_prompt_file() { + let result = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--base-prompt", + "custom orientation", + "--base-prompt-file", + "/tmp/prompt.md", + ]); + assert!(result.is_err(), "clap should reject the conflicting flags"); + } + + #[test] + fn base_prompt_inline_over_1mb_rejected() { + let oversized = "a".repeat(1_048_577); + let args = CliArgs::try_parse_from([ + "buzz-acp", + "--private-key", + TEST_PRIVATE_KEY, + "--base-prompt", + &oversized, + ]) + .expect("clap should parse args"); + let result = Config::from_args(args); + + assert!( + result.is_err(), + "from_args should reject an oversized base prompt" + ); + let msg = result.unwrap_err().to_string(); + assert!( + msg.contains("1 MB limit"), + "error should mention the 1 MB limit: {msg}" + ); + } + /// Every arg whose env var name contains KEY/SECRET/TOKEN/PASSWORD/CRED/AUTH /// must set `hide_env_values = true` to prevent credential leakage in --help. #[test]