hyperhive/hive-ag3nt/src/prompt.rs

452 lines
18 KiB
Rust

//! System-prompt renderer. Single `prompts/system.md` with
//! HTML-comment markers gating role-specific blocks; this module
//! assembles the final prompt for a given flavor. Marker grammar +
//! placeholder substitution rules in
//! `docs/turn-loop.md::On-boot files` (`claude-system-prompt.md`).
use std::path::{Path, PathBuf};
use anyhow::{Context, Result};
use crate::mcp::Flavor;
/// Assemble the system prompt for a given flavor + 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.md::On-boot files` (`claude-system-prompt.md`).
#[must_use]
pub fn render(
template: &str,
flavor: Flavor,
label: &str,
operator_pronouns: &str,
hive_name: Option<&str>,
swarm_name: Option<&str>,
) -> String {
let target = match flavor {
Flavor::Agent => "agent",
Flavor::Manager => "manager",
};
let body = filter_role_blocks(template, target);
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}`"));
body.replace("{label}", label)
.replace("{qualified_label}", &qualified)
.replace("{operator_pronouns}", operator_pronouns)
.replace("{hive_identity}", &hive_identity)
.replace("{swarm_identity}", &swarm_identity)
}
/// Walk `template` line-by-line. Inside a `<!-- role:X -->` block,
/// suppress all lines unless `X == target`. Marker lines themselves are
/// always elided from the output. Unbalanced openers (no matching
/// closer) hold the suppression state until end-of-file. A mismatched
/// closer (`<!-- /role:manager -->` inside a `role:agent` block) is
/// elided from the output but does NOT reset the active role — keeps
/// the suppression conservative so a typo can't dump wrong-flavor
/// content.
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 let Some(close_role) = parse_close_marker(trimmed) {
if active_role == Some(close_role) {
active_role = None;
}
// Mismatched close: elide the marker line but keep the
// active role intact so wrong-flavor content stays gated.
continue;
}
let include = match active_role {
None => true,
Some(role) => role == target,
};
if include {
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, flavor: Flavor) -> Result<PathBuf> {
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();
let body = render(
&template,
flavor,
label,
&pronouns,
hive_name.as_deref(),
swarm_name.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-ag3nt/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 mismatched_close_keeps_active_role() {
// `<!-- role:agent -->` block with a stray `<!-- /role:manager -->`
// closer inside: the manager-tagged close must NOT pop the agent
// gate, else manager-target output would leak the agent block's
// text (or vice-versa). Stray marker line itself is still elided.
let template = "shared\n\
<!-- role:agent -->\n\
agent line 1\n\
<!-- /role:manager -->\n\
agent line 2\n\
<!-- /role:agent -->\n\
shared end\n";
let manager = filter_role_blocks(template, "manager");
// Both agent lines stay gated out for the manager target; the
// stray close didn't accidentally pop the role. Stray marker
// itself elided from the output.
assert!(!manager.contains("agent line 1"));
assert!(!manager.contains("agent line 2"));
assert!(!manager.contains("<!--"));
assert!(manager.contains("shared"));
assert!(manager.contains("shared end"));
// Agent target still sees both lines (matched closer pops at the end).
let agent = filter_role_blocks(template, "agent");
assert!(agent.contains("agent line 1"));
assert!(agent.contains("agent line 2"));
assert!(agent.contains("shared end"));
}
#[test]
fn unbalanced_opener_suppresses_until_eof() {
// Stray opener with no closer — content stays suppressed for
// the wrong-role target right through to end-of-file. Real-
// file safety net: a typo in a closer doesn't accidentally
// dump wrong-flavor content into the active prompt.
let template = "shared\n<!-- role:manager -->\nm-only\nstill m-only\n";
let agent = filter_role_blocks(template, "agent");
assert_eq!(agent, "shared\n");
}
#[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,
Flavor::Agent,
"alice",
"they/them",
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_agent_excludes_manager_only_tools() {
// Spot-check: the manager-only tool block (request_init_config,
// kill, schedule_*) MUST NOT appear in the agent's rendered
// prompt. Drift between flavor and tool surface bites every
// time it happens.
let rendered = render(
&PRODUCTION_TEMPLATE,
Flavor::Agent,
"alice",
"she/her",
None,
None,
);
assert!(!rendered.contains("request_init_config"));
assert!(!rendered.contains("request_apply_commit"));
assert!(!rendered.contains("get_logs"));
// Sanity: shared tools DO appear.
assert!(rendered.contains("mcp__hyperhive__recv"));
assert!(rendered.contains("mcp__hyperhive__ask"));
}
#[test]
fn render_manager_includes_manager_only_tools() {
let rendered = render(
&PRODUCTION_TEMPLATE,
Flavor::Manager,
"ruth",
"she/her",
None,
None,
);
assert!(rendered.contains("request_init_config"));
assert!(rendered.contains("request_apply_commit"));
assert!(rendered.contains("get_logs"));
assert!(rendered.contains("request_schedule_prompt"));
assert!(rendered.contains("cancel_schedule"));
// Sub-agent-only sections must NOT appear in manager prompt.
assert!(!rendered.contains("request_next_turn"));
}
#[test]
fn render_uses_correct_role_opener() {
let agent = render(
&PRODUCTION_TEMPLATE,
Flavor::Agent,
"alice",
"she/her",
None,
None,
);
assert!(agent.starts_with("You are hyperhive agent"));
let manager = render(
&PRODUCTION_TEMPLATE,
Flavor::Manager,
"ruth",
"she/her",
None,
None,
);
assert!(manager.starts_with("You are the hyperhive manager"));
}
// 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. Pronouns: **{operator_pronouns}**.
";
#[test]
fn render_substitutes_hive_identity_when_set() {
let rendered = render(
IDENTITY_FIXTURE,
Flavor::Agent,
"alice",
"she/her",
Some("pr1ma"),
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,
Flavor::Manager,
"ruth",
"she/her",
None,
Some("constellat1on"),
);
assert!(rendered.contains("in swarm `constellat1on`"));
assert!(!rendered.contains("on hive"));
}
#[test]
fn render_substitutes_both_when_both_set() {
let rendered = render(
IDENTITY_FIXTURE,
Flavor::Agent,
"iris",
"she/her",
Some("pr1ma"),
Some("constellat1on"),
);
// 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,
Flavor::Agent,
"alice",
"she/her",
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,
Flavor::Agent,
"alice",
"she/her",
Some(""),
Some(""),
);
assert!(!rendered.contains("on hive"));
assert!(!rendered.contains("in swarm"));
}
}