diff --git a/docs/2-handbook/swarm/archetypes.md b/docs/2-handbook/swarm/archetypes.md index fe4c325d5..1df0c479b 100644 --- a/docs/2-handbook/swarm/archetypes.md +++ b/docs/2-handbook/swarm/archetypes.md @@ -3,8 +3,8 @@ 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. +resource caps, inference and AGENTS.md fields from it, and you can still +change each field before you create the agent. An agent created from an archetype has no `agent-configs/` repo of its own. The swarm's `internal/meta` flake builds it from the archetype's module, @@ -35,14 +35,23 @@ hyperhiveArchetypes.reviewer = { permissions = [ "messaging" "inbox" "execution" ]; limits = { memory_max = "4G"; }; inference = { effort = "high"; }; + prompt = "You review pull requests and never push."; }; }; ``` +A long prompt can live in a file next to the flake. `builtins.readFile` gives a +plain string at evaluation, so `meta` stays plain data: + +```nix +hyperhiveArchetypes.reviewer.meta.prompt = builtins.readFile ./prompts/reviewer.md; +``` + - `meta` is plain data: no derivation, no path, no function. The swarm evaluates `meta` alone. - Every archetype needs a `description`. `permissions`, `limits` and - `inference` are optional and take the shapes `POST /api/agents` takes. The swarm ignores + `inference` are optional and take the shapes `POST /api/agents` takes. + `prompt` is optional text that prefills `AGENTS.md`, [below](#the-prompt). The swarm ignores other keys in `meta`. - `module` is a NixOS module for the agent's container. The meta flake imports it into each agent created from the archetype. @@ -52,6 +61,24 @@ Today the swarm reads one flake: the hyperhive source the flake has one archetype source too, its own `hyperhive` input, and refuses an archetype from any other source name. +## The prompt + +An archetype's `meta.prompt` is starting text for the new agent's `AGENTS.md`: +a reviewer that never pushes, a triage agent that keeps its notes in one +place. Picking the archetype in the create form fills the form's AGENTS.md box +with it, next to the permissions, caps and inference it fills. Edit the text +freely; the agent starts with what the box holds when you create it. An +archetype without `prompt` leaves the box alone. + +- The pick replaces the box only while the box is empty or still holds the + previous pick's prompt. Text you typed or edited stays. +- The limit is 64 KiB (65536 bytes), the limit of an initial `AGENTS.md`. The + swarm refuses a longer `prompt` the way it refuses any bad `meta`: the + archetype leaves the list, and its name and the reason go into the record's + `refused` map. A blank `prompt` counts as none. +- The text is a copy made at create. Editing the archetype's `prompt` later + changes what new agents start with, not the `AGENTS.md` of agents that exist. + ## How the list stays current The swarm controller asks the builder for the list when the queue connects and diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentAgentsMd.ts b/frontend/packages/swarm-ui/src/pages/CreateAgentAgentsMd.ts index 94c4c04e6..fa6429db5 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentAgentsMd.ts +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentAgentsMd.ts @@ -21,3 +21,17 @@ export function agentsMdRefusal(problem: ProblemDetails): string | null { ? problem.detail : null; } + +/** + * The AGENTS.md box after the archetype pick changes from one whose prompt was + * `previous` to one whose prompt is `next` (both `""` for none). The pick + * replaces what is in the box only when the box is empty or still holds the + * previous pick's prompt; text the operator typed or edited stays. + */ +export function agentsMdAfterPick( + current: string, + previous: string, + next: string, +): string { + return current.trim() === "" || current === previous ? next : current; +} diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts b/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts index 508fcf640..b5dcf90b1 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts @@ -15,6 +15,7 @@ export interface Archetype { subagent_models?: string[]; max_subagents?: number; }; + prompt?: string; } // Mirrors swarm-controller's `Archetypes`. diff --git a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx index 7ff3f85f4..9ea8dd550 100644 --- a/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx +++ b/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx @@ -63,7 +63,11 @@ import { requestArchetype, type ArchetypeList, } from "./CreateAgentArchetype.js"; -import { agentsMdBody, agentsMdRefusal } from "./CreateAgentAgentsMd.js"; +import { + agentsMdAfterPick, + agentsMdBody, + agentsMdRefusal, +} from "./CreateAgentAgentsMd.js"; import { NAME_PATTERN, nextStep, @@ -191,13 +195,14 @@ export function CreateAgentForm({ })().catch((e: unknown) => setArchetypesError({ detail: String(e) })); }, []); - // Fills permissions, caps and inference from the picked archetype; each - // stays editable, and the request carries only those fields. + // Fills permissions, caps, inference and the AGENTS.md text from the picked + // archetype; each stays editable, and the request carries only those fields. function pickArchetype(key: string) { setArchetype(key); - const p = prefill( - archetypes?.archetypes.find((a) => archetypeKey(a) === key), - catalogue?.default ?? [], + const next = archetypes?.archetypes.find((a) => archetypeKey(a) === key); + const p = prefill(next, catalogue?.default ?? []); + setAgentsMd((current) => + agentsMdAfterPick(current, picked?.prompt ?? "", next?.prompt ?? ""), ); setSelected(p.permissions); setCpu(p.cpu); @@ -401,7 +406,8 @@ export function CreateAgentForm({

The text the agent's AGENTS.md starts with. Optional: leave it - empty and the agent starts without the file. + empty and the agent starts without the file. An archetype with a + prompt fills the box in; edit it freely.

{ assert.equal(agentsMdRefusal({ status: 400 }), null); assert.equal(agentsMdRefusal({ detail: "agents_md is too long" }), null); }); + +test("a pick fills an empty box with the archetype's prompt", () => { + assert.equal(agentsMdAfterPick("", "", "Review only."), "Review only."); + assert.equal(agentsMdAfterPick(" \n", "", "Review only."), "Review only."); +}); + +test("a pick keeps text the operator typed or edited", () => { + assert.equal(agentsMdAfterPick("# mine", "", "Review only."), "# mine"); + assert.equal( + agentsMdAfterPick("Review only. Also x.", "Review only.", "Triage."), + "Review only. Also x.", + ); +}); + +test("a pick over an unedited prefill swaps it, and none clears it", () => { + assert.equal( + agentsMdAfterPick("Review only.", "Review only.", "Triage."), + "Triage.", + ); + assert.equal(agentsMdAfterPick("Review only.", "Review only.", ""), ""); +}); + +test("an archetype without a prompt leaves the box as it is", () => { + assert.equal(agentsMdAfterPick("# mine", "", ""), "# mine"); + assert.equal(agentsMdAfterPick("", "", ""), ""); +}); diff --git a/nix/archetypes/default.nix b/nix/archetypes/default.nix index 1c90d90a4..28ed513cc 100644 --- a/nix/archetypes/default.nix +++ b/nix/archetypes/default.nix @@ -3,7 +3,8 @@ # `meta` is plain data (no derivations, no `pkgs`), because the swarm # builder evaluates `meta` alone and swarm-controller stores it as JSON. # `meta` fields: `description` (required), `permissions`, `limits`, -# `inference`, in the shapes `POST /api/agents` takes them; see +# `inference`, in the shapes `POST /api/agents` takes them, and `prompt`, the +# text the create form prefills the new agent's AGENTS.md with; see # docs/2-handbook/swarm/archetypes.md. { reviewer = { diff --git a/swarm-controller/src/archetypes.rs b/swarm-controller/src/archetypes.rs index 9b390dccd..fa253d7ec 100644 --- a/swarm-controller/src/archetypes.rs +++ b/swarm-controller/src/archetypes.rs @@ -18,7 +18,9 @@ use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; use swarm_controller_api::inference::InferenceSettings; use swarm_controller_api::limits::Caps; -use swarm_queue_client::archetypes::{ArchetypeMeta, BUILTIN_SOURCE, SourceRecord}; +use swarm_queue_client::archetypes::{ + ArchetypeMeta, BUILTIN_SOURCE, MAX_PROMPT_BYTES, SourceRecord, +}; use swarm_wire::builder::{ ARCHETYPES_REPLY_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes, }; @@ -55,6 +57,8 @@ struct DeclaredMeta { limits: Option, #[serde(default)] inference: Option, + #[serde(default)] + prompt: Option, } /// `meta` as stored, or why a create would refuse it. @@ -64,6 +68,14 @@ fn check(name: &str, meta: serde_json::Value) -> Result { if declared.description.trim().is_empty() { return Err("description is empty".to_owned()); } + if let Some(prompt) = &declared.prompt + && prompt.len() > MAX_PROMPT_BYTES + { + return Err(format!( + "prompt is {} bytes; the limit is {MAX_PROMPT_BYTES}", + prompt.len() + )); + } let permissions = match declared.permissions { Some(perms) => { agent_permissions::unique(&perms)?; @@ -90,6 +102,7 @@ fn check(name: &str, meta: serde_json::Value) -> Result { .map(inference::into_stored) .transpose() .map_err(|e| format!("inference: {e}"))?, + prompt: declared.prompt.filter(|p| !p.trim().is_empty()), }) } @@ -227,6 +240,9 @@ pub(crate) struct ArchetypeView { #[serde(skip_serializing_if = "Option::is_none")] #[schema(value_type = Option)] inference: Option, + /// The text the create form prefills the agent's `AGENTS.md` with. + #[serde(skip_serializing_if = "Option::is_none")] + prompt: Option, } /// One archetype a source declared and this daemon refused. @@ -253,6 +269,7 @@ fn view(key: &str, record: SourceRecord) -> Archetypes { permissions: meta.permissions, limits: meta.limits, inference: meta.inference, + prompt: meta.prompt, }); } for (name, reason) in record.refused { @@ -344,6 +361,58 @@ mod tests { assert!(meta.inference.as_ref().is_some_and(|i| i.effort.is_some())); } + /// The prompt is stored as declared; one past the cap refuses the + /// archetype by name and the cap itself passes; a blank one is no prompt. + #[test] + fn the_prompt_is_stored_and_capped() { + let r = evaluated(json!({ + "plain": { "description": "x" }, + "with": { "description": "x", "prompt": "Review only." }, + "blank": { "description": "x", "prompt": " \n" }, + "at-cap": { "description": "x", "prompt": "a".repeat(MAX_PROMPT_BYTES) }, + "over-cap": { "description": "x", "prompt": "a".repeat(MAX_PROMPT_BYTES + 1) }, + "not-text": { "description": "x", "prompt": 3 } + })); + assert_eq!(r.archetypes["plain"].prompt, None); + assert_eq!(r.archetypes["with"].prompt.as_deref(), Some("Review only.")); + assert_eq!(r.archetypes["blank"].prompt, None); + assert_eq!( + r.archetypes["at-cap"].prompt.as_deref().map(str::len), + Some(MAX_PROMPT_BYTES) + ); + assert_eq!( + r.refused.keys().map(String::as_str).collect::>(), + ["not-text", "over-cap"] + ); + assert!( + r.refused["over-cap"].contains(&MAX_PROMPT_BYTES.to_string()), + "{}", + r.refused["over-cap"] + ); + } + + /// `GET /api/archetypes` carries the prompt, and leaves it out when + /// there is none. + #[test] + fn the_view_carries_the_prompt() { + let r = evaluated(json!({ + "with": { "description": "x", "prompt": "Review only." }, + "plain": { "description": "x" } + })); + let wire = serde_json::to_value(view(BUILTIN_SOURCE, r)).unwrap(); + let by_name = |name: &str| { + wire["archetypes"] + .as_array() + .unwrap() + .iter() + .find(|a| a["name"] == name) + .unwrap() + .clone() + }; + assert_eq!(by_name("with")["prompt"], "Review only."); + assert!(by_name("plain").get("prompt").is_none()); + } + #[test] fn a_bad_archetype_is_refused_by_name_and_the_rest_stay() { let r = evaluated(json!({ diff --git a/swarm-queue-client/src/archetypes.rs b/swarm-queue-client/src/archetypes.rs index 8df55357c..4082644fc 100644 --- a/swarm-queue-client/src/archetypes.rs +++ b/swarm-queue-client/src/archetypes.rs @@ -21,6 +21,10 @@ pub const BUCKET: &str = "agent-archetypes"; /// there is so far. pub const BUILTIN_SOURCE: &str = "hyperhive"; +/// The most bytes an archetype's `meta.prompt` may hold: the limit of an +/// agent's initial `AGENTS.md`, which the prompt prefills. +pub const MAX_PROMPT_BYTES: usize = crate::agents_md_initial::MAX_BYTES; + /// One source's archetypes: the value under `BUCKET`/``. #[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct SourceRecord { @@ -46,6 +50,10 @@ pub struct ArchetypeMeta { pub limits: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub inference: Option, + /// The text the create form prefills an agent's initial `AGENTS.md` with, + /// at most [`MAX_PROMPT_BYTES`]. Absent prefills nothing. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub prompt: Option, } /// Open the archetype bucket for writing, creating it if nothing has yet. @@ -114,4 +122,18 @@ mod tests { r#"{"source":"/nix/store/h","archetypes":{"reviewer":{"description":"reads","permissions":["messaging"]}}}"# ); } + + /// A record stored before `prompt` existed still parses, with no prompt; + /// one with a prompt round-trips it. + #[test] + fn prompt_is_optional_on_the_wire() { + let old = r#"{"source":"s","archetypes":{"reviewer":{"description":"reads"}}}"#; + let mut record: SourceRecord = serde_json::from_str(old).unwrap(); + assert_eq!(record.archetypes["reviewer"].prompt, None); + + record.archetypes.get_mut("reviewer").unwrap().prompt = Some("be brief".to_owned()); + let wire = serde_json::to_string(&record).unwrap(); + assert!(wire.contains(r#""prompt":"be brief""#), "{wire}"); + assert_eq!(serde_json::from_str::(&wire).unwrap(), record); + } }