//! 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. /// 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`). /// /// `{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. #[must_use] pub fn render(template: &str, flavor: Flavor, label: &str, operator_pronouns: &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); body.replace("{label}", label) .replace("{qualified_label}", &qualified) .replace("{operator_pronouns}", operator_pronouns) } /// 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 = socket.parent().unwrap_or_else(|| Path::new("/run/hive")); 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() ) })?; let body = render(&template, flavor, label, &pronouns); 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"); 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"); 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"); 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"); assert!(agent.starts_with("You are hyperhive agent")); let manager = render(&PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her"); assert!(manager.starts_with("You are the hyperhive manager")); } }