archetypes: prefill the create form from flake-declared agent archetypes
A flake declares agent archetypes in its `hyperhiveArchetypes` output,
`<name> = { module; meta; }`. The hyperhive flake ships `reviewer`
(default permissions without `execution`).
swarm-builder answers `$SWARM.builder.archetypes` with the `meta` of
every archetype its own hyperhive source declares. swarm-controller asks
once the queue connects and every 10 minutes, checks each archetype as
`POST /api/agents` checks its settings, and stores the result in the
`agent-archetypes` KV bucket when it changed. `GET /api/archetypes`
serves it, and the create form's archetype select prefills permissions,
resource caps and inference from it.
The agent created is an ordinary agent with its own config repo; the
archetype's module is not applied yet.
Refs #5386
This commit is contained in:
parent
eb92e83c95
commit
51035eac1d
22 changed files with 1244 additions and 30 deletions
63
frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts
Normal file
63
frontend/packages/swarm-ui/src/pages/CreateAgentArchetype.ts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
// The create-agent form's archetype picker: what picking one puts in the
|
||||
// form. Kept out of the component so `npm test` can run it.
|
||||
import type { InferenceDraft } from "../agent/detail/AgentInference.js";
|
||||
|
||||
// Mirrors swarm-controller's `ArchetypeView` (`GET /api/archetypes`).
|
||||
export interface Archetype {
|
||||
source: string;
|
||||
name: string;
|
||||
description: string;
|
||||
permissions?: string[];
|
||||
limits?: { cpu_quota?: string; memory_max?: string };
|
||||
inference?: {
|
||||
model?: string;
|
||||
effort?: string;
|
||||
subagent_models?: string[];
|
||||
max_subagents?: number;
|
||||
};
|
||||
}
|
||||
|
||||
// Mirrors swarm-controller's `Archetypes`.
|
||||
export interface ArchetypeList {
|
||||
archetypes: Archetype[];
|
||||
refused: { source: string; name: string; reason: string }[];
|
||||
}
|
||||
|
||||
/** The settings fields a pick fills in. */
|
||||
export interface Prefill {
|
||||
permissions: string[];
|
||||
cpu: string;
|
||||
memory: string;
|
||||
inference: InferenceDraft;
|
||||
}
|
||||
|
||||
/** The select's value for `a`; unique across sources. */
|
||||
export function archetypeKey(a: Archetype): string {
|
||||
return `${a.source}/${a.name}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* The form's settings for `archetype`, or for no archetype when it is
|
||||
* `undefined`. A setting the archetype leaves out is the form's empty value,
|
||||
* and permissions it leaves out are `defaults`.
|
||||
*/
|
||||
export function prefill(
|
||||
archetype: Archetype | undefined,
|
||||
defaults: string[],
|
||||
): Prefill {
|
||||
const inference = archetype?.inference ?? {};
|
||||
return {
|
||||
permissions: archetype?.permissions ?? defaults,
|
||||
cpu: archetype?.limits?.cpu_quota ?? "",
|
||||
memory: archetype?.limits?.memory_max ?? "",
|
||||
inference: {
|
||||
model: inference.model ?? "",
|
||||
effort: inference.effort ?? "",
|
||||
subagents: (inference.subagent_models ?? []).join(", "),
|
||||
maxSubagents:
|
||||
inference.max_subagents === undefined
|
||||
? ""
|
||||
: String(inference.max_subagents),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -69,3 +69,8 @@
|
|||
.create-agent-result-ok {
|
||||
color: var(--green);
|
||||
}
|
||||
/* The picked archetype's description, under its select. */
|
||||
.create-agent-archetype-description {
|
||||
margin: 0;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -54,6 +54,11 @@ import {
|
|||
requestPermissions,
|
||||
togglePermission,
|
||||
} from "./CreateAgentPermissions.js";
|
||||
import {
|
||||
archetypeKey,
|
||||
prefill,
|
||||
type ArchetypeList,
|
||||
} from "./CreateAgentArchetype.js";
|
||||
import "./CreateAgentForm.css";
|
||||
|
||||
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
|
||||
|
|
@ -131,6 +136,16 @@ export function CreateAgentForm({
|
|||
null,
|
||||
);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
// `null` until `/api/archetypes` answers; `archetype` is the picked one's
|
||||
// `archetypeKey`, `""` for none.
|
||||
const [archetypes, setArchetypes] = useState<ArchetypeList | null>(null);
|
||||
const [archetypesError, setArchetypesError] = useState<ProblemDetails | null>(
|
||||
null,
|
||||
);
|
||||
const [archetype, setArchetype] = useState("");
|
||||
const picked = archetypes?.archetypes.find(
|
||||
(a) => archetypeKey(a) === archetype,
|
||||
);
|
||||
|
||||
// Loaded once, not re-fetched on submit: the roster changes rarely
|
||||
// enough (a nix-level swarm config change) that staleness within one
|
||||
|
|
@ -165,6 +180,31 @@ export function CreateAgentForm({
|
|||
})().catch((e: unknown) => setCatalogueError({ detail: String(e) }));
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const r = await fetch("/api/archetypes");
|
||||
if (!r.ok) {
|
||||
setArchetypesError(await readApiError(r));
|
||||
return;
|
||||
}
|
||||
setArchetypes((await r.json()) as ArchetypeList);
|
||||
})().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.
|
||||
function pickArchetype(key: string) {
|
||||
setArchetype(key);
|
||||
const p = prefill(
|
||||
archetypes?.archetypes.find((a) => archetypeKey(a) === key),
|
||||
catalogue?.default ?? [],
|
||||
);
|
||||
setSelected(p.permissions);
|
||||
setCpu(p.cpu);
|
||||
setMemory(p.memory);
|
||||
setInference(p.inference);
|
||||
}
|
||||
|
||||
const hiveOptions: SelectOption[] = (hives ?? []).map((h) => ({
|
||||
value: h.name,
|
||||
label: h.name,
|
||||
|
|
@ -199,6 +239,7 @@ export function CreateAgentForm({
|
|||
const data = (await r.json()) as CreateAgentResponse;
|
||||
setResult({ status: "done", nodeId: data.node_id });
|
||||
setName("");
|
||||
setArchetype("");
|
||||
if (catalogue)
|
||||
setSelected(
|
||||
initialSelection(catalogue.all, catalogue.default, extraPermissions),
|
||||
|
|
@ -230,6 +271,12 @@ export function CreateAgentForm({
|
|||
problem={catalogueError}
|
||||
/>
|
||||
)}
|
||||
{archetypesError && (
|
||||
<ApiErrorPanel
|
||||
context="failed to load the archetype list"
|
||||
problem={archetypesError}
|
||||
/>
|
||||
)}
|
||||
<form class="create-agent-form" onSubmit={submit}>
|
||||
<TextField
|
||||
id="agent-name"
|
||||
|
|
@ -256,6 +303,29 @@ export function CreateAgentForm({
|
|||
: "select a hive"
|
||||
}
|
||||
/>
|
||||
{archetypes && archetypes.archetypes.length > 0 && (
|
||||
<>
|
||||
<SelectField
|
||||
id="agent-archetype"
|
||||
label="archetype"
|
||||
value={archetype}
|
||||
onChange={pickArchetype}
|
||||
disabled={!catalogue}
|
||||
options={[
|
||||
{ value: "", label: "none" },
|
||||
...archetypes.archetypes.map((a) => ({
|
||||
value: archetypeKey(a),
|
||||
label: a.name,
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
{picked && (
|
||||
<p class="create-agent-archetype-description">
|
||||
{picked.description}
|
||||
</p>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<FormField label="permissions" htmlFor="agent-permissions">
|
||||
<div id="agent-permissions">
|
||||
{catalogue ? (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
// The create-agent form's archetype picker: what a pick puts in the form.
|
||||
// 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";
|
||||
|
||||
const defaults = ["messaging", "meta", "inbox", "execution"];
|
||||
|
||||
const empty = {
|
||||
permissions: defaults,
|
||||
cpu: "",
|
||||
memory: "",
|
||||
inference: { model: "", effort: "", subagents: "", maxSubagents: "" },
|
||||
};
|
||||
|
||||
test("no archetype is the default permissions and empty settings", () => {
|
||||
assert.deepEqual(prefill(undefined, defaults), empty);
|
||||
});
|
||||
|
||||
test("an archetype's settings fill the form", () => {
|
||||
const reviewer = {
|
||||
source: "hyperhive",
|
||||
name: "reviewer",
|
||||
description: "reviews",
|
||||
permissions: ["messaging", "meta", "inbox"],
|
||||
limits: { cpu_quota: "200%", memory_max: "4G" },
|
||||
inference: {
|
||||
model: "opus",
|
||||
effort: "high",
|
||||
subagent_models: ["haiku", "sonnet"],
|
||||
max_subagents: 0,
|
||||
},
|
||||
};
|
||||
assert.deepEqual(prefill(reviewer, defaults), {
|
||||
permissions: ["messaging", "meta", "inbox"],
|
||||
cpu: "200%",
|
||||
memory: "4G",
|
||||
inference: {
|
||||
model: "opus",
|
||||
effort: "high",
|
||||
subagents: "haiku, sonnet",
|
||||
maxSubagents: "0",
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("an archetype that sets nothing leaves the form as no archetype does", () => {
|
||||
const bare = { source: "hyperhive", name: "bare", description: "d" };
|
||||
assert.deepEqual(prefill(bare, defaults), empty);
|
||||
});
|
||||
|
||||
test("an empty permission list is kept, not replaced by the default", () => {
|
||||
const none = {
|
||||
source: "hyperhive",
|
||||
name: "none",
|
||||
description: "d",
|
||||
permissions: [],
|
||||
};
|
||||
assert.deepEqual(prefill(none, defaults).permissions, []);
|
||||
});
|
||||
|
||||
test("the key tells sources apart", () => {
|
||||
assert.notEqual(
|
||||
archetypeKey({ source: "a", name: "x", description: "" }),
|
||||
archetypeKey({ source: "b", name: "x", description: "" }),
|
||||
);
|
||||
});
|
||||
Loading…
Reference in a new issue