swarm-ui: let the create-agent form set the new agent's initial AGENTS.md
The form gets a collapsed "instructions" section with a plain text box. Its text goes in the optional agents_md field of the create request, and is left out while the box is empty or only whitespace. The controller's size refusal shows under the box instead of in the form's general error panel. The text box empties after a successful create. The send and refusal logic sits in its own module with tests, and docs/swarm/ui.md describes the field.
This commit is contained in:
parent
456cf9a7cb
commit
30ba34f411
5 changed files with 112 additions and 3 deletions
|
|
@ -386,6 +386,19 @@ controller puts back what it replaced. Each agent name keys its settings and
|
||||||
destroying an agent doesn't clear them, so a name set up before keeps its
|
destroying an agent doesn't clear them, so a name set up before keeps its
|
||||||
stored settings when you leave the fields empty.
|
stored settings when you leave the fields empty.
|
||||||
|
|
||||||
|
### Initial AGENTS.md at creation
|
||||||
|
|
||||||
|
A third collapsed section, **instructions**, holds a plain text box for the
|
||||||
|
text the new agent's `AGENTS.md` starts with. Left empty, or holding only
|
||||||
|
whitespace, it sends nothing and the agent starts without the file. Otherwise
|
||||||
|
the text goes as typed in the optional `agents_md` field of `POST /api/agents`.
|
||||||
|
The agent writes it on its first start, and only when it has no `AGENTS.md`
|
||||||
|
yet, so creating an agent again under a name that has one never replaces it.
|
||||||
|
The controller refuses a text over 64 KiB; its reason shows under the box and
|
||||||
|
no agent is created. After a successful create the box empties. The
|
||||||
|
[instructions editor](#editing-an-agents-instructions) changes the file later.
|
||||||
|
The [swarm README](README.md#an-agents-agentsmd) has the wire details.
|
||||||
|
|
||||||
### Linking external accounts
|
### Linking external accounts
|
||||||
|
|
||||||
Each agent on `/agents` opens three dialogs that write a credential for it
|
Each agent on `/agents` opens three dialogs that write a credential for it
|
||||||
|
|
|
||||||
23
frontend/packages/swarm-ui/src/pages/CreateAgentAgentsMd.ts
Normal file
23
frontend/packages/swarm-ui/src/pages/CreateAgentAgentsMd.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
// The create-agent form's initial `AGENTS.md` field, kept out of the
|
||||||
|
// component so `npm test` can run it.
|
||||||
|
import type { ProblemDetails } from "@hive/shared/api-error.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The `agents_md` to send with a create: nothing while the box is empty or
|
||||||
|
* holds only whitespace, so the agent starts without the file; otherwise the
|
||||||
|
* text as typed.
|
||||||
|
*/
|
||||||
|
export function agentsMdBody(text: string): { agents_md?: string } {
|
||||||
|
return text.trim() === "" ? {} : { agents_md: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The controller's refusal of the text, when `problem` is one: a 400 whose
|
||||||
|
* detail starts with the field's name. It shows beside the box, so any other
|
||||||
|
* failure stays with the form's general error.
|
||||||
|
*/
|
||||||
|
export function agentsMdRefusal(problem: ProblemDetails): string | null {
|
||||||
|
return problem.status === 400 && problem.detail?.startsWith("agents_md ")
|
||||||
|
? problem.detail
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
@ -69,6 +69,11 @@
|
||||||
.create-agent-result-ok {
|
.create-agent-result-ok {
|
||||||
color: var(--green);
|
color: var(--green);
|
||||||
}
|
}
|
||||||
|
/* The controller's refusal of the AGENTS.md text, under its box. */
|
||||||
|
.create-agent-field-error {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--red);
|
||||||
|
}
|
||||||
/* The picked archetype's description, under its select. */
|
/* The picked archetype's description, under its select. */
|
||||||
.create-agent-archetype-description {
|
.create-agent-archetype-description {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
|
|
|
||||||
|
|
@ -38,6 +38,7 @@ import {
|
||||||
SelectField,
|
SelectField,
|
||||||
type SelectOption,
|
type SelectOption,
|
||||||
} from "../ui/select-field/SelectField.js";
|
} from "../ui/select-field/SelectField.js";
|
||||||
|
import { TextAreaField } from "../ui/text-area-field/TextAreaField.js";
|
||||||
import { Button } from "../ui/button/Button.js";
|
import { Button } from "../ui/button/Button.js";
|
||||||
import { capsBody, LimitsFields } from "../agent/detail/AgentLimits.js";
|
import { capsBody, LimitsFields } from "../agent/detail/AgentLimits.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -60,6 +61,7 @@ import {
|
||||||
requestArchetype,
|
requestArchetype,
|
||||||
type ArchetypeList,
|
type ArchetypeList,
|
||||||
} from "./CreateAgentArchetype.js";
|
} from "./CreateAgentArchetype.js";
|
||||||
|
import { agentsMdBody, agentsMdRefusal } from "./CreateAgentAgentsMd.js";
|
||||||
import "./CreateAgentForm.css";
|
import "./CreateAgentForm.css";
|
||||||
|
|
||||||
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
|
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
|
||||||
|
|
@ -144,6 +146,8 @@ export function CreateAgentForm({
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [archetype, setArchetype] = useState("");
|
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(
|
const picked = archetypes?.archetypes.find(
|
||||||
(a) => archetypeKey(a) === archetype,
|
(a) => archetypeKey(a) === archetype,
|
||||||
);
|
);
|
||||||
|
|
@ -211,6 +215,9 @@ export function CreateAgentForm({
|
||||||
label: h.name,
|
label: h.name,
|
||||||
}));
|
}));
|
||||||
const hivesReady = hives !== null && hives.length > 0;
|
const hivesReady = hives !== null && hives.length > 0;
|
||||||
|
// The controller's size refusal of the AGENTS.md text, shown by its box.
|
||||||
|
const refusal =
|
||||||
|
result.status === "error" ? agentsMdRefusal(result.problem) : null;
|
||||||
|
|
||||||
async function submit(e: Event) {
|
async function submit(e: Event) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -232,6 +239,7 @@ export function CreateAgentForm({
|
||||||
? requestPermissions(selected, catalogue.default)
|
? requestPermissions(selected, catalogue.default)
|
||||||
: undefined,
|
: undefined,
|
||||||
archetype: requestArchetype(picked),
|
archetype: requestArchetype(picked),
|
||||||
|
...agentsMdBody(agentsMd),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!r.ok) {
|
if (!r.ok) {
|
||||||
|
|
@ -241,6 +249,7 @@ export function CreateAgentForm({
|
||||||
const data = (await r.json()) as CreateAgentResponse;
|
const data = (await r.json()) as CreateAgentResponse;
|
||||||
setResult({ status: "done", nodeId: data.node_id });
|
setResult({ status: "done", nodeId: data.node_id });
|
||||||
setName("");
|
setName("");
|
||||||
|
setAgentsMd("");
|
||||||
pickArchetype("");
|
pickArchetype("");
|
||||||
if (catalogue)
|
if (catalogue)
|
||||||
setSelected(
|
setSelected(
|
||||||
|
|
@ -258,8 +267,8 @@ export function CreateAgentForm({
|
||||||
<p class="create-agent-intro">
|
<p class="create-agent-intro">
|
||||||
Create a new agent's swarm-level identity. This only queues the job
|
Create a new agent's swarm-level identity. This only queues the job
|
||||||
— check <Link href="/jobs">jobs</Link> to watch it settle. Resource
|
— check <Link href="/jobs">jobs</Link> to watch it settle. Resource
|
||||||
caps and inference are optional; leave them empty to set nothing. A
|
caps, inference and instructions are optional; leave them empty to
|
||||||
name set up before keeps the settings stored for it.
|
set nothing. A name set up before keeps the settings stored for it.
|
||||||
</p>
|
</p>
|
||||||
{hivesError && (
|
{hivesError && (
|
||||||
<ApiErrorPanel
|
<ApiErrorPanel
|
||||||
|
|
@ -365,6 +374,22 @@ export function CreateAgentForm({
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</details>
|
</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}
|
||||||
|
/>
|
||||||
|
{refusal !== null && (
|
||||||
|
<p class="create-agent-field-error">{refusal}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</details>
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
|
|
@ -384,7 +409,7 @@ export function CreateAgentForm({
|
||||||
<Link href="/jobs">watch it in jobs</Link>
|
<Link href="/jobs">watch it in jobs</Link>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
{result.status === "error" && (
|
{result.status === "error" && refusal === null && (
|
||||||
<ApiErrorPanel context="failed to queue" problem={result.problem} />
|
<ApiErrorPanel context="failed to queue" problem={result.problem} />
|
||||||
)}
|
)}
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
|
||||||
43
frontend/packages/swarm-ui/test/create-agents-md.test.mjs
Normal file
43
frontend/packages/swarm-ui/test/create-agents-md.test.mjs
Normal file
|
|
@ -0,0 +1,43 @@
|
||||||
|
// What the create-agent form sends for the initial AGENTS.md, and which error
|
||||||
|
// it shows beside the box. Run by `npm test`, which Node runs with its
|
||||||
|
// built-in test runner and type stripping, so the `.ts` module is imported as
|
||||||
|
// it is.
|
||||||
|
import assert from "node:assert/strict";
|
||||||
|
import { test } from "node:test";
|
||||||
|
import {
|
||||||
|
agentsMdBody,
|
||||||
|
agentsMdRefusal,
|
||||||
|
} from "../src/pages/CreateAgentAgentsMd.ts";
|
||||||
|
|
||||||
|
test("an empty or whitespace-only box sends no agents_md", () => {
|
||||||
|
assert.deepEqual(agentsMdBody(""), {});
|
||||||
|
assert.deepEqual(agentsMdBody(" \n\t \n"), {});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("text is sent as typed, whitespace and all", () => {
|
||||||
|
assert.deepEqual(agentsMdBody("# me\n\nDo the thing.\n"), {
|
||||||
|
agents_md: "# me\n\nDo the thing.\n",
|
||||||
|
});
|
||||||
|
assert.deepEqual(agentsMdBody(" indented"), { agents_md: " indented" });
|
||||||
|
});
|
||||||
|
|
||||||
|
test("the size refusal shows beside the box", () => {
|
||||||
|
const detail = "agents_md is 70000 bytes; the limit is 65536";
|
||||||
|
assert.equal(agentsMdRefusal({ status: 400, detail }), detail);
|
||||||
|
});
|
||||||
|
|
||||||
|
test("other failures stay with the general error", () => {
|
||||||
|
assert.equal(
|
||||||
|
agentsMdRefusal({ status: 400, detail: "name is not a valid identifier" }),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(
|
||||||
|
agentsMdRefusal({
|
||||||
|
status: 500,
|
||||||
|
detail: "agents_md is 70000 bytes; the limit is 65536",
|
||||||
|
}),
|
||||||
|
null,
|
||||||
|
);
|
||||||
|
assert.equal(agentsMdRefusal({ status: 400 }), null);
|
||||||
|
assert.equal(agentsMdRefusal({ detail: "agents_md is too long" }), null);
|
||||||
|
});
|
||||||
Loading…
Reference in a new issue