Watch
0
0
Fork
You've already forked hyperhive
0

an archetype's prompt prefills the new agent's agents.md in the create form

This commit is contained in:
atlas 2026-11-11 00:40:40 +01:00 • committed by mara
commit a5470d82ae
8 changed files with 179 additions and 12 deletions

View file

@ -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/<name>` 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

View file

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

View file

@ -15,6 +15,7 @@ export interface Archetype {
subagent_models?: string[];
max_subagents?: number;
};
prompt?: string;
}
// Mirrors swarm-controller's `Archetypes`.

View file

@ -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({
<div class="create-agent-fields">
<p class="create-agent-intro">
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.
</p>
<TextAreaField
id="agent-agents-md"

View file

@ -5,6 +5,7 @@
import assert from "node:assert/strict";
import { test } from "node:test";
import {
agentsMdAfterPick,
agentsMdBody,
agentsMdRefusal,
} from "../src/pages/CreateAgentAgentsMd.ts";
@ -41,3 +42,29 @@ test("other failures stay with the general error", () => {
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("", "", ""), "");
});

View file

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

View file

@ -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<Caps>,
#[serde(default)]
inference: Option<InferenceSettings>,
#[serde(default)]
prompt: Option<String>,
}
/// `meta` as stored, or why a create would refuse it.
@ -64,6 +68,14 @@ fn check(name: &str, meta: serde_json::Value) -> Result<ArchetypeMeta, String> {
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<ArchetypeMeta, String> {
.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<Object>)]
inference: Option<swarm_wire::agent_inference::AgentInference>,
/// The text the create form prefills the agent's `AGENTS.md` with.
#[serde(skip_serializing_if = "Option::is_none")]
prompt: Option<String>,
}
/// 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::<Vec<_>>(),
["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!({

View file

@ -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`/`<source>`.
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
pub struct SourceRecord {
@ -46,6 +50,10 @@ pub struct ArchetypeMeta {
pub limits: Option<AgentCaps>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub inference: Option<AgentInference>,
/// 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<String>,
}
/// 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::<SourceRecord>(&wire).unwrap(), record);
}
}