From 3323bca677cad3f45581b6aa41145d57370f8b2c Mon Sep 17 00:00:00 2001 From: damocles Date: Sun, 31 May 2026 12:09:46 +0200 Subject: [PATCH] prompt: thread hive + swarm display names into system prompt opener (#709) --- hive-ag3nt/prompts/system.md | 4 +- hive-ag3nt/src/prompt.rs | 197 ++++++++++++++++++++++++++++++++--- 2 files changed, 185 insertions(+), 16 deletions(-) diff --git a/hive-ag3nt/prompts/system.md b/hive-ag3nt/prompts/system.md index ba8f7dd5..f3fbe872 100644 --- a/hive-ag3nt/prompts/system.md +++ b/hive-ag3nt/prompts/system.md @@ -1,8 +1,8 @@ -You are hyperhive agent `{label}` (qualified: `{qualified_label}`) in a multi-agent system. The operator (recipient `operator` in `send`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + the manager can disambiguate; within your own hive the short form is fine. +You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. The operator (recipient `operator` in `send`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person (e.g. when relaying to a peer or the manager). When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`) so the operator + the manager can disambiguate; within your own hive the short form is fine. -You are the hyperhive manager `{label}` (qualified: `{qualified_label}`) in a multi-agent system. You coordinate sub-agents and relay between them and the operator. The operator (recipient `operator`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person. When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`); within your own hive the short form is fine. +You are the hyperhive manager `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. You coordinate sub-agents and relay between them and the operator. The operator (recipient `operator`, the human at the dashboard) uses **{operator_pronouns}** pronouns — use them naturally when you refer to them in third person. When you're talking to or about a peer on a different hive, use the qualified form (`name@hive`); within your own hive the short form is fine. Tools (hyperhive surface): diff --git a/hive-ag3nt/src/prompt.rs b/hive-ag3nt/src/prompt.rs index 9b6ff5e1..08b0ffbd 100644 --- a/hive-ag3nt/src/prompt.rs +++ b/hive-ag3nt/src/prompt.rs @@ -33,7 +33,8 @@ use anyhow::{Context, Result}; use crate::mcp::Flavor; -/// Assemble the system prompt for a given flavor + label + pronouns. +/// Assemble the system prompt for a given flavor + label + pronouns + +/// optional hive / swarm display names. /// Pure function — no I/O. Splits out from [`write_system_prompt`] so /// the marker logic + substitution is unit-testable in isolation. /// The caller supplies the template body so tests can pass an inline @@ -41,23 +42,49 @@ use crate::mcp::Flavor; /// [`hive_sh4re::assets::prompt_template`] (`$HIVE_ASSETS_DIR/prompts/ /// system.md`). /// -/// `{label}` and `{operator_pronouns}` are substituted in the filtered body. -/// `{qualified_label}` (#589) is also substituted — it's `${label}@${hive}` -/// in federated deployments, or the same as `{label}` when no hive domain is -/// configured (single-hive deployments). Templates that always want the -/// fully-qualified form can use `{qualified_label}` and stay correct in -/// both shapes. +/// Substitutions: +/// +/// - `{label}` — short hive-local name (e.g. `iris`). +/// - `{qualified_label}` (#589) — `${label}@${domain}` in federated +/// deployments, same as `{label}` when no hive domain is configured. +/// - `{operator_pronouns}` — `"she/her"` / `"they/them"` / etc. +/// - `{hive_identity}` (#701 / #709) — ` on hive \`pr1ma\`` (with +/// leading space + backticks) when `hive_name` is `Some`, **empty +/// string** when `None`. Lets the template drop the names into the +/// opener prose without breaking single-hive deployments that +/// never set the option. +/// - `{swarm_identity}` (#701 / #709) — same shape for the swarm: +/// ` in swarm \`constellat1on\`` when `Some`, empty when `None`. +/// +/// Both `hive_name` / `swarm_name` are independent: setting just one +/// surfaces only that clause; setting both yields the full prose +/// (`… on hive \`pr1ma\` in swarm \`constellat1on\` …`). #[must_use] -pub fn render(template: &str, flavor: Flavor, label: &str, operator_pronouns: &str) -> String { +pub fn render( + template: &str, + flavor: Flavor, + label: &str, + operator_pronouns: &str, + hive_name: Option<&str>, + swarm_name: Option<&str>, +) -> String { let target = match flavor { Flavor::Agent => "agent", Flavor::Manager => "manager", }; let body = filter_role_blocks(template, target); let qualified = crate::identity::qualify(label); + let hive_identity = hive_name + .filter(|n| !n.is_empty()) + .map_or(String::new(), |n| format!(" on hive `{n}`")); + let swarm_identity = swarm_name + .filter(|n| !n.is_empty()) + .map_or(String::new(), |n| format!(" in swarm `{n}`")); body.replace("{label}", label) .replace("{qualified_label}", &qualified) .replace("{operator_pronouns}", operator_pronouns) + .replace("{hive_identity}", &hive_identity) + .replace("{swarm_identity}", &swarm_identity) } /// Walk `template` line-by-line. Inside a `` block, @@ -139,7 +166,19 @@ pub async fn write_system_prompt(_socket: &Path, label: &str, flavor: Flavor) -> template_path.display() ) })?; - let body = render(&template, flavor, label, &pronouns); + // #701 / #709: surface hive + swarm display names in the prompt + // opener when configured. Both `None` falls back to the pre-#709 + // wording verbatim (single-hive deployments see no diff). + let hive_name = crate::identity::hive_name(); + let swarm_name = crate::identity::swarm_name(); + let body = render( + &template, + flavor, + label, + &pronouns, + hive_name.as_deref(), + swarm_name.as_deref(), + ); let path = parent.join("claude-system-prompt.md"); tokio::fs::write(&path, body).await?; tracing::info!(path = %path.display(), "wrote claude system prompt"); @@ -279,7 +318,14 @@ shared closer // Real template's first agent line — keeps the renderer // honest about the {label} / {operator_pronouns} pair the // harness already relied on. - let rendered = render(&PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "they/them"); + let rendered = render( + &PRODUCTION_TEMPLATE, + Flavor::Agent, + "alice", + "they/them", + None, + None, + ); assert!(rendered.contains("hyperhive agent `alice`")); assert!(rendered.contains("**they/them** pronouns")); assert!(!rendered.contains("{label}")); @@ -292,7 +338,14 @@ shared closer // kill, schedule_*) MUST NOT appear in the agent's rendered // prompt. Drift between flavor and tool surface bites every // time it happens (cf. #511 missing-allow-list bug). - let rendered = render(&PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her"); + let rendered = render( + &PRODUCTION_TEMPLATE, + Flavor::Agent, + "alice", + "she/her", + None, + None, + ); assert!(!rendered.contains("request_init_config")); assert!(!rendered.contains("request_apply_commit")); assert!(!rendered.contains("get_logs")); @@ -303,7 +356,14 @@ shared closer #[test] fn render_manager_includes_manager_only_tools() { - let rendered = render(&PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); + let rendered = render( + &PRODUCTION_TEMPLATE, + Flavor::Manager, + "hm1nd", + "she/her", + None, + None, + ); assert!(rendered.contains("request_init_config")); assert!(rendered.contains("request_apply_commit")); assert!(rendered.contains("get_logs")); @@ -315,9 +375,118 @@ shared closer #[test] fn render_uses_correct_role_opener() { - let agent = render(&PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her"); + let agent = render( + &PRODUCTION_TEMPLATE, + Flavor::Agent, + "alice", + "she/her", + None, + None, + ); assert!(agent.starts_with("You are hyperhive agent")); - let manager = render(&PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); + let manager = render( + &PRODUCTION_TEMPLATE, + Flavor::Manager, + "hm1nd", + "she/her", + None, + None, + ); assert!(manager.starts_with("You are the hyperhive manager")); } + + // Inline fixture for the #709 placeholders. Cargo's `cargo test` + // resolves `PRODUCTION_TEMPLATE` against `$HIVE_ASSETS_DIR/prompts/ + // system.md`, which the flake builds at derivation time — a fresh + // placeholder added on the source side isn't in the shipped asset + // until the flake rebuilds, so PRODUCTION_TEMPLATE can't be the + // fixture here. The string below carries just enough of the + // opener shape to exercise the substitution logic; nothing here + // depends on the production template's flavor markers. + const IDENTITY_FIXTURE: &str = "\ +You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity}{swarm_identity} in a multi-agent system. Pronouns: **{operator_pronouns}**. +"; + + #[test] + fn render_substitutes_hive_identity_when_set() { + let rendered = render( + IDENTITY_FIXTURE, + Flavor::Agent, + "alice", + "she/her", + Some("pr1ma"), + None, + ); + assert!(rendered.contains("on hive `pr1ma`"), "{rendered}"); + // swarm clause stays absent when only hive is set. + assert!(!rendered.contains("in swarm")); + // No raw placeholder leaks. + assert!(!rendered.contains("{hive_identity}")); + assert!(!rendered.contains("{swarm_identity}")); + } + + #[test] + fn render_substitutes_swarm_identity_when_set() { + let rendered = render( + IDENTITY_FIXTURE, + Flavor::Manager, + "hm1nd", + "she/her", + None, + Some("constellat1on"), + ); + assert!(rendered.contains("in swarm `constellat1on`")); + assert!(!rendered.contains("on hive")); + } + + #[test] + fn render_substitutes_both_when_both_set() { + let rendered = render( + IDENTITY_FIXTURE, + Flavor::Agent, + "iris", + "she/her", + Some("pr1ma"), + Some("constellat1on"), + ); + // Order: hive then swarm, both inline before "in a multi-agent + // system" — keeps the opener grammar intact. + assert!(rendered.contains("on hive `pr1ma` in swarm `constellat1on`")); + } + + #[test] + fn render_omits_identity_when_unset() { + // None / None must round-trip the pre-#709 opener verbatim — + // single-hive deployments see zero diff. + let rendered = render( + IDENTITY_FIXTURE, + Flavor::Agent, + "alice", + "she/her", + None, + None, + ); + assert!(!rendered.contains("on hive")); + assert!(!rendered.contains("in swarm")); + assert!(!rendered.contains("{hive_identity}")); + assert!(!rendered.contains("{swarm_identity}")); + } + + #[test] + fn render_treats_empty_identity_as_none() { + // Defensive: an env var set to empty string round-trips + // through `identity::hive_name()` as None (the accessor + // filters empty), but `render` should still no-op on a + // direct `Some("")` from a test fixture or a future caller. + let rendered = render( + IDENTITY_FIXTURE, + Flavor::Agent, + "alice", + "she/her", + Some(""), + Some(""), + ); + assert!(!rendered.contains("on hive")); + assert!(!rendered.contains("in swarm")); + } }