From 277149cba0c904402e5dc277b2d731d9dd7f7a63 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Tue, 18 Aug 2026 11:32:54 -0700 Subject: [PATCH 1/4] feat(acp): default to gender-neutral pronouns via an always-on interaction norms preamble MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Buzz agents were assigning genders to people without instruction, and persistent core memory lets one session's guessed gender poison every future session of the agent. Ports the approach from block/berd#82, adapted for Buzz's shared-memory architecture: - Add a tiny always-on [Defaults] interaction-norms preamble (interaction_norms.rs) with a Buzz-specific memory clause: record pronouns only as stated, never as guessed, and correct contradicted memory the same turn - Lead both delivery paths with it — framed_system_prompt (session/new system role) and StandingContext::sections() (legacy first message) — so it survives --no-base-prompt and precedes author-controlled content, letting personas and user statements read as the override - Add authoring guidance to base_prompt.md (drafted agents get no unrequested gender; memory records facts as stated) and PERSONA_PACK_SPEC.md (persona authors) - Teach the desktop transcript parser the leading [Defaults] section so the Prompt context panel labels it instead of folding it into Base Generated with Goose Signed-off-by: Clay Delk --- crates/buzz-acp/src/base_prompt.md | 3 + crates/buzz-acp/src/interaction_norms.rs | 50 ++++++ crates/buzz-acp/src/lib.rs | 25 ++- crates/buzz-acp/src/pool.rs | 167 ++++++++++++++---- crates/buzz-acp/src/queue.rs | 33 +++- crates/buzz-persona/PERSONA_PACK_SPEC.md | 6 + .../ui/agentSessionTranscriptHelpers.test.mjs | 56 ++++++ .../ui/agentSessionTranscriptHelpers.ts | 59 +++++-- 8 files changed, 340 insertions(+), 59 deletions(-) create mode 100644 crates/buzz-acp/src/interaction_norms.rs diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index f2de6983282..f534301cc20 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`, do not give the agent a gender or gendered pronouns unless the creator asked for one. Personality, yes; pronouns, no. Use it/its or they/them for the agent — "it" for an agent framed as a tool, "they" for one framed as a character — or sidestep pronouns entirely. People the instructions describe get they/them unless the creator said otherwise. If the creator wants the agent to be a "he" or a "she", they'll say so, and then preserve it. + ## Communication Patterns ### Mentions @@ -114,6 +116,7 @@ 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. +- **Record facts about people only as stated, never as guessed.** This especially covers gender and pronouns: write them to memory only when the person stated them or they are clearly established. Memory is shared across all your sessions, so one recorded guess repeats everywhere. If someone states pronouns that contradict your stored memory, correct the memory the same turn. - Cite sources with paths, links, or command outputs. No unsupported claims. ## 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..8fff947640d --- /dev/null +++ b/crates/buzz-acp/src/interaction_norms.rs @@ -0,0 +1,50 @@ +//! 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). + +/// The `[Defaults]` section leading every agent's standing context. +/// +/// 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. +pub(crate) const INTERACTION_NORMS_PREAMBLE: &str = "[Defaults]\n\ +- Never assume anyone's gender — the user, channel members, people mentioned, or other agents. Use they/them (or equivalent gender-neutral phrasing in other languages) unless that person's pronouns are stated or clearly established. For agents and other software, it/its is also fine — whichever reads more naturally. This is a default: pronouns someone states always win.\n\ +- Record a person's pronouns in memory only when they are stated or clearly established — never a guess. If someone states pronouns that contradict your stored memory, correct the 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 assume anyone's gender")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("they/them")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("pronouns someone states always win")); + } + + #[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("never a guess")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("correct the memory the same turn")); + } +} diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 68b2df4d607..71ac665cd7c 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,18 @@ 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() { + // Two authoring/memory rules backing the [Defaults] interaction norms + // (interaction_norms.rs): drafted agents get no unrequested gender, and + // pronouns enter shared memory only as stated — never as a guess. + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("do not give the agent a gender")); + assert!(prompt.contains("unless the creator asked for one")); + assert!(prompt.contains("Record facts about people only as stated, never as guessed")); + assert!(prompt.contains("correct the memory the same turn")); + } + #[test] fn shared_base_prompt_teaches_real_newlines_for_multiline_messages() { let prompt = include_str!("base_prompt.md"); @@ -5216,12 +5229,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..ecb4ac9dbdd 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]", diff --git a/crates/buzz-persona/PERSONA_PACK_SPEC.md b/crates/buzz-persona/PERSONA_PACK_SPEC.md index cb3a7d1c05a..264c5899df6 100644 --- a/crates/buzz-persona/PERSONA_PACK_SPEC.md +++ b/crates/buzz-persona/PERSONA_PACK_SPEC.md @@ -226,6 +226,12 @@ 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 for one. Use it/its +or they/them for the agent — "it" for an agent framed as a tool, "they" for one framed as a +character — or avoid pronouns entirely; people the prompt describes get they/them unless stated +otherwise. Buzz injects a gender-neutral `[Defaults]` norm ahead of every persona, but the persona +layer overrides it — an unrequested gendered persona defeats the platform default. + --- ## 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 }); From 6e983f156452ae44335bf5c6be93f73916a69476 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Tue, 18 Aug 2026 12:39:34 -0700 Subject: [PATCH 2/4] feat(acp): name the inference vectors and bind remembered facts to pubkeys MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review of the first pass (with Buzz agents that had produced the failure) surfaced two mechanisms the wording missed: - The observed slip was gender read off a display name's connotation, so the norm now names the vectors it forbids — name, avatar, persona theme, writing style — rather than only stating the rule - Display names are not unique on the relay (see mentions.rs match_names_to_profiles, which deliberately returns every pubkey sharing a name), so a pronoun sourced from one profile can attach to a same-named stranger. That error looks sourced, so it survives scrutiny a guess would not — and once written to shared core memory it is re-asserted in every later session. Memory now keys facts by pubkey Also generalizes the underlying failure (filling an unknown with something that sounds right) onto base_prompt.md's existing 'No unsupported claims' line, and recommends stating the neutral case explicitly — silence is the gap that gets filled from a name. Deliberately not included: asking agents to announce that they are defaulting to they/them. In a channel that spotlights a teammate's unstated identity; the quiet correct default is better. A test pins it. Generated with Goose Signed-off-by: Clay Delk --- crates/buzz-acp/src/base_prompt.md | 5 ++- crates/buzz-acp/src/interaction_norms.rs | 43 ++++++++++++++++++++++-- crates/buzz-acp/src/lib.rs | 22 ++++++++++++ crates/buzz-persona/PERSONA_PACK_SPEC.md | 4 +++ 4 files changed, 71 insertions(+), 3 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index f534301cc20..923cdaf8bb9 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -47,6 +47,8 @@ For explicit changes to an existing personal agent, use `buzz agents draft-updat When writing a `--system-prompt`, do not give the agent a gender or gendered pronouns unless the creator asked for one. Personality, yes; pronouns, no. Use it/its or they/them for the agent — "it" for an agent framed as a tool, "they" for one framed as a character — or sidestep pronouns entirely. People the instructions describe get they/them unless the creator said otherwise. If the creator wants the agent to be a "he" or a "she", they'll say so, and then preserve it. +Stating the neutral case explicitly beats leaving it out: a line like "no gender — refer to me by name, or as they/it" gives other agents something to read instead of a blank they might fill from the name. The same applies to your own `core` memory and profile. + ## Communication Patterns ### Mentions @@ -117,7 +119,8 @@ Your `core` memory is auto-injected into your context every turn — it holds id - **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. - **Record facts about people only as stated, never as guessed.** This especially covers gender and pronouns: write them to memory only when the person stated them or they are clearly established. Memory is shared across all your sessions, so one recorded guess repeats everywhere. If someone states pronouns that contradict your stored memory, correct the memory the same turn. -- Cite sources with paths, links, or command outputs. No unsupported claims. +- **Key facts about people by pubkey, not display name.** Display names are not unique — several people or agents may share one. Record who a fact came from by pubkey, and before applying a remembered fact to someone, confirm it belongs to that pubkey. A fact carried to a same-named stranger looks sourced, so it survives scrutiny a guess would not. +- 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 index 8fff947640d..edc026a4ff5 100644 --- a/crates/buzz-acp/src/interaction_norms.rs +++ b/crates/buzz-acp/src/interaction_norms.rs @@ -18,6 +18,15 @@ /// 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 @@ -25,7 +34,7 @@ /// correction matches the existing "evict completed work the same turn" /// memory discipline in the base prompt. pub(crate) const INTERACTION_NORMS_PREAMBLE: &str = "[Defaults]\n\ -- Never assume anyone's gender — the user, channel members, people mentioned, or other agents. Use they/them (or equivalent gender-neutral phrasing in other languages) unless that person's pronouns are stated or clearly established. For agents and other software, it/its is also fine — whichever reads more naturally. This is a default: pronouns someone states always win.\n\ +- Never infer anyone's gender or pronouns — the user, channel members, people mentioned, or other agents — from a name, avatar, persona theme, or writing style. Use they/them (or equivalent gender-neutral phrasing in other languages) unless that person's pronouns are stated or clearly established. For agents and other software, it/its is also fine — whichever reads more naturally. This is a default: pronouns someone states always win.\n\ - Record a person's pronouns in memory only when they are stated or clearly established — never a guess. If someone states pronouns that contradict your stored memory, correct the memory the same turn."; #[cfg(test)] @@ -35,11 +44,41 @@ mod tests { #[test] fn preamble_is_framed_as_an_overridable_default() { assert!(INTERACTION_NORMS_PREAMBLE.starts_with("[Defaults]\n")); - assert!(INTERACTION_NORMS_PREAMBLE.contains("Never assume anyone's gender")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("Never infer anyone's gender")); assert!(INTERACTION_NORMS_PREAMBLE.contains("they/them")); assert!(INTERACTION_NORMS_PREAMBLE.contains("pronouns someone states 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 diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 71ac665cd7c..fc2cf90ee6e 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4572,6 +4572,28 @@ mod agent_draft_prompt_tests { assert!(prompt.contains("unless the creator asked for one")); assert!(prompt.contains("Record facts about people only as stated, never as guessed")); assert!(prompt.contains("correct the memory the same turn")); + // 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")); + } + + #[test] + fn shared_base_prompt_keys_remembered_people_by_pubkey() { + // Display names are not unique on the relay (see + // buzz_sdk::mentions::match_names_to_profiles, which intentionally + // returns every pubkey sharing a name). A fact misbound to a same-named + // stranger looks sourced, so it outlives a guess. + let prompt = include_str!("base_prompt.md"); + assert!(prompt.contains("Key facts about people by pubkey, not display name")); + assert!(prompt.contains("confirm it belongs to that pubkey")); + } + + #[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] diff --git a/crates/buzz-persona/PERSONA_PACK_SPEC.md b/crates/buzz-persona/PERSONA_PACK_SPEC.md index 264c5899df6..279d54d5ff0 100644 --- a/crates/buzz-persona/PERSONA_PACK_SPEC.md +++ b/crates/buzz-persona/PERSONA_PACK_SPEC.md @@ -232,6 +232,10 @@ character — or avoid pronouns entirely; people the prompt describes get they/t otherwise. Buzz injects a gender-neutral `[Defaults]` norm ahead of every persona, but the persona layer overrides it — an unrequested gendered persona defeats the platform default. +Prefer stating the neutral case over omitting it. A persona that says "no gender — refer to me by +name, or as they/it" gives other agents a fact to read; silence leaves a gap they may fill from the +persona's name or theme, which is the failure this guidance exists to prevent. + --- ## 5. Two-Layer Prompt Architecture From f42a50d6aa131d418c8a468a0622a3d2a57efe37 Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Tue, 18 Aug 2026 13:15:33 -0700 Subject: [PATCH 3/4] docs(acp): carry pubkey keying in the preamble, document legacy durability MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps found reviewing the change: - The pubkey-keying rule lived only in base_prompt.md, which BUZZ_ACP_BASE_PROMPT_FILE replaces wholesale. An operator with a custom base prompt would keep the pronoun default but lose the binding rule — exactly the combination that produces confidently-sourced mis-gendering. The memory bullet now carries the short form (+16 tokens); the fuller rule stays in the base prompt. - Enforcement is not equally strong on both delivery paths, and nothing said so. Modern agents hold the norms in the session/new system role for the life of the session; legacy agents get them in the first user message only, because format_prompt gates standing context behind standing_context_sent. Adherence therefore degrades over a long legacy session. Re-sending per turn would re-add the entire standing block, since StandingContext::sections() renders it together — so this is documented as a known cost rather than fixed. The existing omits-standing-after-first-message test now says the same thing at the assertion that pins it. Generated with Goose Signed-off-by: Clay Delk --- crates/buzz-acp/src/interaction_norms.rs | 45 +++++++++++++++++++++++- crates/buzz-acp/src/queue.rs | 6 ++++ 2 files changed, 50 insertions(+), 1 deletion(-) diff --git a/crates/buzz-acp/src/interaction_norms.rs b/crates/buzz-acp/src/interaction_norms.rs index edc026a4ff5..d45ed62a47a 100644 --- a/crates/buzz-acp/src/interaction_norms.rs +++ b/crates/buzz-acp/src/interaction_norms.rs @@ -15,6 +15,28 @@ //! 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. /// @@ -33,9 +55,20 @@ /// 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 — the user, channel members, people mentioned, or other agents — from a name, avatar, persona theme, or writing style. Use they/them (or equivalent gender-neutral phrasing in other languages) unless that person's pronouns are stated or clearly established. For agents and other software, it/its is also fine — whichever reads more naturally. This is a default: pronouns someone states always win.\n\ -- Record a person's pronouns in memory only when they are stated or clearly established — never a guess. If someone states pronouns that contradict your stored memory, correct the memory the same turn."; +- Record a person's pronouns in memory only when they are stated or clearly established — never a guess, and keyed to their pubkey, since display names are not unique. If someone states pronouns that contradict your stored memory, correct the memory the same turn."; #[cfg(test)] mod tests { @@ -86,4 +119,14 @@ mod tests { assert!(INTERACTION_NORMS_PREAMBLE.contains("never a guess")); assert!(INTERACTION_NORMS_PREAMBLE.contains("correct the memory the same turn")); } + + #[test] + fn preamble_keys_remembered_pronouns_to_a_pubkey() { + // base_prompt.md states this at length, but it is replaceable via + // BUZZ_ACP_BASE_PROMPT_FILE. Carrying the short form 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 their pubkey")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("display names are not unique")); + } } diff --git a/crates/buzz-acp/src/queue.rs b/crates/buzz-acp/src/queue.rs index ecb4ac9dbdd..b06aba60851 100644 --- a/crates/buzz-acp/src/queue.rs +++ b/crates/buzz-acp/src/queue.rs @@ -2691,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")); From ac1f11ad8fb747fda3bfcab475ff5a9fb802876e Mon Sep 17 00:00:00 2001 From: Clay Delk Date: Tue, 18 Aug 2026 14:08:30 -0700 Subject: [PATCH 4/4] refactor(acp): condense the pronoun norms to one line each MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prompt length is a live complaint, and this change was contributing to it. Every rule is now a single sentence: - Preamble: 178 -> 83 tokens, close to the ~50 of the Berd original it ports. Both bullets kept; the prose around them was doing the work of a doc comment, so it moved into one. - base_prompt: the separate 'record as stated' and 'key by pubkey' bullets merged into one, and the agent-drafting paragraph collapsed from three sentences to one. - PERSONA_PACK_SPEC: two paragraphs to one. Docs-only, no runtime cost. Net injected per session: ~777 -> ~242 tokens. A new test caps the preamble at 400 bytes so a future addition fails there rather than silently taxing every turn. No rule was dropped — the reasoning that justifies each one lives in doc comments and this history, which cost nothing at runtime. Generated with Goose Signed-off-by: Clay Delk --- crates/buzz-acp/src/base_prompt.md | 7 ++---- crates/buzz-acp/src/interaction_norms.rs | 28 +++++++++++++++++------- crates/buzz-acp/src/lib.rs | 26 ++++++++-------------- crates/buzz-persona/PERSONA_PACK_SPEC.md | 13 ++++------- 4 files changed, 35 insertions(+), 39 deletions(-) diff --git a/crates/buzz-acp/src/base_prompt.md b/crates/buzz-acp/src/base_prompt.md index 923cdaf8bb9..ff04551b557 100644 --- a/crates/buzz-acp/src/base_prompt.md +++ b/crates/buzz-acp/src/base_prompt.md @@ -45,9 +45,7 @@ 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`, do not give the agent a gender or gendered pronouns unless the creator asked for one. Personality, yes; pronouns, no. Use it/its or they/them for the agent — "it" for an agent framed as a tool, "they" for one framed as a character — or sidestep pronouns entirely. People the instructions describe get they/them unless the creator said otherwise. If the creator wants the agent to be a "he" or a "she", they'll say so, and then preserve it. - -Stating the neutral case explicitly beats leaving it out: a line like "no gender — refer to me by name, or as they/it" gives other agents something to read instead of a blank they might fill from the name. The same applies to your own `core` memory and profile. +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 @@ -118,8 +116,7 @@ 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. -- **Record facts about people only as stated, never as guessed.** This especially covers gender and pronouns: write them to memory only when the person stated them or they are clearly established. Memory is shared across all your sessions, so one recorded guess repeats everywhere. If someone states pronouns that contradict your stored memory, correct the memory the same turn. -- **Key facts about people by pubkey, not display name.** Display names are not unique — several people or agents may share one. Record who a fact came from by pubkey, and before applying a remembered fact to someone, confirm it belongs to that pubkey. A fact carried to a same-named stranger looks sourced, so it survives scrutiny a guess would not. +- **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 index d45ed62a47a..b3ae677f2d9 100644 --- a/crates/buzz-acp/src/interaction_norms.rs +++ b/crates/buzz-acp/src/interaction_norms.rs @@ -67,8 +67,8 @@ /// 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 — the user, channel members, people mentioned, or other agents — from a name, avatar, persona theme, or writing style. Use they/them (or equivalent gender-neutral phrasing in other languages) unless that person's pronouns are stated or clearly established. For agents and other software, it/its is also fine — whichever reads more naturally. This is a default: pronouns someone states always win.\n\ -- Record a person's pronouns in memory only when they are stated or clearly established — never a guess, and keyed to their pubkey, since display names are not unique. If someone states pronouns that contradict your stored memory, correct the memory the same turn."; +- 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 { @@ -79,7 +79,7 @@ mod tests { 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("pronouns someone states always win")); + assert!(INTERACTION_NORMS_PREAMBLE.contains("stated pronouns always win")); } #[test] @@ -116,17 +116,29 @@ mod tests { 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("never a guess")); - assert!(INTERACTION_NORMS_PREAMBLE.contains("correct the memory the same turn")); + 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 at length, but it is replaceable via - // BUZZ_ACP_BASE_PROMPT_FILE. Carrying the short form here keeps an + // 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 their pubkey")); + 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 fc2cf90ee6e..897601b0584 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -4564,28 +4564,20 @@ mod agent_draft_prompt_tests { #[test] fn shared_base_prompt_teaches_gender_neutral_agent_drafts_and_memory() { - // Two authoring/memory rules backing the [Defaults] interaction norms + // Authoring and memory rules backing the [Defaults] interaction norms // (interaction_norms.rs): drafted agents get no unrequested gender, and - // pronouns enter shared memory only as stated — never as a guess. + // 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("do not give the agent a gender")); - assert!(prompt.contains("unless the creator asked for one")); - assert!(prompt.contains("Record facts about people only as stated, never as guessed")); - assert!(prompt.contains("correct the memory the same turn")); + 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")); - } - - #[test] - fn shared_base_prompt_keys_remembered_people_by_pubkey() { - // Display names are not unique on the relay (see - // buzz_sdk::mentions::match_names_to_profiles, which intentionally - // returns every pubkey sharing a name). A fact misbound to a same-named - // stranger looks sourced, so it outlives a guess. - let prompt = include_str!("base_prompt.md"); - assert!(prompt.contains("Key facts about people by pubkey, not display name")); - assert!(prompt.contains("confirm it belongs to that pubkey")); + assert!(prompt.contains("never as guessed, and keyed to their pubkey")); + assert!(prompt.contains("display names are not unique")); } #[test] diff --git a/crates/buzz-persona/PERSONA_PACK_SPEC.md b/crates/buzz-persona/PERSONA_PACK_SPEC.md index 279d54d5ff0..79bc06a3be1 100644 --- a/crates/buzz-persona/PERSONA_PACK_SPEC.md +++ b/crates/buzz-persona/PERSONA_PACK_SPEC.md @@ -226,15 +226,10 @@ 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 for one. Use it/its -or they/them for the agent — "it" for an agent framed as a tool, "they" for one framed as a -character — or avoid pronouns entirely; people the prompt describes get they/them unless stated -otherwise. Buzz injects a gender-neutral `[Defaults]` norm ahead of every persona, but the persona -layer overrides it — an unrequested gendered persona defeats the platform default. - -Prefer stating the neutral case over omitting it. A persona that says "no gender — refer to me by -name, or as they/it" gives other agents a fact to read; silence leaves a gap they may fill from the -persona's name or theme, which is the failure this guidance exists to prevent. +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. ---