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
72 changes: 17 additions & 55 deletions crates/buzz-acp/src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -422,14 +422,9 @@ pub struct CliArgs {
#[arg(long, env = "BUZZ_ACP_ALLOWED_RESPOND_TO", value_delimiter = ',')]
pub allowed_respond_to: Option<Vec<String>>,

/// Path to a persona pack directory. Used with --persona-name to configure
/// the agent from a .persona.md pack instead of CLI flags.
#[arg(long, env = "BUZZ_ACP_PERSONA_PACK")]
pub persona_pack: Option<PathBuf>,

/// Name of the persona within the pack to use. Required when --persona-pack is set.
#[arg(long, env = "BUZZ_ACP_PERSONA_NAME")]
pub persona_name: Option<String>,
/// Team-owned instructions layered after `[System]` and before agent memory.
#[arg(long, env = "BUZZ_ACP_TEAM_INSTRUCTIONS")]
pub team_instructions: Option<String>,

/// Publish encrypted ACP observer frames over the relay.
#[arg(long, env = "BUZZ_ACP_RELAY_OBSERVER", default_value_t = false)]
Expand Down Expand Up @@ -462,6 +457,8 @@ pub struct Config {
pub turn_liveness_secs: u64,
pub heartbeat_prompt: Option<String>,
pub system_prompt: Option<String>,
/// Team-owned instructions layered separately from the agent system prompt.
pub team_instructions: Option<String>,
pub initial_message: Option<String>,
pub subscribe_mode: SubscribeMode,
pub dedup_mode: DedupMode,
Expand Down Expand Up @@ -705,7 +702,7 @@ impl Config {
.replace_range(.., &"0".repeat(args.private_key.len()));
args.private_key.clear();

let mut system_prompt = if let Some(text) = args.system_prompt {
let system_prompt = if let Some(text) = args.system_prompt {
Some(text)
} else if let Some(ref path) = args.system_prompt_file {
Some(std::fs::read_to_string(path)?)
Expand Down Expand Up @@ -902,52 +899,10 @@ impl Config {
Vec::new()
};

//
// Precedence: CLI/env args > persona values > built-in defaults.
// Persona fills in what's missing. Explicit flags always win.
let (persona_system_prompt, persona_model, mut persona_env_vars) =
match (&args.persona_pack, &args.persona_name) {
(Some(pack_dir), Some(name)) => {
let pack = buzz_persona::resolve::resolve_pack(pack_dir).map_err(|e| {
ConfigError::ConfigFile(format!(
"failed to resolve pack {}: {e}",
pack_dir.display()
))
})?;
let persona = pack
.personas
.into_iter()
.find(|p| p.name == *name)
.ok_or_else(|| {
ConfigError::ConfigFile(format!(
"persona '{name}' not found in pack {}",
pack_dir.display()
))
})?;
(
Some(persona.system_prompt),
persona.model,
persona.runtime_env_vars,
)
}
(Some(_), None) => {
return Err(ConfigError::ConfigFile(
"--persona-pack requires --persona-name".into(),
));
}
(None, Some(_)) => {
return Err(ConfigError::ConfigFile(
"--persona-name requires --persona-pack".into(),
));
}
(None, None) => (None, None, vec![]),
};

// Apply persona defaults: CLI/env wins, persona fills gaps.
if system_prompt.is_none() {
system_prompt = persona_system_prompt;
}
let model = args.model.or(persona_model);
// Spawned desktop agents now carry a complete instance snapshot. Team
// instructions arrive independently so they can be layered at runtime.
let mut persona_env_vars = Vec::new();
let model = args.model;

// Inject CODEX_CONFIG so the @agentclientprotocol/codex-acp adapter (1.x)
// opens the Seatbelt network sandbox for buzz-cli (an MCP subprocess). No-op
Expand Down Expand Up @@ -975,6 +930,12 @@ impl Config {
turn_liveness_secs,
heartbeat_prompt,
system_prompt,
team_instructions: args
.team_instructions
.as_deref()
.map(str::trim)
.filter(|value| !value.is_empty())
.map(str::to_string),
initial_message: args.initial_message,
subscribe_mode: args.subscribe,
dedup_mode: args.dedup,
Expand Down Expand Up @@ -1346,6 +1307,7 @@ mod tests {
turn_liveness_secs: 10,
heartbeat_prompt: None,
system_prompt: None,
team_instructions: None,
initial_message: None,
subscribe_mode: mode,
dedup_mode: DedupMode::Queue,
Expand Down
3 changes: 3 additions & 0 deletions crates/buzz-acp/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1399,6 +1399,7 @@ async fn tokio_main() -> Result<()> {
turn_liveness_interval: Duration::from_secs(config.turn_liveness_secs),
dedup_mode: config.dedup_mode,
system_prompt: config.system_prompt.clone(),
team_instructions: config.team_instructions.clone(),
base_prompt: if config.no_base_prompt {
None
} else if let Some(content) = base_prompt_content {
Expand Down Expand Up @@ -3881,6 +3882,7 @@ mod build_mcp_servers_tests {
turn_liveness_secs: 10,
heartbeat_prompt: None,
system_prompt: None,
team_instructions: None,
initial_message: None,
subscribe_mode: config::SubscribeMode::All,
dedup_mode: config::DedupMode::Queue,
Expand Down Expand Up @@ -4045,6 +4047,7 @@ mod error_outcome_emission_tests {
turn_liveness_secs: 10,
heartbeat_prompt: None,
system_prompt: None,
team_instructions: None,
initial_message: None,
subscribe_mode: config::SubscribeMode::All,
dedup_mode: config::DedupMode::Queue,
Expand Down
23 changes: 22 additions & 1 deletion crates/buzz-acp/src/pool.rs
Original file line number Diff line number Diff line change
Expand Up @@ -379,6 +379,7 @@ pub struct PromptContext {
pub turn_liveness_interval: Duration,
pub dedup_mode: DedupMode,
pub system_prompt: Option<String>,
pub team_instructions: Option<String>,
pub heartbeat_prompt: Option<String>,
/// Base prompt content, or `None` if `--no-base-prompt` was passed.
///
Expand Down Expand Up @@ -694,7 +695,10 @@ async fn create_session_and_apply_model(
let combined_system_prompt: Option<String> = if agent.protocol_version >= 2 {
with_canvas(
with_core(
framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()),
with_team(
framed_system_prompt(&ctx.cwd, ctx.base_prompt, ctx.system_prompt.as_deref()),
ctx.team_instructions.as_deref(),
),
agent_core,
),
agent_canvas,
Expand Down Expand Up @@ -1027,6 +1031,21 @@ fn workspace_section(cwd: &str) -> Option<String> {
}
}

/// Append the team-owned instruction section after `[System]` and before core memory.
fn with_team(prompt: Option<String>, instructions: Option<&str>) -> Option<String> {
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), None) => Some(prompt),
(None, None) => None,
}
}

/// Append the agent's core memory section onto the framed system prompt.
///
/// Core already carries its own `[Agent Memory — core]` header from
Expand Down Expand Up @@ -1552,6 +1571,7 @@ pub async fn run_prompt_task(
has_system_prompt_support: agent.protocol_version >= 2,
base_prompt: ctx.base_prompt,
system_prompt: ctx.system_prompt.as_deref(),
team_instructions: ctx.team_instructions.as_deref(),
agent_canvas: agent_canvas.as_deref(),
},
)
Expand Down Expand Up @@ -4527,6 +4547,7 @@ mod tests {
turn_liveness_interval: Duration::ZERO,
dedup_mode: DedupMode::Drop,
system_prompt: None,
team_instructions: None,
heartbeat_prompt: None,
base_prompt: None,
cwd: ".".to_string(),
Expand Down
9 changes: 9 additions & 0 deletions crates/buzz-acp/src/queue.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1327,6 +1327,8 @@ pub struct FormatPromptArgs<'a> {
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]`.
pub team_instructions: Option<&'a str>,
/// Rendered `[Channel Canvas]` metadata section for legacy agents.
///
/// For modern agents (protocol_version >= 2) the section is delivered via
Expand Down Expand Up @@ -1395,6 +1397,13 @@ pub fn format_prompt(batch: &FlushBatch, args: &FormatPromptArgs<'_>) -> Vec<Str
if let Some(sp) = args.system_prompt {
sections.push(format!("[System]\n{sp}"));
}
if let Some(team) = args
.team_instructions
.map(str::trim)
.filter(|value| !value.is_empty())
{
sections.push(format!("[Team Instructions]\n{team}"));
}
}

// NIP-AE agent core memory (rendered by `engram_fetch::build_core_section`).
Expand Down
48 changes: 12 additions & 36 deletions crates/buzz-persona/src/resolve.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,10 @@ pub struct ResolvedPersona {
pub avatar: Option<String>,
pub version: String,

// → Config.system_prompt (persona body + pack_instructions)
// → Config.system_prompt (persona body only)
pub system_prompt: String,
/// Pack-owned instructions kept separate for the team model migration.
pub pack_instructions: Option<String>,

// → Config.model (plain model ID, post-split)
pub model: Option<String>,
Expand Down Expand Up @@ -195,7 +197,11 @@ fn resolve_one_persona(
pack_instructions: Option<&str>,
shared_mcp: Option<&serde_json::Value>,
) -> ResolvedPersona {
let system_prompt = compose_prompt(&lp.prompt, pack_instructions);
let system_prompt = lp.prompt.clone();
let pack_instructions = pack_instructions
.map(str::trim)
.filter(|instructions| !instructions.is_empty())
.map(str::to_string);

// Split "provider:model-id" into separate fields (V3 contract).
let (llm_provider, model) = match lp.model.as_deref() {
Expand Down Expand Up @@ -228,6 +234,7 @@ fn resolve_one_persona(
avatar: lp.avatar.clone(),
version,
system_prompt,
pack_instructions,
model,
llm_provider,
runtime: lp.runtime.clone(),
Expand All @@ -244,16 +251,6 @@ fn resolve_one_persona(
}
}

/// Compose the effective system prompt: persona body + pack instructions.
fn compose_prompt(persona_prompt: &str, pack_instructions: Option<&str>) -> String {
match pack_instructions {
Some(instructions) if !instructions.trim().is_empty() => {
format!("{persona_prompt}\n\n---\n# Team Instructions\n{instructions}")
}
_ => persona_prompt.to_owned(),
}
}

/// Convert `TriggersData` to `ResolvedTriggers`.
fn resolve_triggers(rt: Option<&TriggersData>) -> ResolvedTriggers {
match rt {
Expand Down Expand Up @@ -407,26 +404,6 @@ mod tests {

use crate::merge::{HooksData, TriggersData};

#[test]
fn compose_prompt_body_only() {
let result = compose_prompt("You are a bot.", None);
assert_eq!(result, "You are a bot.");
}

#[test]
fn compose_prompt_with_instructions() {
let result = compose_prompt("You are a bot.", Some("Follow the rules."));
assert!(result.starts_with("You are a bot."));
assert!(result.contains("# Team Instructions"));
assert!(result.contains("Follow the rules."));
}

#[test]
fn compose_prompt_empty_instructions_ignored() {
let result = compose_prompt("You are a bot.", Some(" "));
assert_eq!(result, "You are a bot.");
}

#[test]
fn triggers_from_triggers_data() {
let data = TriggersData {
Expand Down Expand Up @@ -708,10 +685,9 @@ mod tests {
let pack = resolve_pack(dir).unwrap();
let p = &pack.personas[0];

// Prompt composed with instructions
assert!(p.system_prompt.contains("You are Bot."));
assert!(p.system_prompt.contains("# Team Instructions"));
assert!(p.system_prompt.contains("Always be helpful."));
// Persona body kept separate from pack instructions.
assert_eq!(p.system_prompt, "You are Bot.\n");
assert_eq!(p.pack_instructions.as_deref(), Some("Always be helpful."));

// Model split into separate fields (V3 contract)
assert_eq!(p.model.as_deref(), Some("claude-sonnet-4-20250514"));
Expand Down
12 changes: 7 additions & 5 deletions crates/buzz-persona/tests/integration.rs
Original file line number Diff line number Diff line change
Expand Up @@ -420,18 +420,20 @@ fn resolve_full_pipeline() {
assert_eq!(lep.llm_provider.as_deref(), Some("anthropic"));
assert_eq!(lep.model.as_deref(), Some("claude-sonnet-4-20250514"));

// System prompt composed: persona body + pack instructions
// System prompt is persona body only; pack instructions stay separate.
assert!(
pip.system_prompt.contains("You are Pip"),
"pip prompt should contain persona body"
);
assert!(
pip.system_prompt.contains("Be helpful"),
"pip prompt should contain pack instructions"
!pip.system_prompt.contains("Be helpful"),
"pip prompt should not contain pack instructions"
);
assert!(
pip.system_prompt.contains("Team Instructions"),
"pip prompt should contain instructions header"
pip.pack_instructions
.as_deref()
.is_some_and(|instructions| instructions.contains("Be helpful")),
"pip.pack_instructions should carry the pack instructions"
);

// Temperature inherited from defaults
Expand Down
1 change: 1 addition & 0 deletions desktop/playwright.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,7 @@ export default defineConfig({
"**/tokens.spec.ts",
"**/persona-env-vars.spec.ts",
"**/persona-sync.spec.ts",
"**/team-snapshot.spec.ts",
"**/mesh-compute.spec.ts",
"**/parity-ancestor-island.spec.ts",
],
Expand Down
21 changes: 16 additions & 5 deletions desktop/scripts/check-file-sizes.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,8 @@ const overrides = new Map([
["src-tauri/src/managed_agents/nest.rs", 704],
// keyring-dev-isolation: agent key migration added copy_agent_keys_between_stores
// and load_readonly support; file grew past 1000 default. Queued to split.
["src-tauri/src/managed_agents/storage.rs", 1325],
// +7 for try_delete_agent_key result-returning seam (snapshot-import rollback).
["src-tauri/src/managed_agents/storage.rs", 1335],
// harness-persona-sync: persona-runtime resolution threaded into the spawn
// path here. Load-bearing feature growth; queued to split in the resolver
// unify refactor followup. +26 for resolve_effective_prompt_model_provider
Expand Down Expand Up @@ -164,7 +165,9 @@ const overrides = new Map([
// setup-mode requirements. The Windows-only requirement and serialization
// test add eight lines; split remains queued with the existing file debt.
// Windows Doctor install fix: cli_install_commands_windows field added to test stubs.
["src-tauri/src/managed_agents/readiness.rs", 1764],
// team-instructions-first-class: ManagedAgentRecord fixture gains the new
// team_id field (+1 line).
["src-tauri/src/managed_agents/readiness.rs", 1765],
// applyWorkspace reposDir parameter plus the validateReposDir binding,
// threaded through Tauri invokes for configurable repos_dir, plus the
// harness-persona-sync `harnessOverride` create-input bit — load-bearing
Expand Down Expand Up @@ -197,7 +200,9 @@ const overrides = new Map([
// RawInstallRuntimeResult + fromRawInstallRuntimeResult mapper (+2).
// Git Bash Doctor discovery adds the raw Tauri response and its camelCase
// mapper. This is the existing API boundary; split remains queued.
["src/shared/api/tauri.ts", 1304],
// team-instructions-first-class: createManagedAgent Tauri bridge threads the
// new teamId input through to the backend (+1 line).
["src/shared/api/tauri.ts", 1305],
// doctor-npm-eacces-preflight: hint field added to InstallStepResult (+1 line).
// codex-acp-package-swap: "adapter_outdated" variant added to AcpAvailabilityStatus (+1 line).
// doctor-install-reliability: AuthStatus tagged union + nodeRequired/authStatus/
Expand All @@ -208,7 +213,11 @@ const overrides = new Map([
// Git Bash prerequisite payload adds four fields to the shared Tauri API
// contract. This is the canonical type location; split remains queued.
// signout-wipe: resetFailed field added to Identity type (+6 lines).
["src/shared/api/types.ts", 1044],
// team-instructions-first-class: CreateManagedAgentInput.teamId (+2, incl.
// doc comment) and AgentTeam/CreateTeamInput/UpdateTeamInput.instructions
// (+3) — the new team-id spawn link and the runtime-layered instructions
// field.
["src/shared/api/types.ts", 1047],
// readiness-gate: PersonaDialog.tsx threads computeLocalModeGate +
// requiredCredentialEnvKeys + RequiredFieldLabel so the "New agent" dialog
// shows required markers and credential amber rows (parity with
Expand Down Expand Up @@ -280,7 +289,9 @@ const overrides = new Map([
// candidates, cli_install_commands_for_os PowerShell selection, login_shell_path
// None regression, .cmd shim resolution, no-git-bash error hint.
// +32: deterministic .cmd resolver + no-registry + install_shell_from tests.
["src-tauri/src/managed_agents/discovery/tests.rs", 1270],
// team-instructions-first-class: record_with test fixture gained the new
// ManagedAgentRecord.team_id field (+1 line) alongside persona_team_dir.
["src-tauri/src/managed_agents/discovery/tests.rs", 1271],
// identity-import-keyring: the identity resolution state machine's behavioral
// matrix (46 tests over FakeIdentityStore — probe × marker × file cells,
// adoption / read-back-corruption / marker-failure arms, recovery-mode
Expand Down
Loading
Loading