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::>(&store, agent) + .await + .map_err(|e| queue_problem(&e))?; + if current == value && !undecodable { + tracing::info!(%agent, ?choice, "set agent archetype at create"); + return Ok(None); + } + if let Some(written) = write_revision(&store, agent, &value, revision) + .await + .map_err(|e| queue_problem(&e))? + { + tracing::info!(%agent, ?choice, "set agent archetype at create"); + return Ok(Some(crate::schedules::Replaced::new( + current.map(Some), + written, + ))); + } + } + Err(queue_problem(&anyhow::anyhow!( + "gave up writing the archetype of {agent} after {MAX_ATTEMPTS} conflicting writes" + ))) +} + +/// Undo what [`store`] did, unless another write has landed since. Best +/// effort: a failure is logged, since the caller is already on its way to an +/// error of its own. +pub(crate) async fn restore(client: &async_nats::Client, agent: &str, replaced: Replaced) { + let Some(replaced) = replaced else { return }; + let undone = match agent_archetype::open_or_create(client).await { + Ok(store) => replaced.undo(&store, agent).await, + Err(e) => Err(anyhow::anyhow!("{e:?}")), + }; + match undone { + Ok(true) => {} + Ok(false) => { + tracing::info!(%agent, "left the agent's archetype: another write landed since"); + } + Err(e) => { + tracing::warn!(%agent, error = %format!("{e:#}"), "could not put back the agent's archetype"); + } + } +} + +/// Delete a destroyed agent's key, so the next agent of that name is not +/// built from this one's archetype. A failure is logged and does not stop the +/// destroy, as in `agent_permissions::clear_on_destroy`. +pub(crate) async fn clear_on_destroy(state: &AppState, hive: &str, agent: &str) { + let cleared = async { + let client = queue(state).map_err(|(_, why)| anyhow::anyhow!(why))?; + agent_archetype::open_or_create(&client) + .await? + .delete(agent) + .await?; + anyhow::Ok(()) + } + .await; + match cleared { + Ok(()) => tracing::info!(hive, agent, "destroyed agent's archetype cleared"), + Err(e) => tracing::error!( + hive, + agent, + error = %format!("{e:#}"), + "clearing this agent's archetype FAILED; it was destroyed anyway, so a new agent \ + of the same name is built from this archetype. Declaring the agent destroyed \ + again re-runs this." + ), + } +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + use swarm_queue_client::archetypes::ArchetypeMeta; + + fn choice(name: &str) -> ArchetypeRef { + ArchetypeRef { + source: "hyperhive".to_owned(), + name: name.to_owned(), + } + } + + #[test] + fn only_a_listed_archetype_passes() { + let record = SourceRecord { + source: "/nix/store/h".to_owned(), + archetypes: BTreeMap::from([("reviewer".to_owned(), ArchetypeMeta::default())]), + refused: BTreeMap::from([("broken".to_owned(), "limits: bad".to_owned())]), + }; + assert_eq!(listed(&record, &choice("reviewer")), Ok(())); + let refused = listed(&record, &choice("broken")).unwrap_err(); + assert!(refused.contains("limits: bad"), "{refused}"); + let unknown = listed(&record, &choice("nope")).unwrap_err(); + assert!(unknown.contains("no archetype \"nope\""), "{unknown}"); + listed(&SourceRecord::default(), &choice("reviewer")).unwrap_err(); + } +} diff --git a/swarm-controller/src/agent_permissions.rs b/swarm-controller/src/agent_permissions.rs index 26c32005..919f726a 100644 --- a/swarm-controller/src/agent_permissions.rs +++ b/swarm-controller/src/agent_permissions.rs @@ -797,6 +797,7 @@ mod tests { limits: None, inference: None, permissions, + archetype: None, }), ) }; @@ -857,6 +858,7 @@ mod tests { limits: None, inference: None, permissions: None, + archetype: None, }), ) .await diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 39a5c800..47365245 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -42,6 +42,7 @@ use swarm_authelia_bridge_sock::BridgeResponse; use utoipa::{OpenApi, ToSchema}; use utoipa_axum::{router::OpenApiRouter, routes}; +mod agent_archetype; mod agent_control; mod agent_icon; mod agent_identity; @@ -1455,12 +1456,13 @@ async fn set_agent_state( // After the declaration and never before it: the write above is the // destroy order, and until it lands the agent is not being torn down, so - // its credential and permissions have to keep working. Sequenced rather - // than spawned so both attempts are finished — and their outcomes + // its credential, permissions and archetype have to keep working. Sequenced + // rather than spawned so every attempt is finished — and their outcomes // logged — by the time the operator's call returns. if revokes_queue_credential(req.state) { revoke_or_complain(&hive, &agent).await; agent_permissions::clear_on_destroy(&state, &hive, &agent).await; + agent_archetype::clear_on_destroy(&state, &hive, &agent).await; } Ok(Json(render(&declaration))) } @@ -1793,6 +1795,12 @@ struct CreateAgentRequest { #[serde(default)] #[schema(value_type = Option>)] permissions: Option>, + /// The archetype to build the agent from, one `GET /api/archetypes` + /// lists. The agent then gets no `agent-configs/` repo: its hive + /// builds it from the archetype's module. Absent is a custom agent. + #[serde(default)] + #[schema(value_type = Option, example = json!({"source": "hyperhive", "name": "reviewer"}))] + archetype: Option, } /// Where the queued job landed — a caller polls `/api/jobq/graph` (or @@ -1827,9 +1835,11 @@ struct CreateAgentResponse { /// validation error, not an idempotent no-op. `InitAgentConfigRepo` has no /// such second dependency: seeding files uses this daemon's own forge /// token, never the agent's, so it only ever needed the repo (mara, design -/// review: it's a jobq graph, not a linear chain). Returns as soon as the -/// graph is inserted — **not** once any of it has run; `run_swarm_node` -/// does that asynchronously off the scheduler loop already running +/// review: it's a jobq graph, not a linear chain). An agent created from an +/// `archetype` has no config repo, so its graph has none of the three repo +/// nodes. Returns as soon as the graph is inserted — **not** once any of it +/// has run; `run_swarm_node` does that asynchronously off the scheduler loop +/// already running /// (`spawn_jobq_worker`). The response reports `CreateIdentity`'s id, one /// of the graph's two roots — a caller watches the whole thing settle via /// `/api/jobq/graph`, which serves every root, not just this one. @@ -1846,10 +1856,10 @@ struct CreateAgentResponse { request_body = CreateAgentRequest, responses( (status = 200, description = "job chain queued", body = CreateAgentResponse), - (status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, `limits` or `inference` holds a value the matching `PUT` refuses, or a permission is listed twice (problem+json)", body = String), + (status = 400, description = "`name` or `hive` is not a valid identifier, `name` is new and reserved or refused by the forge, `hive` is not in this swarm, `limits` or `inference` holds a value the matching `PUT` refuses, a permission is listed twice, or `archetype` is not one the stored archetype list holds (problem+json)", body = String), (status = 409, description = "the swarm has already placed `name` on a different hive (problem+json)", body = String), (status = 422, description = "an unknown field in the body, in `limits`, or in `inference`, an `effort` that is not one of the five levels, or a `permissions` entry that is not a permission name"), - (status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits`, `inference` or `permissions` is set and cannot be stored (problem+json)", body = String), + (status = 503, description = "the swarm queue is not connected, so whether `name` is placed on another hive is unknown, or `limits`, `inference`, `permissions` or `archetype` is set and cannot be stored (problem+json)", body = String), (status = 500, description = "the job chain could not be queued, or another hive's wanted state could not be read (problem+json)", body = String), ), tag = "agents" @@ -1864,7 +1874,7 @@ async fn create_agent( // Settings are checked before anything is created, so a bad value stops // the request rather than leaving an agent without them. - let settings = CreateSettings::parse(req.limits, req.inference, req.permissions) + let settings = CreateSettings::parse(req.limits, req.inference, req.permissions, req.archetype) .map_err(|why| error_problem(axum::http::StatusCode::BAD_REQUEST, &why))?; // Two checks, and the second is the one that makes the field worth @@ -1928,11 +1938,7 @@ async fn create_agent( } // Checked after the roster, so a mistyped hive stays a 400 while the queue // is down. - let settings_queue = if settings.any() { - Some(schedules::queue(&state).map_err(schedules::refused)?) - } else { - None - }; + let settings_queue = settings.queue(&state).await?; // Agent names are one swarm-wide namespace: identity, forge user, store // path and matrix id all carry the bare name. The same name on the same @@ -1965,6 +1971,7 @@ async fn create_agent( settings.limits.as_ref(), settings.inference.as_ref(), settings.permissions.as_ref(), + settings.archetype.as_ref(), ) .await? } @@ -1975,7 +1982,9 @@ async fn create_agent( .jobq .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); - sched.insert_job(None, |b| declare_agent_job(b, &agent, &hive)) + sched.insert_job(None, |b| { + declare_agent_job(b, &agent, &hive, settings.archetype.is_some()) + }) }; let ids = match queued { Ok(ids) => ids, @@ -2003,27 +2012,55 @@ struct CreateSettings { limits: Option, inference: Option, permissions: Option, + archetype: Option, } impl CreateSettings { /// Each setting given, or why its `PUT` would refuse the first bad one. + /// An archetype is checked here for its shape only; whether the swarm + /// lists it is [`agent_archetype::check`]. fn parse( limits: Option, inference: Option, permissions: Option>, + archetype: Option, ) -> Result { + if let Some(a) = &archetype { + for part in [&a.source, &a.name] { + hive_types::Ident::parse(part) + .map_err(|why| format!("archetype {part:?}: {why}"))?; + } + } Ok(Self { limits: limits.map(agent_limits::Caps::into_stored).transpose()?, inference: inference .map(inference::InferenceSettings::into_stored) .transpose()?, permissions: permissions.map(agent_permissions::at_create).transpose()?, + archetype, }) } /// Whether any setting is given. fn any(&self) -> bool { self.limits.is_some() || self.inference.is_some() || self.permissions.is_some() + || self.archetype.is_some() + } + + /// The queue to store the settings through, `None` when none is given. + /// An archetype is refused here unless the stored list holds it. + async fn queue( + &self, + state: &AppState, + ) -> Result, problem_details::ProblemDetails> { + if !self.any() { + return Ok(None); + } + let client = schedules::queue(state).map_err(schedules::refused)?; + if let Some(archetype) = &self.archetype { + agent_archetype::check(&client, archetype).await?; + } + Ok(Some(client)) } } @@ -2034,17 +2071,19 @@ struct StoredSettings { limits: agent_limits::Replaced, inference: inference::Replaced, permissions: agent_permissions::Replaced, + archetype: agent_archetype::Replaced, } impl StoredSettings { - /// Store whichever of `limits`, `permissions` and `inference` are given, in - /// that order. A failure on one puts back the ones stored before it. + /// Store whichever of `limits`, `permissions`, `inference` and `archetype` are + /// given, in that order. A failure on one puts back the ones stored before it. async fn store( client: &async_nats::Client, agent: &str, limits: Option<&swarm_queue_client::agent_limits::AgentCaps>, inference: Option<&swarm_queue_client::agent_inference::AgentInference>, permissions: Option<&swarm_queue_client::agent_permissions::AgentPermissions>, + archetype: Option<&swarm_queue_client::agent_archetype::ArchetypeRef>, ) -> Result { let mut stored = Self::default(); if let Some(limits) = limits { @@ -2068,6 +2107,15 @@ impl StoredSettings { } } } + if let Some(archetype) = archetype { + match agent_archetype::store(client, agent, archetype).await { + Ok(replaced) => stored.archetype = replaced, + Err(e) => { + stored.roll_back(client, agent).await; + return Err(e); + } + } + } Ok(stored) } @@ -2075,6 +2123,7 @@ impl StoredSettings { agent_limits::restore(client, agent, self.limits).await; inference::restore(client, agent, self.inference).await; agent_permissions::restore(client, agent, self.permissions).await; + agent_archetype::restore(client, agent, self.archetype).await; } } @@ -2430,6 +2479,7 @@ fn declare_agent_job( b: &hive_jobq::builder::JobBuilder, agent: &str, hive: &str, + archetype: bool, ) -> Vec { let create_identity = b.node(SwarmNodeKind::CreateIdentity { agent: agent.to_owned(), @@ -2440,22 +2490,31 @@ fn declare_agent_job( let create_forge_user = b.node(SwarmNodeKind::CreateForgeUser { agent: agent.to_owned(), }); - let create_repo = b - .node(SwarmNodeKind::CreateRepo { - agent: agent.to_owned(), - }) - .after_ok(create_identity); - // `AddRepoMember` needs both parents: the repo to add a collaborator to, - // and the forge user to add as one — adding a nonexistent user is a - // Forgejo validation error, not an idempotent no-op. - // `InitAgentConfigRepo` needs only the repo — see the doc comment above - // for why. - let _add_repo_member = b - .node(SwarmNodeKind::AddRepoMember { + // An archetype agent has no config repo: its hive builds it from the + // archetype's module, so there is no repo to create, share or seed. + let init_config = (!archetype).then(|| { + let create_repo = b + .node(SwarmNodeKind::CreateRepo { + agent: agent.to_owned(), + }) + .after_ok(create_identity); + // `AddRepoMember` needs both parents: the repo to add a collaborator + // to, and the forge user to add as one — adding a nonexistent user is + // a Forgejo validation error, not an idempotent no-op. + // `InitAgentConfigRepo` needs only the repo — see the doc comment + // above for why. + let _add_repo_member = b + .node(SwarmNodeKind::AddRepoMember { + agent: agent.to_owned(), + }) + .after_ok(create_repo) + .after_ok(create_forge_user); + b.node(SwarmNodeKind::InitAgentConfigRepo { agent: agent.to_owned(), }) .after_ok(create_repo) - .after_ok(create_forge_user); + .guid() + }); // Read on `internal/docs`, a repo that exists whatever this agent is, so // it needs only the user. Not a parent of the deploy: the agent reads docs // once running, and a failed grant is retried by `forge::docs_grant::spawn`. @@ -2464,11 +2523,6 @@ fn declare_agent_job( agent: agent.to_owned(), }) .after_ok(create_forge_user); - let init_config = b - .node(SwarmNodeKind::InitAgentConfigRepo { - agent: agent.to_owned(), - }) - .after_ok(create_repo); // The agent's own identity at the swarm secret store, minted and // published at swarm level so no hive ever needs the capability to mint // one. After `create_identity` because the certificate names a principal @@ -2507,7 +2561,8 @@ fn declare_agent_job( // deploys by reading that repo, so a deploy asked for any earlier would // find nothing to build. This is the edge that makes creating an agent at // swarm level actually put it on a hive, rather than leaving a - // provisioned name nobody runs. + // provisioned name nobody runs. An archetype agent has no repo, so its + // deploy waits on the mints and the declaration alone. // // `after_any` on the mint, not `after_ok`: a hive cannot pass down a // certificate the swarm has not published, so the deploy must not @@ -2526,13 +2581,12 @@ fn declare_agent_job( // queue at all fails this node — but `TriggerDeploy` has nothing to // publish to on such a host either, so the strict edge cancels a node // that could not have succeeded anyway. - let _trigger_deploy = b + let trigger_deploy = b .node(SwarmNodeKind::TriggerDeploy { hive: hive.to_owned(), agent: agent.to_owned(), rev: None, }) - .after_ok(init_config) .after_any(mint_identity) .after_any(mint_forge_token) // `after_any` for the same reason: the container reads this token @@ -2541,6 +2595,9 @@ fn declare_agent_job( // and only this node fails, by name. .after_any(mint_matrix) .after_ok(set_wanted); + if let Some(init_config) = init_config { + let _ = trigger_deploy.after_ok(init_config); + } vec![create_identity.guid()] } @@ -3904,6 +3961,7 @@ mod tests { limits: None, inference: None, permissions: None, + archetype: None, }), ) .await @@ -3945,6 +4003,7 @@ mod tests { limits: None, inference: None, permissions: None, + archetype: None, }), ) .await @@ -4108,6 +4167,7 @@ mod tests { Some(&caps(r#"{"cpu_quota":"400%"}"#)), Some(&inference), None, + None, ) .await; assert!(stored.is_err(), "{agent}: the inference write must fail"); @@ -4246,6 +4306,7 @@ mod tests { limits: None, inference: None, permissions: None, + archetype: None, }), ) .await @@ -5374,6 +5435,7 @@ mod tests { limits: None, inference: None, permissions: None, + archetype: None, }), ) .await @@ -5408,6 +5470,168 @@ mod tests { ); } + /// Node labels of the graph [`super::declare_agent_job`] builds, and the + /// labels the deploy node waits on. + fn creation_graph(archetype: bool) -> (Vec, Vec) { + use hive_jobq_wire::WireNode as _; + + let mut sched = hive_jobq::scheduler::Scheduler::new( + hive_jobq::Graph::new(), + hive_jobq::resources::ResourceTable::new(), + ); + sched + .insert_job(None, |b| { + super::declare_agent_job(b, "atlas", "pr1ma", archetype) + }) + .expect("the graph inserts"); + let graph = sched.graph(); + let labels = graph.nodes().map(|n| n.payload.label()).collect(); + let deploy = graph + .nodes() + .find(|n| n.payload.label() == "trigger_deploy") + .expect("every creation deploys"); + let waits = deploy + .deps + .iter() + .filter_map(|d| match d { + hive_jobq::Dep::Node { id, .. } => graph.node(*id).map(|n| n.payload.label()), + hive_jobq::Dep::Resource { .. } => None, + }) + .collect(); + (labels, waits) + } + + /// An archetype agent's graph has no repo nodes and its deploy waits on + /// no repo; a custom agent's has all three, and its deploy waits on the + /// seeded repo. + #[test] + fn an_archetype_agent_is_created_without_a_config_repo() { + const REPO_NODES: [&str; 3] = ["create_repo", "add_repo_member", "init_agent_config_repo"]; + + let (custom, custom_waits) = creation_graph(false); + for repo in REPO_NODES { + assert!(custom.iter().any(|l| l == repo), "custom lacks {repo}"); + } + assert!(custom_waits.iter().any(|l| l == "init_agent_config_repo")); + + let (archetype, archetype_waits) = creation_graph(true); + for repo in REPO_NODES { + assert!(!archetype.iter().any(|l| l == repo), "archetype has {repo}"); + } + for kept in [ + "create_identity", + "create_forge_user", + "grant_docs_read", + "mint_agent_identity", + "mint_agent_forge_token", + "mint_agent_matrix_account", + "set_agent_wanted", + ] { + assert!(archetype.iter().any(|l| l == kept), "archetype lacks {kept}"); + assert!(custom.iter().any(|l| l == kept), "custom lacks {kept}"); + } + assert!(archetype_waits.iter().any(|l| l == "set_agent_wanted")); + assert_eq!(archetype.len() + REPO_NODES.len(), custom.len()); + } + + /// Seed the stored archetype list with `reviewer` under `hyperhive`. + async fn seed_archetypes(client: &async_nats::Client) { + let record = swarm_queue_client::archetypes::SourceRecord { + source: "/nix/store/h".to_owned(), + archetypes: std::collections::BTreeMap::from([( + "reviewer".to_owned(), + swarm_queue_client::archetypes::ArchetypeMeta { + description: "reviews".to_owned(), + ..Default::default() + }, + )]), + refused: std::collections::BTreeMap::new(), + }; + swarm_queue_client::archetypes::open_or_create(client) + .await + .expect("bucket") + .put( + swarm_queue_client::archetypes::BUILTIN_SOURCE, + serde_json::to_vec(&record).expect("encodes").into(), + ) + .await + .expect("seeded"); + } + + /// Through the real handler and a real `nats-server`: a listed archetype + /// lands under the agent's key as the hive reads it, and the queued graph + /// has no config repo. + #[tokio::test] + async fn a_creation_from_an_archetype_records_it_and_queues_no_repo() { + use hive_jobq_wire::WireNode as _; + + let nats = crate::test_nats::TestNats::start(); + let client = nats.client().await; + seed_archetypes(&client).await; + let state = crate::agent_limits::tests::state(&client); + let req: super::CreateAgentRequest = serde_json::from_str( + r#"{"name":"atlas","hive":"pr1ma", + "archetype":{"source":"hyperhive","name":"reviewer"}}"#, + ) + .expect("body parses"); + let _queued = super::create_agent(axum::extract::State(state.clone()), axum::Json(req)) + .await + .expect("a listed archetype goes through"); + + let stored = swarm_queue_client::agent_archetype::open_or_create(&client) + .await + .expect("bucket") + .get("atlas") + .await + .expect("read") + .expect("a value"); + assert_eq!(&stored[..], br#"{"source":"hyperhive","name":"reviewer"}"#); + let labels: Vec = state + .jobq + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .graph() + .nodes() + .map(|n| n.payload.label()) + .collect(); + assert!(labels.iter().any(|l| l == "trigger_deploy"), "{labels:?}"); + assert!(!labels.iter().any(|l| l == "create_repo"), "{labels:?}"); + } + + /// An archetype the stored list does not hold is a 400 that stores and + /// queues nothing, and so is a name that is not an identifier. + #[tokio::test] + async fn an_unlisted_archetype_is_refused_before_anything_is_stored() { + let nats = crate::test_nats::TestNats::start(); + let client = nats.client().await; + seed_archetypes(&client).await; + let state = crate::agent_limits::tests::state(&client); + for archetype in [ + r#"{"source":"hyperhive","name":"nope"}"#, + r#"{"source":"elsewhere","name":"reviewer"}"#, + r#"{"source":"hyperhive","name":"a.b"}"#, + ] { + let req: super::CreateAgentRequest = serde_json::from_str(&format!( + r#"{{"name":"atlas","hive":"pr1ma","permissions":["messaging"], + "archetype":{archetype}}}"# + )) + .expect("body parses"); + let err = super::create_agent(axum::extract::State(state.clone()), axum::Json(req)) + .await + .expect_err("an unlisted archetype must be refused"); + assert_eq!( + err.status, + Some(axum::http::StatusCode::BAD_REQUEST), + "{archetype}: {err:?}" + ); + } + assert_eq!(node_count(&state.jobq), 0, "nothing may be queued"); + let permissions = swarm_queue_client::agent_permissions::open_or_create(&client) + .await + .expect("bucket"); + assert_eq!(permissions.get("atlas").await.expect("read"), None); + } + /// The ordering the operator's ruling requires: a hive cannot pass down /// a certificate the swarm has not published, so the deploy message must /// not leave before the mint is terminal. @@ -5428,6 +5652,7 @@ mod tests { limits: None, inference: None, permissions: None, + archetype: None, }), ) .await @@ -5478,6 +5703,7 @@ mod tests { limits: None, inference: None, permissions: None, + archetype: None, }), ) .await @@ -5536,6 +5762,7 @@ mod tests { limits: None, inference: None, permissions: None, + archetype: None, }), ) .await @@ -5741,6 +5968,7 @@ mod tests { limits: None, inference: None, permissions: None, + archetype: None, }), ) .await diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index c3482dd4..7776d3fa 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -583,6 +583,13 @@ impl Policy { "$JS.API.DIRECT.GET.{}.>", swarm_queue_client::agent_permissions::STREAM )); + // The archetype an agent was created from + // (`swarm_queue_client::agent_archetype`), read the same way and every + // key for the same reason. + subjects.push(format!( + "$JS.API.DIRECT.GET.{}.>", + swarm_queue_client::agent_archetype::STREAM + )); // The one-shot import of the grants this hive's meta repo held, sent // as a request on this hive's own subject: the controller takes the // hive from the subject, so one hive cannot import for another. @@ -877,6 +884,38 @@ mod tests { } } + /// A hive reads any agent's archetype and can change none of them, and the + /// grant reaches no other bucket. + #[test] + fn a_hive_may_read_every_agents_archetype_and_write_none() { + use swarm_queue_client::agent_archetype::{STREAM, direct_get_subject, key_subject}; + + let p = policy() + .permissions("hive-alpha") + .expect("a hive is admitted"); + let may = |subject: &str| p.publish.iter().any(|g| covers(g, subject)); + + for agent in ["atlas", "iris"] { + assert!(may(&direct_get_subject(agent)), "cannot read {agent}"); + } + for forbidden in [ + key_subject("atlas"), + format!("$JS.API.STREAM.CREATE.{STREAM}"), + format!("$JS.API.STREAM.UPDATE.{STREAM}"), + format!("$JS.API.STREAM.DELETE.{STREAM}"), + format!("$JS.API.STREAM.PURGE.{STREAM}"), + format!("$JS.API.STREAM.MSG.DELETE.{STREAM}"), + format!("$JS.API.CONSUMER.CREATE.{STREAM}"), + ] { + assert!(!may(&forbidden), "hive alpha may publish {forbidden}"); + } + // Not the per-source archetype list, which only the controller reads. + assert!(!may(&format!( + "$JS.API.DIRECT.GET.KV_{0}.$KV.{0}.hyperhive", + swarm_queue_client::archetypes::BUCKET + ))); + } + /// A hive reads any agent's permissions and can change none of them; an /// agent, with the template `swarm-nats.nix` passes, reads its own key and /// no other, and writes none. diff --git a/swarm-queue-client/Cargo.toml b/swarm-queue-client/Cargo.toml index 46825254..7bb3086e 100644 --- a/swarm-queue-client/Cargo.toml +++ b/swarm-queue-client/Cargo.toml @@ -59,6 +59,9 @@ agent-limits = ["async-nats/jetstream"] # `Permission` names, so the crates that only need the bucket name do not link # it. agent-permissions = ["dep:hive-sh4re", "async-nats/jetstream"] +# `agent_archetype::read`, for hive-c0re, which reads the archetype of each +# agent it provisions or rebuilds. Explicit for the same reason as `notices`. +agent-archetype = ["async-nats/jetstream"] [dependencies] # Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature diff --git a/swarm-queue-client/src/agent_archetype.rs b/swarm-queue-client/src/agent_archetype.rs new file mode 100644 index 00000000..9c0aa739 --- /dev/null +++ b/swarm-queue-client/src/agent_archetype.rs @@ -0,0 +1,159 @@ +//! The per-agent archetype KV bucket: one key per agent created from an +//! archetype, holding which archetype that is. +//! +//! **swarm-controller is the only writer**: `POST /api/agents` stores the key +//! before it queues the agent's creation, and a destroy deletes it. An agent +//! with no key is a custom agent, built from its own `agent-configs/` +//! repo. +//! +//! **The hive running the agent reads the key** when it provisions or +//! rebuilds the agent, and renders the archetype's module into its meta flake +//! in place of a config repo. Its grant covers every key of this bucket, +//! read-only, for the reason [`crate::agent_limits`] gives. +//! +//! Keyed by the agent alone, like [`crate::agent_limits`]: an agent can move +//! between hives, and its archetype goes with it. + +/// The KV bucket archetype choices are published into, keyed by agent name. +pub const BUCKET: &str = "agent-archetype"; + +/// The stream backing [`BUCKET`]. `create_key_value` prefixes `KV_`. +pub const STREAM: &str = "KV_agent-archetype"; + +/// The subject `agent`'s entry is stored under. +#[must_use] +pub fn key_subject(agent: &str) -> String { + format!("$KV.{BUCKET}.{agent}") +} + +/// The request subject that reads `agent`'s current entry. +#[must_use] +pub fn direct_get_subject(agent: &str) -> String { + format!("$JS.API.DIRECT.GET.{STREAM}.{}", key_subject(agent)) +} + +/// Which archetype an agent was created from: the value under +/// `BUCKET`/``. `source` is a key of [`crate::archetypes::BUCKET`] +/// and `name` an archetype in that source's record. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct ArchetypeRef { + pub source: String, + pub name: String, +} + +/// Open the archetype-choice bucket for writing, creating it if nothing has +/// yet. +/// +/// **Controller-side only.** `history: 1`: a hive wants the current value. +#[cfg(feature = "kv")] +pub async fn open_or_create( + client: &async_nats::Client, +) -> Result { + let js = async_nats::jetstream::new(client.clone()); + crate::kv::open_or_create( + &js, + BUCKET, + "agent-archetype", + "The archetype each agent was created from, set by the swarm controller".to_owned(), + ) + .await +} + +/// What reading one agent's key found. +#[cfg(feature = "agent-archetype")] +#[derive(Debug)] +pub enum Read { + /// The bucket answered: the agent's archetype, or `None` for an absent or + /// deleted key. + Current(Option), + /// Nothing answers for the bucket's stream: no archetype agent has been + /// created yet, or `JetStream` is not serving it right now. + NoBucket, +} + +/// Read `agent`'s current archetype with one direct get. +/// +/// # Errors +/// +/// A failed request or an undecodable value. A request the queue refused for +/// permissions reaches the client as a timeout, not as a refusal. +#[cfg(feature = "agent-archetype")] +pub async fn read( + client: &async_nats::Client, + agent: &str, +) -> Result> { + use async_nats::jetstream::stream::DirectGetErrorKind; + + let js = async_nats::jetstream::new(client.clone()); + let stream = js.get_stream_no_info(STREAM).await?; + let message = match stream.direct_get_last_for_subject(key_subject(agent)).await { + Ok(message) => message, + Err(e) if matches!(e.kind(), DirectGetErrorKind::NotFound) => { + return Ok(Read::Current(None)); + } + Err(e) + if matches!( + e.kind(), + DirectGetErrorKind::ErrorResponse(status, _) + if status == async_nats::StatusCode::NO_RESPONDERS + ) => + { + return Ok(Read::NoBucket); + } + Err(e) => return Err(e.into()), + }; + Ok(Read::Current(decode( + message + .headers + .get("KV-Operation") + .map(async_nats::HeaderValue::as_str), + &message.payload, + )?)) +} + +/// One stored message as a value: a KV delete or purge is stored as an empty +/// marker carrying the `KV-Operation` header, and reads as no archetype. +#[cfg(feature = "agent-archetype")] +fn decode(operation: Option<&str>, payload: &[u8]) -> serde_json::Result> { + if matches!(operation, Some("DEL" | "PURGE")) { + return Ok(None); + } + serde_json::from_slice(payload).map(Some) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The names the grant in `swarm-nats-auth` is built from. + #[test] + fn names_match_the_queue_grant() { + assert_eq!(STREAM, format!("KV_{BUCKET}")); + assert_eq!(key_subject("iris"), "$KV.agent-archetype.iris"); + assert_eq!( + direct_get_subject("iris"), + "$JS.API.DIRECT.GET.KV_agent-archetype.$KV.agent-archetype.iris" + ); + } + + /// A stored choice reads back, a field a newer controller adds is + /// ignored, and a deleted key is no archetype. + #[cfg(feature = "agent-archetype")] + #[test] + fn decoding_reads_the_choice_and_a_delete_as_none() { + let android = ArchetypeRef { + source: "hyperhive".to_owned(), + name: "android-dev".to_owned(), + }; + assert_eq!( + decode( + None, + br#"{"source":"hyperhive","name":"android-dev","later":1}"# + ) + .unwrap(), + Some(android) + ); + assert_eq!(decode(Some("DEL"), b"").unwrap(), None); + assert_eq!(decode(Some("PURGE"), b"").unwrap(), None); + } +} diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 6970c024..1fc3929e 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -232,6 +232,12 @@ pub mod agent_permissions; /// unconditional; writing needs `kv` and reading needs `operator-profiles`. pub mod operator_profiles; +/// The bucket swarm-controller records each archetype agent's archetype in +/// — one key per agent, read by the hive that runs it. The names and the +/// stored type are unconditional; writing needs `kv` and reading needs +/// `agent-archetype`. +pub mod agent_archetype; + /// The login flag in an agent's turn-state header. No bucket: it travels /// on [`agent_state_subject`] only. pub mod agent_login; From 553ed8376c8115feb83dbbec427cb35459ad4fd2 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 17 Oct 2026 08:49:03 +0200 Subject: [PATCH 2/3] 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- input and mkAgent imports hyperhive.hyperhiveArchetypes..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 --- docs/agent-lifecycle/agent-roster.md | 7 +- docs/agent-lifecycle/config-changes.md | 2 + docs/agent-lifecycle/persistence.md | 6 + docs/swarm/archetypes.md | 66 +++++-- docs/turn-loop/claude-invocation.md | 20 ++- .../src/pages/CreateAgentArchetype.ts | 10 ++ .../swarm-ui/src/pages/CreateAgentForm.tsx | 10 +- .../test/create-agent-archetype.test.mjs | 20 ++- hive-agent/prompts/system.md | 9 + hive-agent/src/prompt.rs | 161 +++++++++++++----- hive-c0re/Cargo.toml | 1 + hive-c0re/src/agent_config/archetypes.rs | 143 ++++++++++++++++ hive-c0re/src/agent_config/mod.rs | 9 +- hive-c0re/src/job_queue/exec.rs | 18 +- hive-c0re/src/lifecycle/mod.rs | 31 ++-- hive-c0re/src/main.rs | 2 +- hive-c0re/src/meta.rs | 85 ++++++++- hive-c0re/src/migrate.rs | 9 + hive-c0re/src/swarm_status.rs | 9 + hive-sh4re/src/assets.rs | 4 +- nix/archetypes/default.nix | 14 ++ nix/module-eval/builder.nix | 7 +- swarm-controller/src/agent_archetype.rs | 3 +- swarm-controller/src/main.rs | 9 +- 24 files changed, 560 insertions(+), 95 deletions(-) create mode 100644 hive-c0re/src/agent_config/archetypes.rs diff --git a/docs/agent-lifecycle/agent-roster.md b/docs/agent-lifecycle/agent-roster.md index bb0b9683..51541476 100644 --- a/docs/agent-lifecycle/agent-roster.md +++ b/docs/agent-lifecycle/agent-roster.md @@ -106,13 +106,16 @@ state dirs work as for every other agent. deploy log). Any agent holding it gets the same mounts. The operator dashboard's `POST /api/meta-update` is the only path that writes `flake.lock`. -- **Prompt** — identical for every container: `prompts/system.md` +- **Prompt** — identical for every container but one block (below): + `prompts/system.md` carries `` / `` marker blocks, and `prompt::render` filters for `"agent"` unconditionally, manager included ("always `agent` role — there is only one role", `hive-agent/src/prompt.rs`'s own module doc). The `role:manager` blocks are dead in production, exercised only by a unit test - (`filter_role_blocks(SAMPLE, "manager")`). + (`filter_blocks(SAMPLE, "role", "manager")`). The one block that differs + is `` vs ``: an agent built + from an [archetype](../swarm/archetypes.md) is told it has no config repo. - **Tool allow-list** — the MCP tools claude sees come from the agent's permissions alone, the same mechanism for every agent ([`docs/turn-loop/mcp.md`](../turn-loop/mcp.md)). Ruth's permissions diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index e08cac73..85c971b7 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -65,6 +65,8 @@ Creating a brand-new agent is swarm-level (`swarmctl agent create`, an agent. The swarm controller's `InitAgentConfigRepo` job seeds the agent's config repo with a default `agent.nix` template, then asks the target hive to deploy it. +An agent created from an [archetype](../swarm/archetypes.md) gets no +config repo: its hive builds it from the archetype's module. Changing what the template seeded isn't a special case: like every later change, it's a PR on that config repo, made from a clone and diff --git a/docs/agent-lifecycle/persistence.md b/docs/agent-lifecycle/persistence.md index 26929d39..ee46d818 100644 --- a/docs/agent-lifecycle/persistence.md +++ b/docs/agent-lifecycle/persistence.md @@ -420,6 +420,12 @@ reads it at every container start for `manage_root_agent`. Each agent gets its own entry from it as `swarm-permissions.json` in its runtime dir, which it holds until its own first read of its key succeeds. +`/var/lib/hyperhive/swarm-agent-archetypes.json` holds the archetype each of +this hive's archetype agents runs, as last read from the swarm's +`agent-archetype` bucket at the agent's provision and each rebuild. An agent +with an entry has no `applied/` repo and no `agent-` meta input; see +[archetypes](../swarm/archetypes.md). + hive-c0re renders containers onto `meta#` at creation. ## Destroy vs purge diff --git a/docs/swarm/archetypes.md b/docs/swarm/archetypes.md index c9708a6c..7f16de49 100644 --- a/docs/swarm/archetypes.md +++ b/docs/swarm/archetypes.md @@ -1,21 +1,24 @@ # Agent archetypes -An archetype is a named set of settings to create an agent from. The swarm -UI's create form lists the archetypes the swarm knows. Picking one fills the -form's permissions, resource caps and inference fields from it, and you can -still change each field before you create the agent. +An archetype is a named agent setup: settings to create the agent with, and +a NixOS module its container runs. The swarm UI's create form lists the +archetypes the swarm knows. Picking one fills the form's permissions, +resource caps and inference fields from it, and you can still change each +field before you create the agent. -The agent you create this way is an ordinary agent: it gets its own -`agent-configs/` repo like any other, and the request carries only the -fields as the form shows them. Nothing records which archetype you picked. +An agent created from an archetype has no `agent-configs/` repo of its +own. Its hive builds it from the archetype's module, so every agent of that +archetype runs the same configuration. To change what it runs, change the +archetype. Picking "none" creates a custom agent, with its own config repo. ## The built-in archetypes The hyperhive flake ships these: -| archetype | what it sets | -| ---------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | -| `reviewer` | permissions `messaging`, `inbox`, `execution`: the default set without `meta`. With `execution` it can run `hive-forge`, `git` and other commands. | +| archetype | what it sets | +| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------- | +| `reviewer` | permissions `messaging`, `inbox`, `execution`: the default set without `meta`. With `execution` it can run `hive-forge`, `git` and other commands. | +| `android-dev` | installs `android-tools` (`adb`, `fastboot`), Gradle and a JDK; default permissions. | ## Where an archetype comes from @@ -38,8 +41,8 @@ hyperhiveArchetypes.reviewer = { - Every archetype needs a `description`. `permissions`, `limits` and `inference` are optional and take the shapes `POST /api/agents` takes. The swarm ignores other keys in `meta`. -- `module` is a NixOS module for the agent's container. The swarm doesn't - apply it yet. +- `module` is a NixOS module for the agent's container. A hive imports it + into each agent created from the archetype. Today the swarm reads one flake: the hyperhive source the [builder](builder.md)'s host runs, so only the built-ins appear. @@ -85,3 +88,42 @@ The stored value: "refused": { "broken": "limits: …" } } ``` + +## Creating an agent from an archetype + +The create form sends the picked archetype as `archetype` on +`POST /api/agents`, `{ "source": "hyperhive", "name": "reviewer" }`. The +controller refuses with 400 an archetype the stored list doesn't hold, then: + +1. stores the choice in the `agent-archetype` KV bucket, keyed by agent name, + next to the agent's permissions, caps and inference; +2. queues the creation graph without `CreateRepo`, `AddRepoMember` and + `InitAgentConfigRepo`. Identity, forge user, docs read, the mints and the + wanted state stay, and the deploy waits on those alone. + +Destroying the agent deletes its key. +Every hive may read any key of the bucket +(`$JS.API.DIRECT.GET.KV_agent-archetype.>`) and write none; only the +controller writes it. + +The hive reads the agent's key when it provisions the agent and on every +rebuild, and keeps the last value read in +`/var/lib/hyperhive/swarm-agent-archetypes.json`, so the meta flake renders +without the queue. When the read fails, a rebuild keeps the value +stored there. For an archetype agent the hive: + +- seeds no `proposed` or `applied` repo, and gives the meta flake no + `agent-` input. `mkAgent` imports + `hyperhive.hyperhiveArchetypes..module` in place of the agent's own + flake, and sets `HIVE_ARCHETYPE` in the harness environment; +- relocks no agent input on a rebuild or a meta-input bump, and ignores the + `rev` of a swarm deploy request; +- mounts an empty `/agents//config/`. + +The agent's system prompt says it has no config repo and names its +archetype. + +The hive takes the module from its own `hyperhive` input. When that input +predates the archetype, the agent's build fails with +`this hive's hyperhive input has no archetype `. Update the hive's +`hyperhive` input to fix it. diff --git a/docs/turn-loop/claude-invocation.md b/docs/turn-loop/claude-invocation.md index c1441c99..22b1ba15 100644 --- a/docs/turn-loop/claude-invocation.md +++ b/docs/turn-loop/claude-invocation.md @@ -255,7 +255,12 @@ needs to `chown` a bind mount), once at startup: `hive-agent/prompts/system.md` by `hive-agent`'s `prompt::render`: HTML-comment markers (`...`, same for `role:manager`) gate the role-specific blocks; both flavors - share everything else. Five placeholders are then + share everything else. `` and + `` blocks gate the config-repo paragraphs: an + agent with `HIVE_ARCHETYPE` set (the meta flake sets it on an agent built + from an [archetype](../swarm/archetypes.md)) gets the archetype block and + every other agent the repo block, with `{archetype}` filled in with the + archetype's name. Five placeholders are then substituted: `{label}` (short agent name), `{qualified_label}` (hive-qualified `name@domain` form), `{hive_identity}` (for example `` on hive `pr1ma` ``; empty when @@ -275,14 +280,17 @@ needs to `chown` a bind mount), once at startup: telling the agent where the docs live. Passed via `--system-prompt-file`. - **Marker grammar.** `` opens a block; any - `` closes the current block. The renderer always uses - role `agent`, so it elides blocks with other role tags. Nesting **isn't** + **Marker grammar.** `` opens a block; any + `` closes the current block of that kind. The renderer + filters the two kinds one after the other. The renderer always uses + role `agent`, so it elides blocks with other role tags, and config `repo` + or `archetype` as above. Nesting **isn't** supported — a stray opener with no closer runs until end of file. The renderer tolerates whitespace inside markers (`` parses the same as ``). Content outside any marker is always - included. Today's `system.md` carries no markers (single agent role) — - the grammar stays wired for a future manager / multi-role prompt. + included. Today's `system.md` carries no role markers (single agent + role), and the role grammar stays wired for a future manager / multi-role + prompt; it carries one pair of config blocks. **`hive_identity` / `swarm_identity` shape.** Each carries a leading space + backticked name (` on hive \`pr1ma\``, diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts b/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts index 6b1edc73..508fcf64 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts @@ -61,3 +61,13 @@ export function prefill( }, }; } + +/** + * The `archetype` field of `POST /api/agents` for `archetype`, or nothing for + * a custom agent. + */ +export function requestArchetype( + archetype: Archetype | undefined, +): { source: string; name: string } | undefined { + return archetype && { source: archetype.source, name: archetype.name }; +} diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index e9734be3..3074c765 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -57,6 +57,7 @@ import { import { archetypeKey, prefill, + requestArchetype, type ArchetypeList, } from "./CreateAgentArchetype.js"; import "./CreateAgentForm.css"; @@ -230,6 +231,7 @@ export function CreateAgentForm({ permissions: catalogue ? requestPermissions(selected, catalogue.default) : undefined, + archetype: requestArchetype(picked), }), }); if (!r.ok) { @@ -321,7 +323,9 @@ export function CreateAgentForm({ /> {picked && (

- {picked.description} + {picked.description} An agent created from an archetype has + no config repo: its hive builds it from the archetype's + module.

)} @@ -396,7 +400,9 @@ export function CreateAgentForm({ Submitting this queues everything a new agent needs: a swarm-level identity, a config repo on the forge with the operator added as a collaborator, and a container running the agent on the hive you pick - above. Watch it all settle on jobs. + above. An agent created from an archetype gets no config repo: its + container runs the archetype's module. Watch it all settle on{" "} + jobs.

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) }