//! System-prompt renderer. Single `prompts/system.md` with //! HTML-comment markers gating role-specific and config-repo-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 std::time::Duration; use anyhow::{Context, Result}; use swarm_queue_client::operator_profiles::{self, OperatorProfile}; /// How long [`write_system_prompt`] waits for the operator list before /// rendering without it. const OPERATORS_TIMEOUT: Duration = Duration::from_secs(5); /// Assemble the system prompt for a given label + optional hive / /// swarm display names + the swarm's operators. 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, hive_name: Option<&str>, swarm_name: Option<&str>, docs_dir: Option<&str>, operators: &[OperatorProfile], ) -> String { let body = filter_blocks(template, "role", "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("{hive_identity}", &hive_identity) .replace("{swarm_identity}", &swarm_identity) .replace("{operators}", &operators_line(operators)); // When the reference docs are mounted in-container (`services.hyperhive.agent.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, } } /// The `{operators}` substitution: a paragraph naming each operator, with their /// pronouns where set, or nothing when there are none. fn operators_line(operators: &[OperatorProfile]) -> String { if operators.is_empty() { return String::new(); } let names: Vec = operators .iter() .map(|o| match &o.pronouns { Some(pronouns) => format!("{} ({pronouns})", o.name), None => o.name.clone(), }) .collect(); format!("\n\nOperators: {}.", names.join(", ")) } /// The swarm's operators, or none when the operator-profile bucket cannot be /// read within [`OPERATORS_TIMEOUT`]: the prompt then reads as it does with no /// operator list. async fn operators() -> Vec { within_timeout(OPERATORS_TIMEOUT, read_operators()).await } /// One read of the operator list over this agent's queue connection. async fn read_operators() -> std::result::Result, Box> { let client = crate::swarm_queue::client().await; swarm_queue_client::ensure_connected(&client)?; match operator_profiles::read(&client).await? { operator_profiles::Read::Current(operators) => Ok(operators), operator_profiles::Read::NoBucket => { Err("the operator-profiles bucket does not answer".into()) } } } /// `read`'s profiles, or none when it fails or outlasts `timeout`. async fn within_timeout( timeout: Duration, read: impl Future, E>>, ) -> Vec { match tokio::time::timeout(timeout, read).await { Ok(Ok(operators)) => operators, Ok(Err(e)) => { tracing::warn!(error = %e, "prompt: operator profiles unreadable; rendering without them"); Vec::new() } Err(_) => { tracing::warn!( timeout_s = timeout.as_secs(), "prompt: no operator profiles within the timeout; rendering without them" ); Vec::new() } } } /// 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. Markers of /// any other kind are left in place, for the pass that filters that kind. /// Two kinds exist: `role` (always `agent`; the grammar stays wired for a /// future manager / multi-role prompt) and `config` ([`config_blocks`]). fn filter_blocks(template: &str, kind: &str, target: &str) -> String { let mut out = String::with_capacity(template.len()); // None = outside any block; Some(value) = inside a block of this kind. let mut active: Option<&str> = None; for line in template.lines() { let trimmed = line.trim(); if let Some(value) = parse_open_marker(trimmed, kind) { active = Some(value); continue; } if parse_close_marker(trimmed, kind).is_some() { active = None; continue; } if active.is_none_or(|value| value == target) { out.push_str(line); out.push('\n'); } } out } /// `template` with its `config:repo` blocks for a custom agent, or its /// `config:archetype` blocks and `{archetype}` filled in for an agent built /// from `archetype`. An empty name is a custom agent. fn config_blocks(template: &str, archetype: Option<&str>) -> String { match archetype.filter(|a| !a.is_empty()) { Some(name) => filter_blocks(template, "config", "archetype").replace("{archetype}", name), None => filter_blocks(template, "config", "repo"), } } /// `` with `kind = "role"` → `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(kind)` rejects the leading slash /// before we'd ever see it. fn parse_open_marker<'a>(line: &'a str, kind: &str) -> Option<&'a str> { let inside = line.strip_prefix("")?.trim(); let value = inside.strip_prefix(kind)?.strip_prefix(':')?.trim(); Some(value) } /// `` with `kind = "role"` → `Some("agent")`. Mirror of /// [`parse_open_marker`] for the closing tag. fn parse_close_marker<'a>(line: &'a str, kind: &str) -> Option<&'a str> { let inside = line.strip_prefix("")?.trim(); inside .strip_prefix('/')? .strip_prefix(kind)? .strip_prefix(':') .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 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(); // `services.hyperhive.agent.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()); // The meta flake sets HIVE_ARCHETYPE on an agent built from an archetype, // which has no config repo of its own. let template = config_blocks(&template, std::env::var("HIVE_ARCHETYPE").ok().as_deref()); let body = render( &template, label, hive_name.as_deref(), swarm_name.as_deref(), docs_dir.as_deref(), &operators().await, ); 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_blocks(SAMPLE, "role", "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 `", "role"), Some("agent") ); assert_eq!( parse_open_marker("", "role"), Some("agent") ); assert_eq!( parse_open_marker("", "role"), Some("manager") ); // Close tags must NOT match open-tag parser. assert_eq!(parse_open_marker("", "role"), None); // Non-markers pass through (return None). assert_eq!(parse_open_marker("just text", "role"), None); assert_eq!(parse_open_marker("", "role"), None); } #[test] fn parse_close_marker_handles_whitespace_variants() { assert_eq!( parse_close_marker("", "role"), Some("agent") ); assert_eq!( parse_close_marker("", "role"), Some("manager") ); // Open tags must NOT match close-tag parser. assert_eq!(parse_close_marker("", "role"), None); assert_eq!(parse_close_marker("just text", "role"), None); } #[test] fn render_substitutes_label_and_names_no_operator_pronouns() { // Real template's first agent line. let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]); assert!(rendered.contains("hyperhive agent `alice`")); assert!(rendered.contains("say \"the operator\"")); assert!(!rendered.contains("pronoun")); 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", None, None, None, &[]); assert!(!rendered.contains(" open a PR on agent-configs built from `{archetype}`, no config repo "; /// A custom agent gets the config-repo lines, an archetype agent the /// archetype lines with its name, and an empty name is a custom agent. #[test] fn config_blocks_pick_the_block_by_archetype() { for custom in [None, Some("")] { let picked = config_blocks(CONFIG_FIXTURE, custom); assert!(picked.contains("open a PR on agent-configs"), "{picked}"); assert!(!picked.contains("no config repo"), "{picked}"); } let picked = config_blocks(CONFIG_FIXTURE, Some("android-dev")); assert!(picked.contains("built from `android-dev`, no config repo")); assert!(!picked.contains("agent-configs"), "{picked}"); assert!(picked.contains("shared")); assert!(!picked.contains("