an archetype's prompt prefills the new agent's agents.md in the create form
This commit is contained in:
parent
2acde41e54
commit
a5470d82ae
8 changed files with 179 additions and 12 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ export interface Archetype {
|
|||
subagent_models?: string[];
|
||||
max_subagents?: number;
|
||||
};
|
||||
prompt?: string;
|
||||
}
|
||||
|
||||
// Mirrors swarm-controller's `Archetypes`.
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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("", "", ""), "");
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = {
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue