Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-agent/src/prompt.rs
damocles c0eb6780ec templates: the agent system prompt is a Jinja template, rendered through hive-template
system.md carries {{ label }}-style variables and an {% if not archetype %} block in place of the config:repo / config:archetype marker pairs, so prompt.rs drops its line filter, marker parsers and string replaces. The rendered prompt is byte for byte the old one for a custom agent, an archetype agent and an empty archetype name, with and without hive, swarm, docs and operators. hive-template now drops a block tag's own line.
2026-11-11 09:04:42 +01:00

499 lines
19 KiB
Rust

//! 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<String> = 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<OperatorProfile> {
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<Vec<OperatorProfile>, Box<dyn std::error::Error + Send + Sync>> {
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<E: std::fmt::Display>(
timeout: Duration,
read: impl Future<Output = std::result::Result<Vec<OperatorProfile>, E>>,
) -> Vec<OperatorProfile> {
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<PathBuf> {
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<String> = 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 ["{{", "{%", "<!-- config:", "<!-- /config:", "{archetype}"] {
assert!(!rendered.contains(left), "{left} left in:\n{rendered}");
}
}
}
// Inline fixture for the {{ hive_identity }} / {{ swarm_identity }}
// variables. Cargo's `cargo test` resolves `PRODUCTION_TEMPLATE`
// against `$HIVE_ASSETS_DIR/prompts/system.md`, which the flake
// builds at derivation time — a fresh placeholder added on the
// source side isn't in the shipped asset until the flake rebuilds,
// so PRODUCTION_TEMPLATE can't be the fixture here. The string
// below carries just enough of the opener shape to exercise the
// substitution logic; nothing here depends on the production
// template's config block.
const IDENTITY_FIXTURE: &str = "\
You are hyperhive agent `{{ label }}` (qualified: `{{ qualified_label }}`){{ hive_identity }}{{ swarm_identity }} in a multi-agent system.
";
#[test]
fn render_substitutes_hive_identity_when_set() {
let rendered = render(
IDENTITY_FIXTURE,
"alice",
None,
Some("pr1ma"),
None,
None,
&[],
);
assert!(rendered.contains("on hive `pr1ma`"), "{rendered}");
// swarm clause stays absent when only hive is set.
assert!(!rendered.contains("in swarm"));
// No raw placeholder leaks.
assert!(!rendered.contains("{hive_identity}"));
assert!(!rendered.contains("{swarm_identity}"));
}
#[test]
fn render_substitutes_swarm_identity_when_set() {
let rendered = render(
IDENTITY_FIXTURE,
"ruth",
None,
None,
Some("constellat1on"),
None,
&[],
);
assert!(rendered.contains("in swarm `constellat1on`"));
assert!(!rendered.contains("on hive"));
}
#[test]
fn render_substitutes_both_when_both_set() {
let rendered = render(
IDENTITY_FIXTURE,
"iris",
None,
Some("pr1ma"),
Some("constellat1on"),
None,
&[],
);
// Order: hive then swarm, both inline before "in a multi-agent
// system" — keeps the opener grammar intact.
assert!(rendered.contains("on hive `pr1ma` in swarm `constellat1on`"));
}
#[test]
fn render_omits_identity_when_unset() {
// None / None must round-trip the non-identity opener verbatim
// — single-hive deployments see zero diff.
let rendered = render(IDENTITY_FIXTURE, "alice", None, None, None, None, &[]);
assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm"));
assert!(!rendered.contains("{hive_identity}"));
assert!(!rendered.contains("{swarm_identity}"));
}
#[test]
fn render_treats_empty_identity_as_none() {
// Defensive: an env var set to empty string round-trips
// through `identity::hive_name()` as None (the accessor
// filters empty), but `render` should still no-op on a
// direct `Some("")` from a test fixture or a future caller.
let rendered = render(
IDENTITY_FIXTURE,
"alice",
None,
Some(""),
Some(""),
None,
&[],
);
assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm"));
}
#[test]
fn render_appends_docs_pointer_when_docs_dir_set() {
let rendered = render(
&PRODUCTION_TEMPLATE,
"alice",
None,
None,
None,
Some("/run/hive-docs"),
&[],
);
// Key on a phrase unique to the pointer sentence — "reference docs"
// alone also appears in the /knowledge blurb of the base template.
assert!(
rendered.contains("mounted read-only at `/run/hive-docs`"),
"expected docs pointer sentence with the dir:\n{rendered}"
);
}
#[test]
fn render_no_docs_pointer_when_docs_dir_absent_or_empty() {
for docs in [None, Some("")] {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", None, None, None, docs, &[]);
assert!(
!rendered.contains("mounted read-only at"),
"docs pointer must not appear for {docs:?}:\n{rendered}"
);
}
}
fn operator(name: &str, pronouns: Option<&str>) -> OperatorProfile {
OperatorProfile {
name: name.to_owned(),
pronouns: pronouns.map(str::to_owned),
}
}
const OPERATORS_FIXTURE: &str = "say \"the operator\".{{ operators }}\n\nnext paragraph\n";
#[test]
fn render_lists_operators_with_their_pronouns() {
let operators = [operator("mara", Some("she/her")), operator("sol", None)];
let rendered = render(
OPERATORS_FIXTURE,
"alice",
None,
None,
None,
None,
&operators,
);
assert_eq!(
rendered,
"say \"the operator\".\n\nOperators: mara (she/her), sol.\n\nnext paragraph\n"
);
let rendered = render(
&PRODUCTION_TEMPLATE,
"alice",
None,
None,
None,
None,
&operators,
);
assert!(
rendered.contains("Operators: mara (she/her), sol."),
"{rendered}"
);
}
/// No operators renders the template as it reads without the placeholder,
/// byte for byte.
#[test]
fn render_with_no_operators_is_the_template_without_the_placeholder() {
let rendered = render(OPERATORS_FIXTURE, "alice", None, None, None, None, &[]);
assert_eq!(rendered, "say \"the operator\".\n\nnext paragraph\n");
assert!(PRODUCTION_TEMPLATE.contains("{{ operators }}"));
assert_eq!(
render(&PRODUCTION_TEMPLATE, "alice", None, None, None, None, &[]),
render(
&PRODUCTION_TEMPLATE.replace("{{ operators }}", ""),
"alice",
None,
None,
None,
None,
&[]
)
);
}
/// An unreachable or failing bucket reads as no operators, so the prompt
/// renders as it does with none.
#[tokio::test(start_paused = true)]
async fn an_unread_operator_list_is_empty() {
let hangs = std::future::pending::<std::result::Result<Vec<OperatorProfile>, String>>();
let start = tokio::time::Instant::now();
assert_eq!(within_timeout(OPERATORS_TIMEOUT, hangs).await, []);
assert!(
start.elapsed() < OPERATORS_TIMEOUT * 2,
"{:?}",
start.elapsed()
);
let fails = async { Err::<Vec<OperatorProfile>, _>("no bucket".to_owned()) };
assert_eq!(within_timeout(OPERATORS_TIMEOUT, fails).await, []);
// Control: a read that answers in time is what comes back.
let answers = async { Ok::<_, String>(vec![operator("mara", Some("she/her"))]) };
assert_eq!(
within_timeout(OPERATORS_TIMEOUT, answers).await,
[operator("mara", Some("she/her"))]
);
}
}