From b199c838990a7f1fbacc92110b231da756105885 Mon Sep 17 00:00:00 2001
From: atlas
Date: Sat, 17 Oct 2026 08:32:48 +0200
Subject: [PATCH 1/3] archetypes: create an agent from an archetype with no
config repo
POST /api/agents takes an archetype the stored list holds, records it in
the new agent-archetype bucket, and queues no CreateRepo, AddRepoMember or
InitAgentConfigRepo for it. A destroy clears the key. Hives may read the
bucket.
Refs #5386
---
swarm-controller/src/agent_archetype.rs | 171 ++++++++++++
swarm-controller/src/agent_permissions.rs | 2 +
swarm-controller/src/main.rs | 302 +++++++++++++++++++---
swarm-nats-auth/src/policy.rs | 39 +++
swarm-queue-client/Cargo.toml | 3 +
swarm-queue-client/src/agent_archetype.rs | 159 ++++++++++++
swarm-queue-client/src/lib.rs | 6 +
7 files changed, 645 insertions(+), 37 deletions(-)
create mode 100644 swarm-controller/src/agent_archetype.rs
create mode 100644 swarm-queue-client/src/agent_archetype.rs
diff --git a/swarm-controller/src/agent_archetype.rs b/swarm-controller/src/agent_archetype.rs
new file mode 100644
index 00000000..fa072db5
--- /dev/null
+++ b/swarm-controller/src/agent_archetype.rs
@@ -0,0 +1,171 @@
+//! The archetype an agent was created from: one key per archetype agent in
+//! `agent-archetype` (`swarm_queue_client::agent_archetype`), which this
+//! daemon alone writes. `POST /api/agents` checks the choice against the
+//! `agent-archetypes` record and stores it before it queues the creation; a
+//! destroy deletes it. The hive reads it to build the agent from the
+//! archetype's module, with no config repo.
+
+use problem_details::ProblemDetails;
+use swarm_queue_client::agent_archetype::{self, ArchetypeRef};
+use swarm_queue_client::archetypes::SourceRecord;
+
+use crate::schedules::{queue, read_or_default, write_revision};
+use crate::{AppState, error_problem};
+
+/// How many times a losing writer re-reads and re-applies, as in
+/// `wanted::MAX_ATTEMPTS`.
+const MAX_ATTEMPTS: usize = 5;
+
+fn queue_problem(e: &anyhow::Error) -> ProblemDetails {
+ tracing::warn!(error = %format!("{e:#}"), "agent archetype: queue operation failed");
+ error_problem(
+ axum::http::StatusCode::INTERNAL_SERVER_ERROR,
+ &format!("{e:#}"),
+ )
+}
+
+/// Refuse `choice` unless the stored `agent-archetypes` record lists it.
+pub(crate) async fn check(
+ client: &async_nats::Client,
+ choice: &ArchetypeRef,
+) -> Result<(), ProblemDetails> {
+ let record = match swarm_queue_client::archetypes::open(client)
+ .await
+ .map_err(|e| queue_problem(&e.into()))?
+ {
+ Some(store) => {
+ read_or_default::(&store, &choice.source)
+ .await
+ .map_err(|e| queue_problem(&e))?
+ .0
+ }
+ None => SourceRecord::default(),
+ };
+ listed(&record, choice)
+ .map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))
+}
+
+/// Whether `record`, the stored record of `choice.source`, lists `choice`.
+fn listed(record: &SourceRecord, choice: &ArchetypeRef) -> Result<(), String> {
+ if record.archetypes.contains_key(&choice.name) {
+ return Ok(());
+ }
+ let ArchetypeRef { source, name } = choice;
+ Err(match record.refused.get(name) {
+ Some(why) => format!("archetype {name:?} of {source:?} was refused: {why}"),
+ None => format!("{source:?} has no archetype {name:?}; GET /api/archetypes lists them"),
+ })
+}
+
+/// What [`store`] replaced, for [`restore`]; `None` when it wrote nothing.
+pub(crate) type Replaced = Option>>;
+
+/// Record that `agent` is built from `choice`.
+pub(crate) async fn store(
+ client: &async_nats::Client,
+ agent: &str,
+ choice: &ArchetypeRef,
+) -> Result {
+ let store = agent_archetype::open_or_create(client)
+ .await
+ .map_err(|e| queue_problem(&e.into()))?;
+ let value = Some(choice.clone());
+ for _ in 0..MAX_ATTEMPTS {
+ let (current, revision, undecodable) =
+ read_or_default::
diff --git a/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs b/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs
index fb884822..e6d3d0ca 100644
--- a/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs
+++ b/frontend/packages/swarm-ui/test/create-agent-archetype.test.mjs
@@ -2,7 +2,11 @@
// Run by `npm test` with Node's type stripping.
import assert from "node:assert/strict";
import { test } from "node:test";
-import { archetypeKey, prefill } from "../src/pages/CreateAgentArchetype.ts";
+import {
+ archetypeKey,
+ prefill,
+ requestArchetype,
+} from "../src/pages/CreateAgentArchetype.ts";
const defaults = ["messaging", "meta", "inbox", "execution"];
@@ -65,3 +69,17 @@ test("the key tells sources apart", () => {
archetypeKey({ source: "b", name: "x", description: "" }),
);
});
+
+test("a picked archetype is sent by source and name, none is left out", () => {
+ const reviewer = {
+ source: "hyperhive",
+ name: "reviewer",
+ description: "reviews",
+ permissions: ["messaging"],
+ };
+ assert.deepEqual(requestArchetype(reviewer), {
+ source: "hyperhive",
+ name: "reviewer",
+ });
+ assert.equal(requestArchetype(undefined), undefined);
+});
diff --git a/hive-agent/prompts/system.md b/hive-agent/prompts/system.md
index 18bba8de..c0c76db0 100644
--- a/hive-agent/prompts/system.md
+++ b/hive-agent/prompts/system.md
@@ -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____` — 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.
+
+
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/` 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.
+
+
+
+You are built from the `{archetype}` archetype and have no config repo of your own: there is no `agent-configs/` 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.
+
+
+
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/` 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:
diff --git a/hive-agent/src/prompt.rs b/hive-agent/src/prompt.rs
index 5dccb8ea..4d865040 100644
--- a/hive-agent/src/prompt.rs
+++ b/hive-agent/src/prompt.rs
@@ -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(
}
}
-/// Walk `template` line-by-line, stripping `` /
-/// `` 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 `` /
+/// `` 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
}
-/// `` → `Some("agent")`. Anything else returns
-/// None. Whitespace inside the marker is tolerated so a future
-/// author's `` (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("")?.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"),
+ }
}
-/// `` → `Some("agent")`. Mirror of
-/// [`parse_open_marker`] for the closing tag.
-fn parse_close_marker(line: &str) -> Option<&str> {
+/// `` with `kind = "role"` → `Some("agent")`. Anything
+/// else returns None. Whitespace inside the marker is tolerated so a future
+/// author's `` (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("")?.trim();
- inside.strip_prefix("/role:").map(str::trim)
+ let value = inside.strip_prefix(kind)?.strip_prefix(':')?.trim();
+ Some(value)
+}
+
+/// `` 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("")?.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
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(""), Some("agent"));
- assert_eq!(parse_open_marker(""), Some("agent"));
- assert_eq!(parse_open_marker(""), Some("manager"));
+ assert_eq!(
+ parse_open_marker("", "role"),
+ Some("agent")
+ );
+ assert_eq!(
+ parse_open_marker("", "role"),
+ Some("agent")
+ );
+ assert_eq!(
+ parse_open_marker("", "role"),
+ Some("manager")
+ );
// Close tags must NOT match open-tag parser.
- assert_eq!(parse_open_marker(""), None);
+ assert_eq!(parse_open_marker("", "role"), None);
// Non-markers pass through (return None).
- assert_eq!(parse_open_marker("just text"), None);
- assert_eq!(parse_open_marker(""), None);
+ assert_eq!(parse_open_marker("just text", "role"), None);
+ assert_eq!(parse_open_marker("", "role"), None);
}
#[test]
fn parse_close_marker_handles_whitespace_variants() {
- assert_eq!(parse_close_marker(""), Some("agent"));
- assert_eq!(parse_close_marker(""), Some("manager"));
+ assert_eq!(
+ parse_close_marker("", "role"),
+ Some("agent")
+ );
+ assert_eq!(
+ parse_close_marker("", "role"),
+ Some("manager")
+ );
// Open tags must NOT match close-tag parser.
- assert_eq!(parse_close_marker(""), None);
- assert_eq!(parse_close_marker("just text"), None);
+ assert_eq!(parse_close_marker("", "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
+
+open a PR on agent-configs
+
+
+built from `{archetype}`, no config repo
+
+";
+
+ /// 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("
@@ -11,6 +11,8 @@ Need new packages, env vars, or other NixOS config for yourself? You can't edit
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.
+_Changing_ any agent's config is not a tool call at all — it's a forge PR on the agent's `agent-configs/` repo, which an operator reviews and merges on the forge; the merge deploys it.
+
@@ -18,7 +20,7 @@ You are built from the `{archetype}` archetype and have no config repo of your o
-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/` 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.
+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 creates agents at swarm level. 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:
diff --git a/hive-c0re/src/lifecycle/host_config.rs b/hive-c0re/src/lifecycle/host_config.rs
index df60f3a9..b11d715d 100644
--- a/hive-c0re/src/lifecycle/host_config.rs
+++ b/hive-c0re/src/lifecycle/host_config.rs
@@ -305,8 +305,10 @@ pub const CONTAINER_MANAGER_APPLIED_MOUNT: &str = "/applied";
/// and a `ManageRootAgent` holder's view of another agent's) go through
/// here so they cannot drift.
///
-/// Never empty under a live container: `provision_container` runs
-/// `setup_applied` before `create_only` makes the container at all.
+/// For a custom agent it is never empty under a live container:
+/// `provision_container` runs `setup_applied` before `create_only` makes the
+/// container at all. An archetype agent (`crate::archetypes`) has no applied
+/// repo, so its mount is the empty directory the caller creates.
fn config_bind_source(name: &str) -> PathBuf {
crate::paths::applied_dir(name)
}