//! 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/claude-invocation.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/claude-invocation.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>, docs_dir: 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}`")); let rendered = body .replace("{label}", label) .replace("{qualified_label}", &qualified) .replace("{operator_pronouns}", operator_pronouns) .replace("{hive_identity}", &hive_identity) .replace("{swarm_identity}", &swarm_identity); // When the reference docs are mounted in-container (`hyperhive.docs.enable` // wires `HIVE_DOCS_DIR` + `claude --add-dir`), append a single pointer // sentence so the agent knows they exist. Additive: it doesn't replace the // agent's own memory/project instructions. Absent env → no change. match docs_dir.filter(|d| !d.is_empty()) { Some(dir) => format!( "{rendered}\n\nThe hyperhive reference docs (the repo `docs/` tree \ describing this live system) are mounted read-only at `{dir}` — read \ them (start at the index, then the topic file for the area you're \ touching) rather than guessing.\n" ), None => rendered, } } /// Walk `template` line-by-line, stripping `` / /// `` marker lines and suppressing the lines inside a block /// unless `X == target`. A close marker ends the current block. Production /// `system.md` carries no markers today (single agent role — see the module /// doc), so this is effectively a passthrough; the marker grammar stays wired /// for a future manager / multi-role prompt. 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 parse_close_marker(trimmed).is_some() { active_role = None; continue; } if active_role.is_none_or(|role| role == target) { 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(); // `hyperhive.docs.enable` sets HIVE_DOCS_DIR (and the harness passes it to // claude via `--add-dir`); when present, render() appends a pointer line. let docs_dir = std::env::var("HIVE_DOCS_DIR") .ok() .filter(|d| !d.is_empty()); let body = render( &template, label, &pronouns, hive_name.as_deref(), swarm_name.as_deref(), docs_dir.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-agent/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 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, 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, None); assert!(!rendered.contains("