diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index f2de6983282..ff04551b557 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -45,6 +45,8 @@ Use the channel UUID from `[Context]`. Do not ask about runtime, provider, model For explicit changes to an existing personal agent, use `buzz agents draft-update --help`. Draft updates also require owner review and save. +When writing a `--system-prompt`, give the agent personality but no gender or gendered pronouns unless the creator asked — use it/its or they/them, or state the neutral case outright ("no gender — refer to me by name, or as they/it") so other agents read a fact instead of filling a blank from the name. + ## Communication Patterns ### Mentions @@ -114,7 +116,8 @@ Your `core` memory is auto-injected into your context every turn — it holds id - **Durable detail goes to a cold `mem/` slug, not `core`.** Long-lived findings that don't need to be in front of you every turn belong in a `mem/` slug you read on demand — not appended to `core`. - **Evict completed work.** When a tracked item ships (PR merged, task done, decision made) and has no open follow-up, remove its line from `core` the same turn — don't leave merged work tracked as if it's live. The detail already lives in its cold `mem/` slug if you need it later. - **Treat `core` as load-bearing.** Follow it unless newer explicit user instructions override it. -- Cite sources with paths, links, or command outputs. No unsupported claims. +- **Record facts about people only as stated, never as guessed, and keyed to their pubkey** — display names are not unique, memory is shared across all your sessions, and a fact misbound to a same-named stranger looks sourced. +- Cite sources with paths, links, or command outputs. No unsupported claims — when a fact isn't in front of you, say what's missing instead of filling the gap with a plausible guess. ## Engineering Discipline diff --git a/crates/buzz-acp/src/interaction_norms.rs b/crates/buzz-acp/src/interaction_norms.rs new file mode 100644 index 00000000000..b3ae677f2d9 --- /dev/null +++ b/crates/buzz-acp/src/interaction_norms.rs @@ -0,0 +1,144 @@ +//! App-level interaction norms injected unconditionally into every agent's +//! standing context, on both delivery paths (`session/new` system role for +//! protocol-v2 agents, first-user-message [`StandingContext`] for legacy +//! agents). Unlike the base prompt (replaceable via +//! `BUZZ_ACP_BASE_PROMPT_FILE`, removable via `BUZZ_ACP_NO_BASE_PROMPT`) or +//! the persona (author-controlled), this block has no off switch: it encodes +//! Buzz-the-platform's defaults, not any operator's or author's preferences. +//! +//! Precedence is deliberate: these are defaults, so anything a person states +//! — in the moment, in a persona, or in team instructions — wins. The wording +//! says so explicitly, and both delivery paths place this block before all +//! other standing content so that content reads as the override, not the +//! other way around. +//! +//! Kept tiny. Every norm added here taxes every session of every agent, so +//! entries must be cross-cutting behavioral defaults that cannot live +//! anywhere more targeted (the base prompt, a persona, a skill). +//! +//! # Durability differs by delivery path +//! +//! Enforcement is not equally strong on both paths, and the difference is +//! structural rather than a bug to fix here: +//! +//! - **Protocol-v2 and Claude agents** receive this block in the `session/new` +//! system role, where it persists for the life of the session and is +//! re-established on every new session. +//! - **Legacy agents** (`protocol_version < 2`, excluding claude-agent-acp, +//! plus goose builds without the system-prompt extension) receive it in the +//! session's *first user message only* — `format_prompt` gates standing +//! context behind `!standing_context_sent` so a large block is not re-sent +//! every turn. Fifty turns later these norms are old context competing with +//! everything since, so adherence degrades over a long session. +//! +//! Re-sending per turn was considered and rejected: it would re-add the entire +//! standing block (base prompt, persona, team instructions, memory, canvas) on +//! the legacy path, since `StandingContext::sections()` renders them together. +//! The honest summary is that these are durable defaults for modern agents and +//! best-effort for legacy ones. If a legacy agent slips late in a long +//! session, this gate is the first place to look. + +/// The `[Defaults]` section leading every agent's standing context. +/// +/// The first bullet names the inference vectors (name, avatar, persona theme, +/// writing style) rather than only stating the rule, because that is the +/// observed failure: an agent reads a display name whose connotation feels +/// gendered and writes from that with no source. Naming the vector is what +/// makes the norm bite. It deliberately does not ask the agent to announce +/// that it is defaulting — in a channel that would draw attention to a +/// teammate's unstated identity, which is worse than the quiet correct +/// default. +/// +/// The second bullet exists because Buzz agents have persistent memory +/// (`core` engrams) shared across all sessions of an agent: one session +/// recording a guessed gender poisons every future session, and the +/// mis-gendering outlives the conversation where it happened. Same-turn +/// correction matches the existing "evict completed work the same turn" +/// memory discipline in the base prompt. +/// +/// That bullet also carries the pubkey-keying clause, even though +/// `base_prompt.md` states it at length. Display names are not unique on a +/// relay (`buzz_sdk::mentions::match_names_to_profiles` deliberately returns +/// every pubkey sharing a name), so pronouns read off one profile can attach +/// to a same-named stranger — an error that cites a real source and therefore +/// survives scrutiny a guess would not. The fuller rule lives in the base +/// prompt, which `BUZZ_ACP_BASE_PROMPT_FILE` replaces wholesale; keeping the +/// short form here means an operator with a custom base prompt cannot end up +/// with the pronoun default but not the binding rule, which is exactly the +/// combination that produces confidently-sourced mis-gendering. +pub(crate) const INTERACTION_NORMS_PREAMBLE: &str = "[Defaults]\n\ +- Never infer anyone's gender or pronouns from a name, avatar, persona theme, or writing style: use they/them (it/its for agents) unless stated, and stated pronouns always win.\n\ +- Record pronouns only as stated and keyed to the person's pubkey, since display names are not unique; correct contradicting memory the same turn."; + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn preamble_is_framed_as_an_overridable_default() { + assert!(INTERACTION_NORMS_PREAMBLE.starts_with("[Defaults]\n")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("Never infer anyone's gender")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("they/them")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("stated pronouns always win")); + } + + #[test] + fn preamble_names_the_inference_vectors() { + // Stating the rule alone left the observed failure open: gender read + // off a display name's connotation. The vectors must be explicit. + for vector in ["name", "avatar", "persona theme", "writing style"] { + assert!( + INTERACTION_NORMS_PREAMBLE.contains(vector), + "missing inference vector: {vector}" + ); + } + } + + #[test] + fn preamble_does_not_ask_agents_to_announce_the_default() { + // Announcing "you didn't state pronouns" in a channel spotlights a + // teammate's unstated identity. The default stays quiet. + let lowered = INTERACTION_NORMS_PREAMBLE.to_lowercase(); + for phrase in [ + "say so", + "note that you", + "explain that you", + "tell them you", + ] { + assert!( + !lowered.contains(phrase), + "preamble must not ask agents to announce the default: {phrase}" + ); + } + } + + #[test] + fn preamble_covers_persistent_memory() { + // Buzz-specific: core memory is shared across sessions, so a guessed + // gender recorded once would be re-asserted everywhere, forever. + assert!(INTERACTION_NORMS_PREAMBLE.contains("only as stated")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("correct contradicting memory the same turn")); + } + + #[test] + fn preamble_keys_remembered_pronouns_to_a_pubkey() { + // base_prompt.md states this too, but it is replaceable via + // BUZZ_ACP_BASE_PROMPT_FILE. Carrying the clause here keeps an + // operator from ending up with the pronoun default but not the + // binding rule — the combination that yields sourced mis-gendering. + assert!(INTERACTION_NORMS_PREAMBLE.contains("keyed to the person's pubkey")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("display names are not unique")); + } + + #[test] + fn preamble_stays_small() { + // This block is prepended to every send of every session, so its size + // is a standing tax. Feedback was that prompts are already too long; + // the ceiling makes a regression fail here rather than in a bill. + assert!( + INTERACTION_NORMS_PREAMBLE.len() < 400, + "preamble grew to {} bytes — keep it tight or drop a norm", + INTERACTION_NORMS_PREAMBLE.len() + ); + } +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 68b2df4d607..897601b0584 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4,6 +4,7 @@ mod acp; mod config; mod engram_fetch; mod filter; +mod interaction_norms; mod observer; mod pool; mod pool_lifecycle; @@ -4561,6 +4562,32 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("Do not ask about runtime, provider, model, credentials")); } + #[test] + fn shared_base_prompt_teaches_gender_neutral_agent_drafts_and_memory() { + // Authoring and memory rules backing the [Defaults] interaction norms + // (interaction_norms.rs): drafted agents get no unrequested gender, and + // facts about people enter shared memory only as stated and bound to a + // pubkey — display names are not unique on a relay (see + // buzz_sdk::mentions::match_names_to_profiles, which intentionally + // returns every pubkey sharing a name), and a misbound fact looks + // sourced, so it outlives a guess. + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("no gender or gendered pronouns unless the creator asked")); + // Stating the neutral case beats omitting it — silence is the gap that + // gets filled from a name. + assert!(prompt.contains("no gender — refer to me by name")); + assert!(prompt.contains("never as guessed, and keyed to their pubkey")); + assert!(prompt.contains("display names are not unique")); + } + + #[test] + fn shared_base_prompt_asks_for_named_gaps_over_plausible_guesses() { + // The general form of the gender failure: filling an unknown with + // something that merely sounds right. + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("say what's missing instead of filling the gap")); + } + #[test] fn shared_base_prompt_teaches_real_newlines_for_multiline_messages() { let prompt = include_str!("base_prompt.md"); @@ -5216,12 +5243,16 @@ 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 [Defaults] norms and the [Base] 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" + assert!(composed.starts_with("[Defaults]\n"), "got: {composed}"); + assert!( + composed.ends_with( + "\n\n[Base]\nyou are a helpful agent\n\n[System: Heartbeat]\nrun feed get" + ), + "got: {composed}" ); } diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index 6e3a9b24fa5..24ac7c6023f 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -1586,9 +1586,13 @@ async fn apply_permission_mode( /// Legacy agents (`protocol_version < 2`) don't receive standing context via /// the system role in `session/new`, so it must ride along in the user message /// — in the session's *first* one, and never again. Agents with -/// `protocol_version >= 2`, or an empty [`StandingContext`], get `body` -/// unchanged. Both legacy dispatch paths (initial message, heartbeat) go -/// through this one gate so they can't drift apart again. +/// `protocol_version >= 2` get `body` unchanged. Both legacy dispatch paths +/// (initial message, heartbeat) go through this one gate so they can't drift +/// apart again. +/// +/// The standing context is never empty for legacy agents: its `[Defaults]` +/// interaction norms are unconditional (see `interaction_norms.rs`), so even +/// a bare configuration prepends that one section. /// /// A heartbeat passes base only: it has no channel, so there is no core or /// canvas to carry, and it has never been given the persona. @@ -1616,6 +1620,12 @@ pub(crate) fn prepend_standing_for_legacy( /// only when present, so a persona-only agent yields `[System]\n{persona}` /// rather than an unlabeled blob that would be mislabeled as `[Base]`. /// +/// Always leads with the `[Defaults]` interaction norms (and therefore always +/// returns `Some`): they are platform defaults with no off switch — present +/// even when `--no-base-prompt` removes the base prompt — and they precede +/// author-controlled content so the persona reads as the override. See +/// `interaction_norms.rs`. +/// /// 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 @@ -1628,6 +1638,7 @@ fn framed_system_prompt( base_prompt: Option<&str>, system_prompt: Option<&str>, ) -> Option { + let norms = crate::interaction_norms::INTERACTION_NORMS_PREAMBLE; let body = match (base_prompt, system_prompt) { (Some(bp), Some(sp)) => Some(format!( "{}\n\n[System]\n{sp}", @@ -1636,14 +1647,16 @@ fn framed_system_prompt( (Some(bp), None) => Some(crate::queue::base_section(bp)), (None, Some(sp)) => Some(format!("[System]\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), - } + let framed = match (base_prompt, workspace_section(cwd), body) { + (Some(_), Some(workspace), Some(body)) => format!("{workspace}\n\n{body}"), + (_, _, Some(body)) => body, + (_, _, None) => return Some(norms.to_string()), + }; + Some(format!("{norms}\n\n{framed}")) } /// Render the `[Workspace]` grounding section, or `None` when `cwd` is unusable. @@ -4791,14 +4804,21 @@ 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): [Defaults] and [Base] ride + // along in the user message, composed as + // `[Defaults]\n{norms}\n\n[Base]\n{bp}\n\n{initial_msg}`. 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, + format!( + "{}\n\n[Base]\nyou are a helpful agent\n\nhello channel", + crate::interaction_norms::INTERACTION_NORMS_PREAMBLE + ) + ); } #[test] @@ -4814,12 +4834,18 @@ mod tests { } #[test] - fn test_heartbeat_standing_block_is_base_only() { + fn test_heartbeat_standing_block_is_defaults_and_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]. let composed = prepend_standing_for_legacy(1, &base_only(Some("be helpful")), "tick"); - assert_eq!(composed, "[Base]\nbe helpful\n\ntick"); + assert_eq!( + composed, + format!( + "{}\n\n[Base]\nbe helpful\n\ntick", + crate::interaction_norms::INTERACTION_NORMS_PREAMBLE + ) + ); } #[test] @@ -4874,10 +4900,18 @@ mod tests { } #[test] - fn test_initial_message_legacy_agent_without_base_is_unchanged() { - // No base_prompt configured: nothing to prepend regardless of version. + fn test_initial_message_legacy_agent_without_base_still_gets_defaults() { + // No base_prompt configured (e.g. --no-base-prompt): the [Defaults] + // norms still ride along — they are platform defaults with no off + // switch — but nothing else is prepended. let composed = prepend_standing_for_legacy(1, &base_only(None), "hello channel"); - assert_eq!(composed, "hello channel"); + assert_eq!( + composed, + format!( + "{}\n\nhello channel", + crate::interaction_norms::INTERACTION_NORMS_PREAMBLE + ) + ); } // ── prepend_standing_for_legacy ─────────────────────────────────────────── @@ -4900,6 +4934,7 @@ mod tests { // 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 = [ + "[Defaults]", "[Base]", "[System]", "[Team Instructions]", @@ -4942,15 +4977,31 @@ mod tests { } #[test] - fn test_initial_message_legacy_agent_without_standing_is_unchanged() { - // Nothing configured: body passes through with no stray blank lines. + fn test_initial_message_legacy_agent_without_standing_still_gets_defaults() { + // Nothing configured: the [Defaults] norms are the standing context's + // one unconditional section, so they still lead the first message. let composed = prepend_standing_for_legacy(1, &crate::queue::StandingContext::default(), "do it"); - assert_eq!(composed, "do it"); + assert_eq!( + composed, + format!( + "{}\n\ndo it", + crate::interaction_norms::INTERACTION_NORMS_PREAMBLE + ) + ); } // Pin the session/new systemPrompt framing: each present prompt carries its - // own header so the desktop observer can split into labeled sub-sections. + // own header so the desktop observer can split into labeled sub-sections, + // and the [Defaults] interaction norms always lead. + + /// The exact `[Defaults]` prefix `framed_system_prompt` prepends. + fn norms_prefix() -> String { + format!( + "{}\n\n", + crate::interaction_norms::INTERACTION_NORMS_PREAMBLE + ) + } #[test] fn test_framed_system_prompt_both_present_carries_both_headers() { @@ -4959,13 +5010,19 @@ mod tests { // what pins the framing against a `[Session]` section reappearing here. let framed = framed_system_prompt("/", 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, + format!( + "{}[Base]\nbase text\n\n[System]\npersona text", + norms_prefix() + ) + ); } #[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"); + assert_eq!(framed, format!("{}[Base]\nbase text", norms_prefix())); } #[test] @@ -4974,12 +5031,33 @@ mod tests { // 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"); + assert_eq!(framed, format!("{}[System]\npersona text", norms_prefix())); + } + + #[test] + fn test_framed_system_prompt_neither_is_norms_only() { + // Even with --no-base-prompt and no persona, the platform defaults + // still ship — they have no off switch. + let framed = framed_system_prompt("/", None, None).expect("norms always yield Some"); + assert_eq!(framed, crate::interaction_norms::INTERACTION_NORMS_PREAMBLE); } #[test] - fn test_framed_system_prompt_neither_is_none() { - assert!(framed_system_prompt("/", None, None).is_none()); + fn test_framed_system_prompt_defaults_lead_every_shape() { + // The norms are platform defaults: user-authored content must come + // after them so it reads as the override. + for (base, persona) in [ + (Some("base text"), Some("persona text")), + (Some("base text"), None), + (None, Some("persona text")), + ] { + let framed = framed_system_prompt("/Users/me/.buzz", base, persona) + .expect("present content yields Some"); + assert!( + framed.starts_with("[Defaults]\n"), + "norms must lead: {framed}" + ); + } } #[test] @@ -4987,14 +5065,17 @@ mod tests { 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}" + framed.contains("\n\n[Workspace]\n"), + "workspace section must follow the norms: {framed}" ); assert!(framed.contains("`/Users/me/.buzz`")); assert!( framed.contains("\n\n[Base]\nbase text"), "base must follow the workspace section: {framed}" ); + let workspace_pos = framed.find("[Workspace]").unwrap(); + let base_pos = framed.find("[Base]").unwrap(); + assert!(workspace_pos < base_pos, "workspace precedes base"); } #[test] @@ -5003,14 +5084,14 @@ mod tests { // agent never received that layout, so no [Workspace] anchor is emitted. let framed = framed_system_prompt("/Users/me/.buzz", None, Some("persona text")) .expect("persona yields Some"); - assert_eq!(framed, "[System]\npersona text"); + assert_eq!(framed, format!("{}[System]\npersona text", norms_prefix())); } #[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"); + assert_eq!(framed, format!("{}[Base]\nbase text", norms_prefix())); } #[test] @@ -6037,10 +6118,14 @@ done"# .as_str() .expect("text prompt") }; - assert_eq!(prompt_text(0), "[Base]\nstanding-once\n\nheartbeat-1"); + let standing_prefix = format!( + "{}\n\n[Base]\nstanding-once\n\n", + crate::interaction_norms::INTERACTION_NORMS_PREAMBLE + ); + assert_eq!(prompt_text(0), format!("{standing_prefix}heartbeat-1")); assert_eq!( prompt_text(1), - "[Base]\nstanding-once\n\nheartbeat-2", + format!("{standing_prefix}heartbeat-2"), "retry after ACP failure must resend standing context" ); assert_eq!( @@ -6155,12 +6240,22 @@ done"# .map(|line| serde_json::from_str(line).expect("captured request is JSON")) .collect(); std::fs::remove_file(&capture).expect("remove ACP capture"); + // Sections travel as separate content blocks; join them so the + // assertions survive the [Defaults] norms taking block 0. let prompt_text = |index: usize| { - requests[index]["params"]["prompt"][0]["text"] - .as_str() - .expect("text prompt") + requests[index]["params"]["prompt"] + .as_array() + .expect("prompt blocks") + .iter() + .map(|block| block["text"].as_str().expect("text prompt")) + .collect::>() + .join("\n") }; assert!(prompt_text(0).contains("[Base]\nstanding-once")); + assert!( + prompt_text(0).contains("[Defaults]"), + "first channel message must carry the interaction norms" + ); assert!( prompt_text(1).contains("[Base]\nstanding-once"), "retry after channel ACP failure must resend standing context" @@ -6169,6 +6264,10 @@ done"# !prompt_text(2).contains("[Base]\nstanding-once"), "turn after channel ACP success must omit standing context" ); + assert!( + !prompt_text(2).contains("[Defaults]"), + "later turns must not repeat the interaction norms" + ); } #[tokio::test] diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index b0f0fa248e3..b06aba60851 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -1504,8 +1504,15 @@ pub(crate) struct StandingContext<'a> { impl StandingContext<'_> { /// Render the sections in the order legacy agents have always seen them. + /// + /// Always leads with the `[Defaults]` interaction norms — platform + /// defaults with no off switch, present even when `--no-base-prompt` + /// removes the base prompt, and placed before author-controlled content + /// so the persona reads as the override. Mirrors the `session/new` path + /// (`framed_system_prompt` in pool.rs); see `interaction_norms.rs`. pub(crate) fn sections(&self) -> Vec { - let mut sections = Vec::with_capacity(6); + let mut sections = Vec::with_capacity(7); + sections.push(crate::interaction_norms::INTERACTION_NORMS_PREAMBLE.to_string()); if let Some(bp) = self.base_prompt { sections.push(base_section(bp)); } @@ -2456,10 +2463,12 @@ 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. + // so they must NOT appear in the user message. The [Defaults] norms + // still lead: a legacy first message always carries them. assert!(!prompt.contains("[System]")); assert!(!prompt.contains("[Base]")); - assert!(prompt.starts_with("[Context]")); + assert!(prompt.starts_with("[Defaults]\n")); + assert!(prompt.contains("\n\n[Context]")); } #[test] @@ -2486,8 +2495,12 @@ 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.contains("[Agent Memory — core]\nbe helpful\n\n[Context]"), + "expected core block right before [Context]; got: {prompt}" + ); + assert!( + prompt.starts_with("[Defaults]\n"), + "the interaction norms lead the standing context; got: {prompt}" ); } @@ -2547,7 +2560,8 @@ mod tests { }, ) .join("\n\n"); - assert!(prompt.starts_with("[Agent Memory — core]\nbe helpful\n\n[Context]")); + assert!(prompt.starts_with("[Defaults]\n")); + assert!(prompt.contains("[Agent Memory — core]\nbe helpful\n\n[Context]")); } #[test] @@ -2567,11 +2581,13 @@ mod tests { }; // format_prompt no longer accepts or emits base_prompt/system_prompt. - // They are delivered via session/new system role instead. + // They are delivered via session/new system role instead. The + // [Defaults] norms still ride the legacy first message. let prompt = format_prompt(&batch, &FormatPromptArgs::default()).join("\n\n"); assert!(!prompt.contains("[Base]")); assert!(!prompt.contains("[System]")); - assert!(prompt.starts_with("[Context]")); + assert!(prompt.starts_with("[Defaults]\n")); + assert!(prompt.contains("\n\n[Context]")); } #[test] @@ -2665,6 +2681,7 @@ mod tests { let later = format_prompt(&batch, &args(true)).join("\n\n"); for section in [ + "[Defaults]", "[Base]", "[System]", "[Team Instructions]", @@ -2674,6 +2691,12 @@ mod tests { assert!(first.contains(section), "first message missing {section}"); assert!(!later.contains(section), "turn 2 repeated {section}"); } + // `[Defaults]` dropping out here is a known cost, not a goal: the + // interaction norms are only as durable as this gate on the legacy + // path, where modern agents keep them in the persistent system role. + // Re-sending them alone is not possible without also re-sending the + // rest of the block — `StandingContext::sections()` renders them + // together. See the durability note in `interaction_norms`. // What the turn is actually about survives, and now leads. assert!(later.starts_with("[Context]"), "got: {later}"); assert!(later.contains("hello")); diff --git a/crates/buzz-persona/PERSONA_PACK_SPEC.md b/crates/buzz-persona/PERSONA_PACK_SPEC.md index cb3a7d1c05a..79bc06a3be1 100644 --- a/crates/buzz-persona/PERSONA_PACK_SPEC.md +++ b/crates/buzz-persona/PERSONA_PACK_SPEC.md @@ -226,6 +226,11 @@ Everything after the closing `---` is the persona prompt text. Pack-level `instr appended after it. Embed the prompt directly — do not reference external files or `.mdc` rule files (agent runtimes typically do not read them). +Do not give a persona a gender or gendered pronouns unless its creator asked — use it/its or +they/them, or state the neutral case outright ("no gender — refer to me by name, or as they/it"), +because the persona layer overrides Buzz's gender-neutral `[Defaults]` norm and silence leaves a gap +other agents fill from the persona's name or theme. + --- ## 5. Two-Layer Prompt Architecture diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs index f0f4cbf36da..a937d6bd106 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.test.mjs @@ -356,6 +356,62 @@ test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core ha ]); }); +// ── [Defaults] interaction norms extraction ─────────────────────────────────── + +test("parseSystemPromptSections extracts the leading Defaults norms as their own section", () => { + // framed_system_prompt() in buzz-acp prepends "[Defaults]\n{norms}\n\n" + // before everything else. The observer must show it as a distinct section, + // not fold it into Base. + const framed = [ + "[Defaults]", + "- Never assume anyone's gender.", + "", + "[Workspace]", + "You are operating inside the Buzz platform.", + "", + "[Base]", + "You are an assistant.", + "", + "[System]", + "Custom persona instructions.", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Defaults", body: "- Never assume anyone's gender." }, + { + title: "Base", + body: "[Workspace]\nYou are operating inside the Buzz platform.\n\n[Base]\nYou are an assistant.", + }, + { title: "System", body: "Custom persona instructions." }, + ]); +}); + +test("parseSystemPromptSections handles Defaults directly before System (persona-only agent)", () => { + // A persona-only agent gets no [Workspace] or [Base]; the norms still lead. + const framed = [ + "[Defaults]", + "- Never assume anyone's gender.", + "", + "[System]", + "Custom persona instructions.", + ].join("\n"); + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Defaults", body: "- Never assume anyone's gender." }, + { title: "System", body: "Custom persona instructions." }, + ]); +}); + +test("parseSystemPromptSections handles a Defaults-only prompt (--no-base-prompt, no persona)", () => { + // The norms have no off switch, so this shape is reachable when the base + // prompt is disabled and no persona is configured. + const framed = "[Defaults]\n- Never assume anyone's gender."; + const sections = parseSystemPromptSections(framed); + assert.deepEqual(sections, [ + { title: "Defaults", body: "- Never assume anyone's gender." }, + ]); +}); + // ── Channel Canvas extraction ───────────────────────────────────────────────── test("parseSystemPromptSections pins the full Base+System+Core+Canvas harness shape", () => { diff --git a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts index 09a2bb31cf9..9e60f59ab4e 100644 --- a/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts +++ b/desktop/src/features/agents/ui/agentSessionTranscriptHelpers.ts @@ -56,17 +56,17 @@ export function parsePromptText(text: string): { } /** - * Split the framed `session/new` `systemPrompt` into its `Base`/`System`/ - * `Team Instructions`/`Core Memory`/`Channel Canvas` sub-sections + * Split the framed `session/new` `systemPrompt` into its `Defaults`/`Base`/ + * `System`/`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}` - * 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. + * `[Defaults]\n{norms}\n\n[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}` + * with any section other than `Defaults` 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. * - * Five extraction passes: + * Six extraction passes: * * 1. **Canvas** (`[Channel Canvas]`): appended last by `with_canvas()`. * - Start-of-string: canvas-only input. @@ -82,11 +82,17 @@ export function parsePromptText(text: string): { * or `\n\n[Team Instructions]\n` inline), same last-occurrence guard. Output * position: after System, 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. **Defaults** (`[Defaults]`): prepended first by `framed_system_prompt()` + * in `buzz-acp/src/pool.rs` — the platform interaction norms. Always at + * start-of-string when present; the body is platform-authored (no embedded + * user content), so the FIRST following producer boundary + * (`\n\n[Workspace]\n`, `\n\n[Base]\n`, or `\n\n[System]\n`) ends it. * - * 5. **Legacy Team Instructions** (backward compat): if the `System` body + * 5. **Base/System**: remainder after the section extractions. Split on the + * first `\n[System]\n` boundary; no embedded `[...]` line inside a body + * can start a new section. + * + * 6. **Legacy Team Instructions** (backward compat): if the `System` 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 @@ -157,7 +163,32 @@ export function parseSystemPromptSections( } } - // ── 4. Parse Base/System from the remaining prefix ──────────────────────── + // ── 4. Extract [Defaults] (platform interaction norms) ─────────────────── + // framed_system_prompt() in buzz-acp/src/pool.rs prepends + // "[Defaults]\n{norms}\n\n" ahead of everything else. The body is + // platform-authored, so the FIRST following producer boundary ends it — + // there is no embedded-user-content risk that would require a + // last-occurrence guard here. + const DEFAULTS_HEADER = "[Defaults]"; + let defaultsBody: string | null = null; + + if (remainder.startsWith(`${DEFAULTS_HEADER}\n`)) { + const afterHeader = remainder.slice(`${DEFAULTS_HEADER}\n`.length); + const boundary = ["\n\n[Workspace]\n", "\n\n[Base]\n", "\n\n[System]\n"] + .map((marker) => afterHeader.indexOf(marker)) + .filter((at) => at !== -1) + .reduce((min, at) => Math.min(min, at), Number.POSITIVE_INFINITY); + if (boundary === Number.POSITIVE_INFINITY) { + defaultsBody = afterHeader.trim(); + remainder = ""; + } else { + defaultsBody = afterHeader.slice(0, boundary).trim(); + // Skip the blank-line separator; keep the next section's [Header]. + remainder = afterHeader.slice(boundary + "\n\n".length); + } + } + + // ── 5. Parse Base/System 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}") @@ -180,6 +211,8 @@ export function parseSystemPromptSections( }; } + if (defaultsBody) sections.push({ title: "Defaults", body: defaultsBody }); + const baseAndSystem = remainder; if (baseAndSystem) { if (baseAndSystem.startsWith("[System]\n")) { @@ -205,7 +238,7 @@ export function parseSystemPromptSections( } } - // ── 5. Append team (modern), core, and canvas sections in producer order ── + // ── 6. Append team (modern), core, and canvas sections in producer order ── if (modernTeamBody) sections.push({ title: "Team Instructions", body: modernTeamBody }); if (coreBody) sections.push({ title: "Core Memory", body: coreBody });