Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-agent/src/prompt.rs
atlas 49015eb553 keep the operator list under one key and read it with one direct get
The agent read the operator-profiles bucket with a keys() listing, an
ordered consumer whose grant (STREAM.INFO, CONSUMER.CREATE) and flow-control
reply had never run against a permissioned queue. The controller now writes
the whole list as one JSON array under one key, only when it changed, and
the agent reads it with one DIRECT.GET the way agent_inference::read_own
reads its key. The agent grant shrinks to that one subject.
2026-10-17 13:10:05 +02:00

503 lines
20 KiB
Rust

//! 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 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_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("{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<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()
}
}
}
/// Walk `template` line-by-line, stripping `<!-- role:X -->` /
/// `<!-- /role:X -->` 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
}
/// `<!-- role:agent -->` → `Some("agent")`. Anything else returns
/// None. Whitespace inside the marker is tolerated so a future
/// author's `<!--role:foo-->` (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("<!--")?.strip_suffix("-->")?.trim();
let role = inside.strip_prefix("role:")?.trim();
Some(role)
}
/// `<!-- /role:agent -->` → `Some("agent")`. Mirror of
/// [`parse_open_marker`] for the closing tag.
fn parse_close_marker(line: &str) -> Option<&str> {
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.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<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());
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<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}"))
});
const SAMPLE: &str = "\
shared opener
<!-- role:agent -->
agent-only line
<!-- /role:agent -->
<!-- role:manager -->
manager-only line
<!-- /role:manager -->
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 `<!--` left behind.
assert!(!agent.contains("<!--"));
}
#[test]
fn filter_for_manager_picks_manager_block() {
let manager = filter_role_blocks(SAMPLE, "manager");
assert!(manager.contains("shared opener"));
assert!(!manager.contains("agent-only line"));
assert!(manager.contains("manager-only line"));
assert!(manager.contains("shared closer"));
assert!(!manager.contains("<!--"));
}
#[test]
fn parse_open_marker_handles_whitespace_variants() {
assert_eq!(parse_open_marker("<!-- role:agent -->"), Some("agent"));
assert_eq!(parse_open_marker("<!--role:agent-->"), Some("agent"));
assert_eq!(parse_open_marker("<!-- role:manager -->"), Some("manager"));
// Close tags must NOT match open-tag parser.
assert_eq!(parse_open_marker("<!-- /role:agent -->"), None);
// Non-markers pass through (return None).
assert_eq!(parse_open_marker("just text"), None);
assert_eq!(parse_open_marker("<!-- not a role -->"), None);
}
#[test]
fn parse_close_marker_handles_whitespace_variants() {
assert_eq!(parse_close_marker("<!-- /role:agent -->"), Some("agent"));
assert_eq!(parse_close_marker("<!--/role:manager-->"), Some("manager"));
// Open tags must NOT match close-tag parser.
assert_eq!(parse_close_marker("<!-- role:agent -->"), None);
assert_eq!(parse_close_marker("just text"), 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("<!-- role:"));
assert!(!rendered.contains("<!-- /role:"));
// Shared tools appear.
assert!(rendered.contains("mcp__hyperhive__recv"));
assert!(rendered.contains("mcp__hyperhive__send"));
}
#[test]
fn render_uses_agent_opener() {
let rendered = render(&PRODUCTION_TEMPLATE, "alice", 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, &[]);
assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}");
}
// Inline fixture for the {hive_identity} / {swarm_identity}
// placeholders. 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 flavor markers.
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", 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,
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",
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, &[]);
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", 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,
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, 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, &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, &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, &[]);
assert_eq!(rendered, "say \"the operator\".\n\nnext paragraph\n");
assert!(PRODUCTION_TEMPLATE.contains("{operators}"));
assert_eq!(
render(&PRODUCTION_TEMPLATE, "alice", None, None, None, &[]),
render(
&PRODUCTION_TEMPLATE.replace("{operators}", ""),
"alice",
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"))]
);
}
}