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
|
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
|
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,
|
archetypes the swarm knows. Picking one fills the form's permissions,
|
||||||
resource caps and inference fields from it, and you can still change each
|
resource caps, inference and AGENTS.md fields from it, and you can still
|
||||||
field before you create the agent.
|
change each field before you create the agent.
|
||||||
|
|
||||||
An agent created from an archetype has no `agent-configs/<name>` repo of its
|
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,
|
own. The swarm's `internal/meta` flake builds it from the archetype's module,
|
||||||
|
|
@ -35,14 +35,23 @@ hyperhiveArchetypes.reviewer = {
|
||||||
permissions = [ "messaging" "inbox" "execution" ];
|
permissions = [ "messaging" "inbox" "execution" ];
|
||||||
limits = { memory_max = "4G"; };
|
limits = { memory_max = "4G"; };
|
||||||
inference = { effort = "high"; };
|
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
|
- `meta` is plain data: no derivation, no path, no function. The swarm
|
||||||
evaluates `meta` alone.
|
evaluates `meta` alone.
|
||||||
- Every archetype needs a `description`. `permissions`, `limits` and
|
- 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`.
|
other keys in `meta`.
|
||||||
- `module` is a NixOS module for the agent's container. The meta flake
|
- `module` is a NixOS module for the agent's container. The meta flake
|
||||||
imports it into each agent created from the archetype.
|
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
|
flake has one archetype source too, its own `hyperhive` input, and refuses
|
||||||
an archetype from any other source name.
|
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
|
## How the list stays current
|
||||||
|
|
||||||
The swarm controller asks the builder for the list when the queue connects and
|
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
|
? problem.detail
|
||||||
: null;
|
: 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[];
|
subagent_models?: string[];
|
||||||
max_subagents?: number;
|
max_subagents?: number;
|
||||||
};
|
};
|
||||||
|
prompt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors swarm-controller's `Archetypes`.
|
// Mirrors swarm-controller's `Archetypes`.
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,11 @@ import {
|
||||||
requestArchetype,
|
requestArchetype,
|
||||||
type ArchetypeList,
|
type ArchetypeList,
|
||||||
} from "./CreateAgentArchetype.js";
|
} from "./CreateAgentArchetype.js";
|
||||||
import { agentsMdBody, agentsMdRefusal } from "./CreateAgentAgentsMd.js";
|
import {
|
||||||
|
agentsMdAfterPick,
|
||||||
|
agentsMdBody,
|
||||||
|
agentsMdRefusal,
|
||||||
|
} from "./CreateAgentAgentsMd.js";
|
||||||
import {
|
import {
|
||||||
NAME_PATTERN,
|
NAME_PATTERN,
|
||||||
nextStep,
|
nextStep,
|
||||||
|
|
@ -191,13 +195,14 @@ export function CreateAgentForm({
|
||||||
})().catch((e: unknown) => setArchetypesError({ detail: String(e) }));
|
})().catch((e: unknown) => setArchetypesError({ detail: String(e) }));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// Fills permissions, caps and inference from the picked archetype; each
|
// Fills permissions, caps, inference and the AGENTS.md text from the picked
|
||||||
// stays editable, and the request carries only those fields.
|
// archetype; each stays editable, and the request carries only those fields.
|
||||||
function pickArchetype(key: string) {
|
function pickArchetype(key: string) {
|
||||||
setArchetype(key);
|
setArchetype(key);
|
||||||
const p = prefill(
|
const next = archetypes?.archetypes.find((a) => archetypeKey(a) === key);
|
||||||
archetypes?.archetypes.find((a) => archetypeKey(a) === key),
|
const p = prefill(next, catalogue?.default ?? []);
|
||||||
catalogue?.default ?? [],
|
setAgentsMd((current) =>
|
||||||
|
agentsMdAfterPick(current, picked?.prompt ?? "", next?.prompt ?? ""),
|
||||||
);
|
);
|
||||||
setSelected(p.permissions);
|
setSelected(p.permissions);
|
||||||
setCpu(p.cpu);
|
setCpu(p.cpu);
|
||||||
|
|
@ -401,7 +406,8 @@ export function CreateAgentForm({
|
||||||
<div class="create-agent-fields">
|
<div class="create-agent-fields">
|
||||||
<p class="create-agent-intro">
|
<p class="create-agent-intro">
|
||||||
The text the agent's AGENTS.md starts with. Optional: leave it
|
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>
|
</p>
|
||||||
<TextAreaField
|
<TextAreaField
|
||||||
id="agent-agents-md"
|
id="agent-agents-md"
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { test } from "node:test";
|
import { test } from "node:test";
|
||||||
import {
|
import {
|
||||||
|
agentsMdAfterPick,
|
||||||
agentsMdBody,
|
agentsMdBody,
|
||||||
agentsMdRefusal,
|
agentsMdRefusal,
|
||||||
} from "../src/pages/CreateAgentAgentsMd.ts";
|
} 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({ status: 400 }), null);
|
||||||
assert.equal(agentsMdRefusal({ detail: "agents_md is too long" }), 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
|
# `meta` is plain data (no derivations, no `pkgs`), because the swarm
|
||||||
# builder evaluates `meta` alone and swarm-controller stores it as JSON.
|
# builder evaluates `meta` alone and swarm-controller stores it as JSON.
|
||||||
# `meta` fields: `description` (required), `permissions`, `limits`,
|
# `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.
|
# docs/2-handbook/swarm/archetypes.md.
|
||||||
{
|
{
|
||||||
reviewer = {
|
reviewer = {
|
||||||
|
|
|
||||||
|
|
@ -18,7 +18,9 @@ use problem_details::ProblemDetails;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use swarm_controller_api::inference::InferenceSettings;
|
use swarm_controller_api::inference::InferenceSettings;
|
||||||
use swarm_controller_api::limits::Caps;
|
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::{
|
use swarm_wire::builder::{
|
||||||
ARCHETYPES_REPLY_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes,
|
ARCHETYPES_REPLY_DEADLINE, ARCHETYPES_SUBJECT, ArchetypesReply, EvalArchetypes,
|
||||||
};
|
};
|
||||||
|
|
@ -55,6 +57,8 @@ struct DeclaredMeta {
|
||||||
limits: Option<Caps>,
|
limits: Option<Caps>,
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
inference: Option<InferenceSettings>,
|
inference: Option<InferenceSettings>,
|
||||||
|
#[serde(default)]
|
||||||
|
prompt: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `meta` as stored, or why a create would refuse it.
|
/// `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() {
|
if declared.description.trim().is_empty() {
|
||||||
return Err("description is empty".to_owned());
|
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 {
|
let permissions = match declared.permissions {
|
||||||
Some(perms) => {
|
Some(perms) => {
|
||||||
agent_permissions::unique(&perms)?;
|
agent_permissions::unique(&perms)?;
|
||||||
|
|
@ -90,6 +102,7 @@ fn check(name: &str, meta: serde_json::Value) -> Result<ArchetypeMeta, String> {
|
||||||
.map(inference::into_stored)
|
.map(inference::into_stored)
|
||||||
.transpose()
|
.transpose()
|
||||||
.map_err(|e| format!("inference: {e}"))?,
|
.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")]
|
#[serde(skip_serializing_if = "Option::is_none")]
|
||||||
#[schema(value_type = Option<Object>)]
|
#[schema(value_type = Option<Object>)]
|
||||||
inference: Option<swarm_wire::agent_inference::AgentInference>,
|
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.
|
/// One archetype a source declared and this daemon refused.
|
||||||
|
|
@ -253,6 +269,7 @@ fn view(key: &str, record: SourceRecord) -> Archetypes {
|
||||||
permissions: meta.permissions,
|
permissions: meta.permissions,
|
||||||
limits: meta.limits,
|
limits: meta.limits,
|
||||||
inference: meta.inference,
|
inference: meta.inference,
|
||||||
|
prompt: meta.prompt,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
for (name, reason) in record.refused {
|
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()));
|
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]
|
#[test]
|
||||||
fn a_bad_archetype_is_refused_by_name_and_the_rest_stay() {
|
fn a_bad_archetype_is_refused_by_name_and_the_rest_stay() {
|
||||||
let r = evaluated(json!({
|
let r = evaluated(json!({
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,10 @@ pub const BUCKET: &str = "agent-archetypes";
|
||||||
/// there is so far.
|
/// there is so far.
|
||||||
pub const BUILTIN_SOURCE: &str = "hyperhive";
|
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>`.
|
/// One source's archetypes: the value under `BUCKET`/`<source>`.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)]
|
||||||
pub struct SourceRecord {
|
pub struct SourceRecord {
|
||||||
|
|
@ -46,6 +50,10 @@ pub struct ArchetypeMeta {
|
||||||
pub limits: Option<AgentCaps>,
|
pub limits: Option<AgentCaps>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub inference: Option<AgentInference>,
|
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.
|
/// 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"]}}}"#
|
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