First chunk of #589 v0 phase A: plumbing the hive-qualified 'name@hive' form through the per-agent surfaces that the harness itself owns. Broker from/to + dashboard rendering + container_view follow in subsequent PRs once damocles ships the HYPERHIVE_HIVE_DOMAIN env var in harness-base.nix. - new hive_ag3nt::identity module: label() / hive_domain() / qualified_label() / qualify(label). Reads HYPERHIVE_HIVE_DOMAIN (set by hive-c0re.nix module from hyperhive.domain) — when unset or empty, qualified_label degrades to just the short label so existing single-hive deployments are unchanged. Six unit tests cover the set / unset / empty / arbitrary-label paths. - prompt::render gains {qualified_label} substitution alongside the existing {label}. system.md template uses both: the agent intro now reads 'You are hyperhive agent iris (qualified: iris@darkest.space) in a multi-agent system. ... When you're talking to or about a peer on a different hive, use the qualified form (name@hive) so the operator + the manager can disambiguate'. Manager flavor gets the same treatment. - /api/state gains qualified_label: String. Always present, equals label when no domain is configured. - frontend setHeader takes the qualified_label, drives the browser tab title (so two tabs from different hives are distinguishable in the tab bar) while the glyphic #title stays short for the cinematic header. Gated on env var presence — no behaviour change for single-hive deployments. Pairs with damocles's upcoming harness-base.nix HYPERHIVE_HIVE_DOMAIN ship; safe to land in either order.
323 lines
14 KiB
Rust
323 lines
14 KiB
Rust
//! System-prompt renderer (closes #519).
|
|
//!
|
|
//! Both flavors (agent / manager) used to live in separate files
|
|
//! (`prompts/agent.md`, `prompts/manager.md`) that drifted in lockstep
|
|
//! whenever someone updated only one. Now there's a single
|
|
//! `prompts/system.md` with HTML-comment markers gating role-specific
|
|
//! blocks; this module assembles the final prompt for a given flavor.
|
|
//!
|
|
//! Marker syntax (HTML comments — invisible in rendered markdown,
|
|
//! distinct from `{label}` / `{operator_pronouns}` placeholders):
|
|
//!
|
|
//! ```text
|
|
//! <!-- role:agent -->
|
|
//! sub-agent-only paragraph
|
|
//! <!-- /role:agent -->
|
|
//!
|
|
//! shared paragraph
|
|
//!
|
|
//! <!-- role:manager -->
|
|
//! manager-only paragraph
|
|
//! <!-- /role:manager -->
|
|
//! ```
|
|
//!
|
|
//! Content outside any marker is shared. Nesting is NOT supported;
|
|
//! a stray opener overrides until its closing tag (or end of file).
|
|
//! When #513 lands the marker grammar can grow `cap:<group>` blocks
|
|
//! the same way without touching the renderer surface (the role
|
|
//! distinction folds into a per-cap-set lookup).
|
|
|
|
use std::path::{Path, PathBuf};
|
|
|
|
use anyhow::{Context, Result};
|
|
|
|
use crate::mcp::Flavor;
|
|
|
|
/// Assemble the system prompt for a given flavor + label + pronouns.
|
|
/// 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`).
|
|
///
|
|
/// `{label}` and `{operator_pronouns}` are substituted in the filtered body.
|
|
/// `{qualified_label}` (#589) is also substituted — it's `${label}@${hive}`
|
|
/// in federated deployments, or the same as `{label}` when no hive domain is
|
|
/// configured (single-hive deployments). Templates that always want the
|
|
/// fully-qualified form can use `{qualified_label}` and stay correct in
|
|
/// both shapes.
|
|
#[must_use]
|
|
pub fn render(template: &str, flavor: Flavor, label: &str, operator_pronouns: &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);
|
|
body.replace("{label}", label)
|
|
.replace("{qualified_label}", &qualified)
|
|
.replace("{operator_pronouns}", operator_pronouns)
|
|
}
|
|
|
|
/// 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 (per argus #527 review nit).
|
|
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 = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
|
|
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()
|
|
)
|
|
})?;
|
|
let body = render(&template, flavor, label, &pronouns);
|
|
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;
|
|
|
|
// #555: 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.
|
|
// Per argus #527 review nit.
|
|
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");
|
|
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 (cf. #511 missing-allow-list bug).
|
|
let rendered = render(&PRODUCTION_TEMPLATE, Flavor::Agent, "alice", "she/her");
|
|
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, "hm1nd", "she/her");
|
|
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");
|
|
assert!(agent.starts_with("You are hyperhive agent"));
|
|
let manager = render(&PRODUCTION_TEMPLATE, Flavor::Manager, "hm1nd", "she/her");
|
|
assert!(manager.starts_with("You are the hyperhive manager"));
|
|
}
|
|
}
|