//! System-prompt renderer (closes #519). //! //! Both flavors (agent / manager) used to live in separate files //! (`prompts/agent.md`, `prompts/manager.md`) that drifted in lockstep //! whenever someone updated only one. Now there's a single //! `prompts/system.md` with HTML-comment markers gating role-specific //! blocks; this module assembles the final prompt for a given flavor. //! //! Marker syntax (HTML comments — invisible in rendered markdown, //! distinct from `{label}` / `{operator_pronouns}` placeholders): //! //! ```text //! //! sub-agent-only paragraph //! //! //! shared paragraph //! //! //! manager-only paragraph //! //! ``` //! //! Content outside any marker is shared. Nesting is NOT supported; //! a stray opener overrides until its closing tag (or end of file). //! When #513 lands the marker grammar can grow `cap:` blocks //! the same way without touching the renderer surface (the role //! distinction folds into a per-cap-set lookup). use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; use crate::mcp::Flavor; /// 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 /// fixture and production reads it once at harness startup via /// [`hive_sh4re::assets::prompt_template`] (`$HIVE_ASSETS_DIR/prompts/ /// system.md`). /// /// 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, 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, /// suppress all lines unless `X == target`. Marker lines themselves are /// always elided from the output. Unbalanced openers (no matching /// closer) hold the suppression state until end-of-file. A mismatched /// closer (`` inside a `role:agent` block) is /// elided from the output but does NOT reset the active role — keeps /// the suppression conservative so a typo can't dump wrong-flavor /// content (per argus #527 review nit). fn filter_role_blocks(template: &str, target: &str) -> String { let mut out = String::with_capacity(template.len()); // None = outside any block; Some(role) = inside role-tagged block. let mut active_role: Option<&str> = None; for line in template.lines() { let trimmed = line.trim(); if let Some(role) = parse_open_marker(trimmed) { active_role = Some(role); continue; } if let Some(close_role) = parse_close_marker(trimmed) { if active_role == Some(close_role) { active_role = None; } // Mismatched close: elide the marker line but keep the // active role intact so wrong-flavor content stays gated. continue; } let include = match active_role { None => true, Some(role) => role == target, }; if include { out.push_str(line); out.push('\n'); } } out } /// `` → `Some("agent")`. Anything else returns /// None. Whitespace inside the marker is tolerated so a future /// author's `` (no spaces) still parses; the dashboard /// markdown renderer is equally lenient. Close tags (`/role:...`) /// can't accidentally match — the `strip_prefix("role:")` rejects /// the leading slash before we'd ever see it. fn parse_open_marker(line: &str) -> Option<&str> { let inside = line.strip_prefix("")?.trim(); let role = inside.strip_prefix("role:")?.trim(); Some(role) } /// `` → `Some("agent")`. Mirror of /// [`parse_open_marker`] for the closing tag. fn parse_close_marker(line: &str) -> Option<&str> { let inside = line.strip_prefix("")?.trim(); inside.strip_prefix("/role:").map(str::trim) } /// Write the assembled prompt to a stable path next to the harness /// socket and return the path. The Rust harness passes this path to /// `claude --system-prompt-file` so the per-turn prompts only carry /// the role + tools instructions in the system slot; per-turn prompts /// become much smaller (just the wake-message body). /// /// # Errors /// /// Returns an error if the system prompt file cannot be written. pub async fn write_system_prompt(_socket: &Path, label: &str, flavor: Flavor) -> Result { let parent = crate::paths::config_dir(); tokio::fs::create_dir_all(&parent).await.ok(); let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned()); let template_path = hive_sh4re::assets::prompt_template(); let template = tokio::fs::read_to_string(&template_path) .await .with_context(|| { format!( "read claude system prompt template from {}", template_path.display() ) })?; // #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"); Ok(path) } #[cfg(test)] mod tests { use super::*; use std::sync::LazyLock; // #555: the production template lives at // `$HIVE_ASSETS_DIR/prompts/system.md` and is loaded at runtime. // The unit tests below want to assert against the actual production // wording (so the renderer + tool surface stay honest), so they // resolve the same path at test runtime via two fallbacks: // 1. `$HIVE_ASSETS_DIR/prompts/system.md` — the runtime contract // production uses. The flake's `checks.cargo-test` derivation // sets this to the `hyperhive-assets` output so `cargo test` // inside the nix sandbox finds the file without needing // `prompts/` in the cargo source tree. `packages.default` // explicitly does NOT carry the assets dep, so a prompt edit // doesn't bust the binary derivation — only this test check. // 2. `env!("CARGO_MANIFEST_DIR")/prompts/system.md` — for plain // `cargo test --workspace` from a checked-out repo where the // env var isn't set; `env!` is a compile-time string lookup, // no file open at compile, so this still doesn't pull // `prompts/` into the build hash. // The combined effect is that the flake's `cleanSrc` no longer // unions `./hive-ag3nt/prompts` — tweaks to system.md don't bust // the cargo cache anymore. static PRODUCTION_TEMPLATE: LazyLock = LazyLock::new(|| { let path = match std::env::var("HIVE_ASSETS_DIR") { Ok(v) if !v.is_empty() => format!("{v}/prompts/system.md"), _ => concat!(env!("CARGO_MANIFEST_DIR"), "/prompts/system.md").to_owned(), }; std::fs::read_to_string(&path) .unwrap_or_else(|e| panic!("read production prompt template at {path}: {e}")) }); const SAMPLE: &str = "\ shared opener agent-only line manager-only line shared closer "; #[test] fn filter_keeps_shared_and_target_role() { let agent = filter_role_blocks(SAMPLE, "agent"); assert!(agent.contains("shared opener")); assert!(agent.contains("agent-only line")); assert!(!agent.contains("manager-only line")); assert!(agent.contains("shared closer")); // Marker lines themselves are stripped — no `"), Some("agent")); assert_eq!(parse_open_marker(""), Some("agent")); assert_eq!(parse_open_marker(""), Some("manager")); // Close tags must NOT match open-tag parser. assert_eq!(parse_open_marker(""), None); // Non-markers pass through (return None). assert_eq!(parse_open_marker("just text"), None); assert_eq!(parse_open_marker(""), None); } #[test] fn parse_close_marker_handles_whitespace_variants() { assert_eq!(parse_close_marker(""), Some("agent")); assert_eq!(parse_close_marker(""), Some("manager")); // Open tags must NOT match close-tag parser. assert_eq!(parse_close_marker(""), None); assert_eq!(parse_close_marker("just text"), None); } #[test] fn mismatched_close_keeps_active_role() { // `` block with a stray `` // closer inside: the manager-tagged close must NOT pop the agent // gate, else manager-target output would leak the agent block's // text (or vice-versa). Stray marker line itself is still elided. // Per argus #527 review nit. let template = "shared\n\ \n\ agent line 1\n\ \n\ agent line 2\n\ \n\ shared end\n"; let manager = filter_role_blocks(template, "manager"); // Both agent lines stay gated out for the manager target; the // stray close didn't accidentally pop the role. Stray marker // itself elided from the output. assert!(!manager.contains("agent line 1")); assert!(!manager.contains("agent line 2")); assert!(!manager.contains("\nm-only\nstill m-only\n"; let agent = filter_role_blocks(template, "agent"); assert_eq!(agent, "shared\n"); } #[test] fn render_substitutes_label_and_pronouns() { // 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", None, None, ); assert!(rendered.contains("hyperhive agent `alice`")); assert!(rendered.contains("**they/them** pronouns")); assert!(!rendered.contains("{label}")); assert!(!rendered.contains("{operator_pronouns}")); } #[test] fn render_agent_excludes_manager_only_tools() { // Spot-check: the manager-only tool block (request_init_config, // 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", None, None, ); assert!(!rendered.contains("request_init_config")); assert!(!rendered.contains("request_apply_commit")); assert!(!rendered.contains("get_logs")); // Sanity: shared tools DO appear. assert!(rendered.contains("mcp__hyperhive__recv")); assert!(rendered.contains("mcp__hyperhive__ask")); } #[test] fn render_manager_includes_manager_only_tools() { 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")); assert!(rendered.contains("request_schedule_prompt")); assert!(rendered.contains("cancel_schedule")); // Sub-agent-only sections must NOT appear in manager prompt. assert!(!rendered.contains("request_next_turn")); } #[test] fn render_uses_correct_role_opener() { 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", 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")); } }