harness: unify agent + manager prompts into single template (closes #519)
This commit is contained in:
parent
88476976b7
commit
01af5003d1
9 changed files with 325 additions and 75 deletions
|
|
@ -318,8 +318,9 @@ async fn handle_agent_turn(
|
|||
}
|
||||
|
||||
// Per-turn user prompt: the role/tools/etc. is in the system prompt
|
||||
// (`prompts/agent.md` → `claude --system-prompt-file`); this is just the
|
||||
// wake signal claude reacts to. `unread` is the count of *other*
|
||||
// (`prompts/system.md` filtered to this agent's role-block via
|
||||
// `hive_ag3nt::prompt::render` → `claude --system-prompt-file`); this
|
||||
// is just the wake signal claude reacts to. `unread` is the count of *other*
|
||||
// messages in the inbox right after this one was popped.
|
||||
// `redelivered` flags messages that were popped in a prior harness
|
||||
// session, never acked, and resurfaced after a restart — a banner
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ pub mod login_session;
|
|||
pub mod mcp;
|
||||
pub mod paths;
|
||||
pub mod plugins;
|
||||
pub mod prompt;
|
||||
pub mod serve_common;
|
||||
pub mod stats;
|
||||
pub mod turn;
|
||||
|
|
|
|||
242
hive-ag3nt/src/prompt.rs
Normal file
242
hive-ag3nt/src/prompt.rs
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
//! 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::Result;
|
||||
|
||||
use crate::mcp::Flavor;
|
||||
|
||||
const TEMPLATE: &str = include_str!("../prompts/system.md");
|
||||
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub fn render(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);
|
||||
body.replace("{label}", label)
|
||||
.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.
|
||||
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;
|
||||
}
|
||||
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.
|
||||
fn parse_open_marker(line: &str) -> Option<&str> {
|
||||
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
||||
let role = inside.strip_prefix("role:")?.trim();
|
||||
// Reject closer-looking content ("/role:..." would start with "/")
|
||||
// so `/role:agent` doesn't accidentally match here.
|
||||
if role.starts_with('/') {
|
||||
return None;
|
||||
}
|
||||
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 body = render(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::*;
|
||||
|
||||
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 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(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(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(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(Flavor::Agent, "alice", "she/her");
|
||||
assert!(agent.starts_with("You are hyperhive agent"));
|
||||
let manager = render(Flavor::Manager, "hm1nd", "she/her");
|
||||
assert!(manager.starts_with("You are the hyperhive manager"));
|
||||
}
|
||||
}
|
||||
|
|
@ -164,11 +164,10 @@ pub async fn write_settings(socket: &Path) -> Result<PathBuf> {
|
|||
Ok(path)
|
||||
}
|
||||
|
||||
/// Write the agent's / manager's static system prompt to a file next to
|
||||
/// the MCP config and return the path. Passed to claude via
|
||||
/// `--system-prompt-file`, replacing claude's default system prompt with
|
||||
/// the role + tools instructions. Per-turn prompts become much smaller
|
||||
/// (just the wake message body).
|
||||
/// Thin re-export of [`crate::prompt::write_system_prompt`] for
|
||||
/// callers that already import this module. The actual rendering +
|
||||
/// marker-block logic lives in `prompt.rs` (closes #519); this is
|
||||
/// just the public entry point the binaries call.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
|
|
@ -178,20 +177,7 @@ pub async fn write_system_prompt(
|
|||
label: &str,
|
||||
flavor: mcp::Flavor,
|
||||
) -> Result<PathBuf> {
|
||||
let parent = socket.parent().unwrap_or_else(|| Path::new("/run/hive"));
|
||||
tokio::fs::create_dir_all(parent).await.ok();
|
||||
let template = match flavor {
|
||||
mcp::Flavor::Agent => include_str!("../prompts/agent.md"),
|
||||
mcp::Flavor::Manager => include_str!("../prompts/manager.md"),
|
||||
};
|
||||
let pronouns = std::env::var("HIVE_OPERATOR_PRONOUNS").unwrap_or_else(|_| "she/her".to_owned());
|
||||
let body = template
|
||||
.replace("{label}", label)
|
||||
.replace("{operator_pronouns}", &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)
|
||||
crate::prompt::write_system_prompt(socket, label, flavor).await
|
||||
}
|
||||
|
||||
/// One claude turn's outcome. The harness uses this to decide whether to
|
||||
|
|
|
|||
Loading…
Reference in a new issue