docs(acp): carry pubkey keying in the preamble, document legacy durability

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 <clay.delk@gmail.com>
This commit is contained in:
Clay Delk
2026-08-18 13:15:33 -07:00
parent 6e983f1564
commit f42a50d6aa
2 changed files with 50 additions and 1 deletions

View File

@@ -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"));
}
}

View File

@@ -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"));