archetypes: build an archetype agent from its archetype's module on the hive
The hive reads the agent-archetype key when it provisions and rebuilds an agent and keeps it in swarm-agent-archetypes.json. For an archetype agent the meta flake has no agent-<name> input and mkAgent imports hyperhive.hyperhiveArchetypes.<name>.module; provisioning, rebuilds, the meta-input cascade, a deploy's rev and the startup migration skip the config repos. The harness gets HIVE_ARCHETYPE and the system prompt says the agent has no config repo. The create form sends the picked archetype. Adds the android-dev built-in. Refs #5386
This commit is contained in:
parent
b199c83899
commit
553ed8376c
24 changed files with 557 additions and 92 deletions
|
|
@ -1,6 +1,6 @@
|
|||
//! 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
|
||||
//! HTML-comment markers gating role-specific and config-repo-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`).
|
||||
|
||||
|
|
@ -32,7 +32,7 @@ pub fn render(
|
|||
docs_dir: Option<&str>,
|
||||
operators: &[OperatorProfile],
|
||||
) -> String {
|
||||
let body = filter_role_blocks(template, "agent");
|
||||
let body = filter_blocks(template, "role", "agent");
|
||||
let qualified = crate::identity::qualify(label);
|
||||
let hive_identity = hive_name
|
||||
.filter(|n| !n.is_empty())
|
||||
|
|
@ -118,27 +118,27 @@ async fn within_timeout<E: std::fmt::Display>(
|
|||
}
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Walk `template` line-by-line, stripping `<!-- KIND:X -->` /
|
||||
/// `<!-- /KIND:X -->` marker lines and suppressing the lines inside a block
|
||||
/// unless `X == target`. A close marker ends the current block. Markers of
|
||||
/// any other kind are left in place, for the pass that filters that kind.
|
||||
/// Two kinds exist: `role` (always `agent`; the grammar stays wired for a
|
||||
/// future manager / multi-role prompt) and `config` ([`config_blocks`]).
|
||||
fn filter_blocks(template: &str, kind: &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;
|
||||
// None = outside any block; Some(value) = inside a block of this kind.
|
||||
let mut active: Option<&str> = None;
|
||||
for line in template.lines() {
|
||||
let trimmed = line.trim();
|
||||
if let Some(role) = parse_open_marker(trimmed) {
|
||||
active_role = Some(role);
|
||||
if let Some(value) = parse_open_marker(trimmed, kind) {
|
||||
active = Some(value);
|
||||
continue;
|
||||
}
|
||||
if parse_close_marker(trimmed).is_some() {
|
||||
active_role = None;
|
||||
if parse_close_marker(trimmed, kind).is_some() {
|
||||
active = None;
|
||||
continue;
|
||||
}
|
||||
if active_role.is_none_or(|role| role == target) {
|
||||
if active.is_none_or(|value| value == target) {
|
||||
out.push_str(line);
|
||||
out.push('\n');
|
||||
}
|
||||
|
|
@ -146,23 +146,37 @@ fn filter_role_blocks(template: &str, target: &str) -> String {
|
|||
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)
|
||||
/// `template` with its `config:repo` blocks for a custom agent, or its
|
||||
/// `config:archetype` blocks and `{archetype}` filled in for an agent built
|
||||
/// from `archetype`. An empty name is a custom agent.
|
||||
fn config_blocks(template: &str, archetype: Option<&str>) -> String {
|
||||
match archetype.filter(|a| !a.is_empty()) {
|
||||
Some(name) => filter_blocks(template, "config", "archetype").replace("{archetype}", name),
|
||||
None => filter_blocks(template, "config", "repo"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `<!-- /role:agent -->` → `Some("agent")`. Mirror of
|
||||
/// [`parse_open_marker`] for the closing tag.
|
||||
fn parse_close_marker(line: &str) -> Option<&str> {
|
||||
/// `<!-- role:agent -->` with `kind = "role"` → `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(kind)` rejects the leading slash
|
||||
/// before we'd ever see it.
|
||||
fn parse_open_marker<'a>(line: &'a str, kind: &str) -> Option<&'a str> {
|
||||
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
||||
inside.strip_prefix("/role:").map(str::trim)
|
||||
let value = inside.strip_prefix(kind)?.strip_prefix(':')?.trim();
|
||||
Some(value)
|
||||
}
|
||||
|
||||
/// `<!-- /role:agent -->` with `kind = "role"` → `Some("agent")`. Mirror of
|
||||
/// [`parse_open_marker`] for the closing tag.
|
||||
fn parse_close_marker<'a>(line: &'a str, kind: &str) -> Option<&'a str> {
|
||||
let inside = line.strip_prefix("<!--")?.strip_suffix("-->")?.trim();
|
||||
inside
|
||||
.strip_prefix('/')?
|
||||
.strip_prefix(kind)?
|
||||
.strip_prefix(':')
|
||||
.map(str::trim)
|
||||
}
|
||||
|
||||
/// Write the assembled prompt to a stable path next to the harness
|
||||
|
|
@ -196,6 +210,9 @@ pub async fn write_system_prompt(_socket: &Path, label: &str) -> Result<PathBuf>
|
|||
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 template = config_blocks(&template, std::env::var("HIVE_ARCHETYPE").ok().as_deref());
|
||||
let body = render(
|
||||
&template,
|
||||
label,
|
||||
|
|
@ -257,7 +274,7 @@ shared closer
|
|||
|
||||
#[test]
|
||||
fn filter_keeps_shared_and_target_role() {
|
||||
let agent = filter_role_blocks(SAMPLE, "agent");
|
||||
let agent = filter_blocks(SAMPLE, "role", "agent");
|
||||
assert!(agent.contains("shared opener"));
|
||||
assert!(agent.contains("agent-only line"));
|
||||
assert!(!agent.contains("manager-only line"));
|
||||
|
|
@ -268,7 +285,7 @@ shared closer
|
|||
|
||||
#[test]
|
||||
fn filter_for_manager_picks_manager_block() {
|
||||
let manager = filter_role_blocks(SAMPLE, "manager");
|
||||
let manager = filter_blocks(SAMPLE, "role", "manager");
|
||||
assert!(manager.contains("shared opener"));
|
||||
assert!(!manager.contains("agent-only line"));
|
||||
assert!(manager.contains("manager-only line"));
|
||||
|
|
@ -278,23 +295,38 @@ shared closer
|
|||
|
||||
#[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"));
|
||||
assert_eq!(
|
||||
parse_open_marker("<!-- role:agent -->", "role"),
|
||||
Some("agent")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_open_marker("<!--role:agent-->", "role"),
|
||||
Some("agent")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_open_marker("<!-- role:manager -->", "role"),
|
||||
Some("manager")
|
||||
);
|
||||
// Close tags must NOT match open-tag parser.
|
||||
assert_eq!(parse_open_marker("<!-- /role:agent -->"), None);
|
||||
assert_eq!(parse_open_marker("<!-- /role:agent -->", "role"), None);
|
||||
// Non-markers pass through (return None).
|
||||
assert_eq!(parse_open_marker("just text"), None);
|
||||
assert_eq!(parse_open_marker("<!-- not a role -->"), None);
|
||||
assert_eq!(parse_open_marker("just text", "role"), None);
|
||||
assert_eq!(parse_open_marker("<!-- not a role -->", "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"));
|
||||
assert_eq!(
|
||||
parse_close_marker("<!-- /role:agent -->", "role"),
|
||||
Some("agent")
|
||||
);
|
||||
assert_eq!(
|
||||
parse_close_marker("<!--/role:manager-->", "role"),
|
||||
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);
|
||||
assert_eq!(parse_close_marker("<!-- role:agent -->", "role"), None);
|
||||
assert_eq!(parse_close_marker("just text", "role"), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -331,6 +363,49 @@ shared closer
|
|||
assert!(rendered.contains("`/agents/alice/work/`"), "{rendered}");
|
||||
}
|
||||
|
||||
const CONFIG_FIXTURE: &str = "\
|
||||
shared
|
||||
<!-- config:repo -->
|
||||
open a PR on agent-configs
|
||||
<!-- /config:repo -->
|
||||
<!-- config:archetype -->
|
||||
built from `{archetype}`, no config repo
|
||||
<!-- /config:archetype -->
|
||||
";
|
||||
|
||||
/// 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 config_blocks_pick_the_block_by_archetype() {
|
||||
for custom in [None, Some("")] {
|
||||
let picked = config_blocks(CONFIG_FIXTURE, custom);
|
||||
assert!(picked.contains("open a PR on agent-configs"), "{picked}");
|
||||
assert!(!picked.contains("no config repo"), "{picked}");
|
||||
}
|
||||
let picked = config_blocks(CONFIG_FIXTURE, Some("android-dev"));
|
||||
assert!(picked.contains("built from `android-dev`, no config repo"));
|
||||
assert!(!picked.contains("agent-configs"), "{picked}");
|
||||
assert!(picked.contains("shared"));
|
||||
assert!(!picked.contains("<!--"), "{picked}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_production_prompt_keeps_no_config_markers() {
|
||||
for archetype in [None, Some("reviewer")] {
|
||||
let rendered = render(
|
||||
&config_blocks(&PRODUCTION_TEMPLATE, archetype),
|
||||
"alice",
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
&[],
|
||||
);
|
||||
assert!(!rendered.contains("<!-- config:"), "{rendered}");
|
||||
assert!(!rendered.contains("<!-- /config:"), "{rendered}");
|
||||
assert!(!rendered.contains("{archetype}"), "{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
|
||||
|
|
|
|||
Loading…
Reference in a new issue