//! System-prompt renderer. Single `prompts/system.md`, a Jinja template rendered //! through `hive-template` with an if/else block for the config-repo paragraphs; //! this module builds its variables and assembles the final prompt. The template //! rules and variables are in //! `docs/4-implementation/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 template rendering 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`). The template rules and /// variables are documented in /// `docs/4-implementation/turn-loop/claude-invocation.md::On-boot files` (`claude-system-prompt.md`). #[must_use] pub fn render( template: &str, label: &str, archetype: Option<&str>, hive_name: Option<&str>, swarm_name: Option<&str>, docs_dir: Option<&str>, operators: &[OperatorProfile], ) -> String { 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 context = serde_json::json!({ "label": label, "qualified_label": crate::identity::qualify(label), "hive_identity": hive_identity, "swarm_identity": swarm_identity, "operators": operators_line(operators), // Absent for a custom agent, which has a config repo of its own. "archetype": archetype.filter(|a| !a.is_empty()), }); let rendered = match hive_template::render(template, &context) { Ok(text) => text, Err(why) => { tracing::error!(%why, "prompt: the system prompt template does not render; using it as written"); template.to_owned() } }; // 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() } } } /// 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 archetype = std::env::var("HIVE_ARCHETYPE").ok(); let body = render( &template, label, archetype.as_deref(), 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}")) }); #[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, 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_leaves_no_template_syntax_in_output() { // No raw template tag should survive into the rendered prompt. let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, None, &[]); assert!(!rendered.contains("{{")); assert!(!rendered.contains("{%")); // The guidance the tool schemas cannot give still appears. assert!(rendered.contains("mcp__matrix__send_dm")); assert!(rendered.contains("ending the turn")); } #[test] fn render_uses_agent_opener() { let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, None, &[]); assert!(rendered.starts_with("You are hyperhive agent")); } #[test] fn render_points_agents_at_the_work_dir() { let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, None, &[]); assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}"); } const CONFIG_FIXTURE: &str = "\ shared {% if not archetype %} open a PR on agent-configs {% else %} built from `{{ archetype }}`, no config repo {% endif %} "; /// 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 the_config_block_follows_the_archetype() { for custom in [None, Some("")] { let picked = render(CONFIG_FIXTURE, "alice", custom, None, None, None, &[]); assert_eq!(picked, "shared\nopen a PR on agent-configs\n"); } let picked = render( CONFIG_FIXTURE, "alice", Some("android-dev"), None, None, None, &[], ); assert_eq!(picked, "shared\nbuilt from `android-dev`, no config repo\n"); } #[test] fn the_production_prompt_keeps_no_template_syntax() { for archetype in [None, Some("reviewer")] { let rendered = render( &PRODUCTION_TEMPLATE, "alice", archetype, None, None, None, &[], ); for left in ["{{", "{%", "