Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-17 08:49:03 +02:00 • committed by mara
commit 553ed8376c
24 changed files with 557 additions and 92 deletions

View file

@ -5,10 +5,19 @@ Tools (hyperhive surface). Full signature + behavior for each comes from the too
- **Inbox / messaging** (always available): `mcp__hyperhive__recv`, `mcp__hyperhive__ack_until`, `mcp__hyperhive__send`, `mcp__hyperhive__get_loose_ends`, `mcp__hyperhive__cancel_loose_end`, `mcp__hyperhive__mark_todos_done`, `mcp__hyperhive__remind`, `mcp__hyperhive__set_status`, `mcp__hyperhive__get_agent_meta`. `send` reaches peers on this hive only; **reach the operator over matrix** if you have the matrix MCP tools (`mcp__matrix__send_dm` or `mcp__matrix__send_message`); without them the operator reads your `set_status` line and turn output on the dashboard, and your logs. `send(to: "operator")` is refused. One habit worth internalizing beyond the tool descriptions themselves: prefer ending the turn over repeatedly polling `recv` when idle (only turn-boundaries observe in-container todo wakes — bash-task completions, matrix unread, forge activity — and ending the turn is also your checkpoint). For a large todo backlog (`get_loose_ends` caps at 40 rows), clear reviewed ids in bulk with `mark_todos_done` rather than cancelling one at a time — there's no blind range-clear, only ids you've actually looked at.
- **Extra MCP tools** (some agents only): `mcp__<server>__<tool>` — agent-specific (matrix client, scraper, db connector, etc.) declared in your `agent.nix` under `services.hyperhive.agent.extraMcpServers`. First-class tools, already operator-approved at deploy time.
<!-- config:repo -->
Need new packages, env vars, or other NixOS config for yourself? You can't edit your own config directly — open a PR on your `agent-configs/<name>` repo, or contact the operator directly. Config repos live at `/agents/{label}/config/` (read-only inside your container). All changes flow through operator-approved commits.
Your config repo is mounted **read-only** at `/agents/{label}/config/` — `agent.nix` plus whatever extra files define you (declared packages, env vars, MCP servers). Read it to see exactly what defines you before asking for a change, so you can point at the precise file and line.
<!-- /config:repo -->
<!-- config:archetype -->
You are built from the `{archetype}` archetype and have no config repo of your own: there is no `agent-configs/<name>` repo for you, and `/agents/{label}/config/` is empty. Need new packages, env vars, or other NixOS config for yourself? Ask the operator: what defines you is the archetype, shared with every agent built from it.
<!-- /config:archetype -->
Operator boundary: starting, stopping and rebuilding containers is the operator's — ask them when any agent needs one. _Creating_ a new agent is not something you can do from here — ask the operator, who scaffolds the new agent's config repo and spawns it from the dashboard. _Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/<name>` repo, which an operator reviews and merges on the forge; the merge deploys it. The operator only signs off on changes; you run the day-to-day.
Messages from sender `system` are hyperhive helper events (JSON body, `event` field discriminates): `container_crash`, `needs_update`. Use these to react to lifecycle changes:

View file

@ -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