//! System-prompt renderer. Single `prompts/system.md` with //! HTML-comment markers gating role-specific blocks; this module //! assembles the final prompt (always "agent" role — there is only one //! role). Marker grammar + placeholder substitution rules in //! `docs/turn-loop.md::On-boot files` (`claude-system-prompt.md`). use std::path::{Path, PathBuf}; use anyhow::{Context, Result}; /// Assemble the system prompt for a given 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`). Substitution placeholders + /// marker grammar documented in /// `docs/turn-loop.md::On-boot files` (`claude-system-prompt.md`). #[must_use] pub fn render( template: &str, label: &str, operator_pronouns: &str, hive_name: Option<&str>, swarm_name: Option<&str>, ) -> String { let body = filter_role_blocks(template, "agent"); 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. 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) -> 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() ) })?; // Surface hive + swarm display names in the prompt opener when // configured. Both `None` falls back to the non-identity 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, 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; // 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. 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, "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_no_role_markers_in_output() { // No raw role markers should survive into the rendered prompt. let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None); assert!(!rendered.contains("