Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/pages/CreateAgentForm.tsx
iris dd7fc1c9af swarm-ui: show the AGENTS.md size refusal when the instructions section is collapsed
The refusal sat inside the collapsed section, and the general error panel
hides while a refusal is set, so a refused submit with the section closed
showed nothing. The refusal now renders under the section, outside the
details element.
2026-10-18 16:02:10 +02:00

437 lines
17 KiB
TypeScript

// <CreateAgentForm> — minimal agent creation form, POSTs to
// swarm-controller's `POST /api/agents`. That endpoint only queues a
// `CreateIdentity` job and returns its node id — the identity isn't
// guaranteed to exist yet when this gets a response — so this shows
// the queued confirmation and links to the Jobs page to watch it
// settle, rather than duplicating JobqGraph's per-node polling here.
// Scope matches the originating issue exactly: name field only, no
// forge/deploy options (those aren't wired server-side yet either).
//
// Rendered inside a `Dialog` from `AgentsPage`'s own "+ agent" button,
// not a standalone route — the roster it feeds is the natural home for
// the action that populates it, and a distinct top-level nav item next
// to it was one click of indirection for no benefit. Kept as its own
// component (not inlined into AgentsPage) since the two-panel layout
// below is unchanged from its former life as a page: same content, same
// review history, just a different mount point.
//
// `hive` field added because agent creation had no way to record which
// hive an agent runs on. `POST /api/agents` now requires it, so the
// roster fetched off `GET /api/hives` backs a required select here
// rather than a free-text field — a spawn target has to be one of the
// swarm's actual hives, same reasoning `Ident.parse` gets client-side
// pattern validation for `name`.
//
// Its name field, hive select, and submit button all come from the
// shared `ui/` form kit (`TextField`/`SelectField`/`Button`) rather than
// page-scoped input/button chrome — see that kit's own comments for why.
//
// Permissions: checkboxes from `GET /api/permissions`, on the default set plus
// `extraPermissions`; sent only when that differs from the default.
import { useEffect, useState } from "preact/hooks";
import { Link } from "wouter-preact";
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
import { Panel } from "../ui/panel/Panel.js";
import { TextField } from "../ui/text-field/TextField.js";
import {
SelectField,
type SelectOption,
} from "../ui/select-field/SelectField.js";
import { TextAreaField } from "../ui/text-area-field/TextAreaField.js";
import { Button } from "../ui/button/Button.js";
import { capsBody, LimitsFields } from "../agent/detail/AgentLimits.js";
import {
draftBody,
draftProblem,
EMPTY_INFERENCE_DRAFT,
InferenceFields,
type InferenceDraft,
} from "../agent/detail/AgentInference.js";
import { FormField } from "../ui/form-field/FormField.js";
import { MultiselectFilterOptions } from "../ui/multiselect-filter/MultiselectFilter.js";
import {
initialSelection,
requestPermissions,
togglePermission,
} from "./CreateAgentPermissions.js";
import {
archetypeKey,
prefill,
requestArchetype,
type ArchetypeList,
} from "./CreateAgentArchetype.js";
import { agentsMdBody, agentsMdRefusal } from "./CreateAgentAgentsMd.js";
import "./CreateAgentForm.css";
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
// consumes off `/api/hives/status`, but this page hits the plain
// `/api/hives` roster (no status/freshness needed, just "what hives
// exist to spawn into").
interface HiveEntry {
name: string;
domain: string;
}
interface CreateAgentResponse {
node_id: number;
}
// Mirrors swarm-controller's `Catalogue` (`GET /api/permissions`).
interface PermissionCatalogue {
all: string[];
default: string[];
}
type SubmitState =
| { status: "idle" }
| { status: "submitting" }
| { status: "done"; nodeId: number }
| { status: "error"; problem: ProblemDetails };
// Client-side mirror of `hive_types::Ident` (1-63 chars of `[a-z0-9-]`) —
// a `pattern` hint only, not a substitute for the server's own
// validation; a name this rejects would fail server-side anyway, so
// catching it before a round-trip is a pure UX win, not a new gate.
//
// Hyphen escaped (`\-`) rather than left trailing in the class: modern
// browsers validate the `pattern` attribute's regex in Unicode-set (`v`)
// mode, which is far stricter about hyphen placement than classic mode —
// `[a-z0-9-]` throws "Invalid character class" under `v` mode even though
// it's valid classic-mode regex (a trailing `-` is unambiguous there).
// Reproduced directly: `new RegExp('[a-z0-9-]', 'v')` throws,
// `new RegExp('[a-z0-9\\-]', 'v')` doesn't — confirmed via mara's console
// exception report on this page.
const NAME_PATTERN = "[a-z0-9\\-]{1,63}";
export function CreateAgentForm({
onClose,
initialName = "",
extraPermissions = [],
}: {
/** The name field's starting value, for a caller that suggests one. */
initialName?: string;
/** Permissions checked on top of the default set when the form opens. */
extraPermissions?: string[];
/** Rendered as the form panel's own close button — this form always
* mounts inside a `plain` `Dialog` (see `AgentsPage`), which no longer
* floats its own. */
onClose?: () => void;
}) {
const [name, setName] = useState(initialName);
const [hive, setHive] = useState("");
// `null` = still loading, `[]` = loaded but empty (a real, if unusual,
// swarm state) — distinct from "not fetched yet" so the placeholder
// option's label can tell the two apart.
const [hives, setHives] = useState<HiveEntry[] | null>(null);
const [hivesError, setHivesError] = useState<ProblemDetails | null>(null);
const [result, setResult] = useState<SubmitState>({ status: "idle" });
// The optional settings sections, left empty unless the operator fills them.
const [cpu, setCpu] = useState("");
const [memory, setMemory] = useState("");
const [inference, setInference] = useState<InferenceDraft>(
EMPTY_INFERENCE_DRAFT,
);
const inferenceProblem = draftProblem(inference);
// `null` until `/api/permissions` answers; `selected` starts on its default.
const [catalogue, setCatalogue] = useState<PermissionCatalogue | null>(null);
const [catalogueError, setCatalogueError] = useState<ProblemDetails | null>(
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("");
// The text the agent's `AGENTS.md` starts with; empty sends none.
const [agentsMd, setAgentsMd] = 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
// page visit isn't worth a second round-trip per keystroke/submit.
useEffect(() => {
(async () => {
const r = await fetch("/api/hives");
if (!r.ok) {
setHivesError(await readApiError(r));
return;
}
const data = (await r.json()) as HiveEntry[];
setHives(data);
// Single-hive swarms are the common case — default it rather than
// making the operator pick the only option. Multi-hive swarms get
// no default (the placeholder stays selected), so `required` on
// the select below forces an explicit choice per mara's scope.
if (data.length === 1) setHive(data[0].name);
})().catch((e: unknown) => setHivesError({ detail: String(e) }));
}, []);
useEffect(() => {
(async () => {
const r = await fetch("/api/permissions");
if (!r.ok) {
setCatalogueError(await readApiError(r));
return;
}
const data = (await r.json()) as PermissionCatalogue;
setCatalogue(data);
setSelected(initialSelection(data.all, data.default, extraPermissions));
})().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,
}));
const hivesReady = hives !== null && hives.length > 0;
// The controller's size refusal of the AGENTS.md text, shown under its
// section so a collapsed section still shows it.
const refusal =
result.status === "error" ? agentsMdRefusal(result.problem) : null;
async function submit(e: Event) {
e.preventDefault();
setResult({ status: "submitting" });
try {
// A section left empty is left out, so the request is the plain
// name-and-hive one unless the operator set something.
const limits = capsBody(cpu, memory);
const settings = draftBody(inference);
const r = await fetch("/api/agents", {
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify({
name,
hive,
...(Object.keys(limits).length > 0 ? { limits } : {}),
...(Object.keys(settings).length > 0 ? { inference: settings } : {}),
permissions: catalogue
? requestPermissions(selected, catalogue.default)
: undefined,
archetype: requestArchetype(picked),
...agentsMdBody(agentsMd),
}),
});
if (!r.ok) {
setResult({ status: "error", problem: await readApiError(r) });
return;
}
const data = (await r.json()) as CreateAgentResponse;
setResult({ status: "done", nodeId: data.node_id });
setName("");
setAgentsMd("");
pickArchetype("");
if (catalogue)
setSelected(
initialSelection(catalogue.all, catalogue.default, extraPermissions),
);
} catch (err) {
setResult({ status: "error", problem: { detail: String(err) } });
}
}
return (
<div class="create-agent-layout">
<div class="create-agent-form-col">
<Panel title="create agent" icon="🤖" onClose={onClose}>
<p class="create-agent-intro">
Create a new agent's swarm-level identity. This only queues the job
— check <Link href="/jobs">jobs</Link> to watch it settle. Resource
caps, inference and instructions are optional; leave them empty to
set nothing. A name set up before keeps the settings stored for it.
</p>
{hivesError && (
<ApiErrorPanel
context="failed to load the hive list"
problem={hivesError}
/>
)}
{catalogueError && (
<ApiErrorPanel
context="failed to load the permission list"
problem={catalogueError}
/>
)}
{archetypesError && (
<ApiErrorPanel
context="failed to load the archetype list"
problem={archetypesError}
/>
)}
<form class="create-agent-form" onSubmit={submit}>
<TextField
id="agent-name"
label="agent name"
value={name}
pattern={NAME_PATTERN}
title="1-63 chars: lowercase letters, digits, hyphens"
required
onInput={setName}
/>
<SelectField
id="agent-hive"
label="hive"
value={hive}
onChange={setHive}
options={hiveOptions}
required
disabled={!hivesReady}
placeholder={
!hives
? "loading…"
: hives.length === 0
? "no hives configured"
: "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} An agent created from an archetype has
no config repo: its hive builds it from the archetype's
module.
</p>
)}
</>
)}
<FormField label="permissions" htmlFor="agent-permissions">
<div id="agent-permissions">
{catalogue ? (
<MultiselectFilterOptions
options={catalogue.all}
filter={{ values: selected, negate: false }}
onToggle={(v) => setSelected((s) => togglePermission(s, v))}
/>
) : (
<span>loading…</span>
)}
</div>
</FormField>
<details class="create-agent-section">
<summary>resource caps</summary>
<div class="create-agent-section-body">
<LimitsFields
idPrefix="create-limits"
cpu={cpu}
memory={memory}
onCpu={setCpu}
onMemory={setMemory}
/>
</div>
</details>
<details class="create-agent-section">
<summary>inference</summary>
<div class="create-agent-section-body">
<InferenceFields
idPrefix="create-inference"
draft={inference}
onChange={setInference}
/>
</div>
</details>
<details class="create-agent-section">
<summary>instructions</summary>
<div class="create-agent-section-body">
<TextAreaField
id="agent-agents-md"
label="AGENTS.md"
value={agentsMd}
rows={8}
placeholder="the text the agent's AGENTS.md starts with; leave empty for none"
onInput={setAgentsMd}
/>
</div>
</details>
{refusal !== null && (
<p class="create-agent-field-error">{refusal}</p>
)}
<Button
variant="primary"
type="submit"
disabled={
result.status === "submitting" ||
!hivesReady ||
!catalogue ||
inferenceProblem !== null
}
>
{result.status === "submitting" ? "creating…" : "create"}
</Button>
</form>
{result.status === "done" && (
<p class="create-agent-result create-agent-result-ok">
queued as job node #{result.nodeId} —{" "}
<Link href="/jobs">watch it in jobs</Link>
</p>
)}
{result.status === "error" && refusal === null && (
<ApiErrorPanel context="failed to queue" problem={result.problem} />
)}
</Panel>
</div>
<div class="create-agent-info-col">
{/* Describes the intended end state (a running agent), not today's
literal behaviour (deploying the container onto the hive isn't
wired up server-side yet — see this file's top comment) —
mara's explicit call on this copy: read as finished, not as a
running commentary on partial implementation. */}
<Panel title="what this creates" icon="🪪">
<p>
Submitting this queues everything a new agent needs: a swarm-level
identity, a config repo on the forge with the operator added as a
collaborator, and a container running the agent on the hive you pick
above. An agent created from an archetype gets no config repo: its
container runs the archetype's module. Watch it all settle on{" "}
<Link href="/jobs">jobs</Link>.
</p>
</Panel>
</div>
</div>
);
}