diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 43188320..b6c4e42b 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -330,6 +330,22 @@ The panel reads `GET /api/agents/{agent}/inference`; before the agent's first report reaches the controller, it shows the swarm's stored values only. [inference](inference.md) describes the settings themselves. +### Editing an agent's instructions + +The **instructions** row of an agent's detail panel has an **edit** button. It +opens `AGENTS.md`, the file the agent reads its instructions from, in a plain +text box. The text is the copy the agent last published, so the box still +opens for a stopped agent, but **save** needs the agent running. + +Save sends the swarm a diff against the version you opened, and the agent +applies it to its own file with git. When the agent changed the file in the +meantime, the swarm refuses the diff. The box then keeps your text, shows the +agent's version below it, and offers two ways on: **keep my text, replace the +agent's changes**, which makes your text the new base for the next save, or +**use the agent's version**, which drops yours. A diff that git refuses for any +other reason shows git's message and leaves the file as it was. Closing the +dialog keeps an unsaved edit for the next time you open it. + ### Resource caps The agent's detail panel shows its CPU quota and memory max under **resource diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx index c471371e..808afa77 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentDetail.tsx @@ -11,6 +11,7 @@ import { Badge } from "@hive/shared/badge.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import type { AgentActions } from "../roster/AgentActions.js"; import { AgentInference } from "./AgentInference.js"; +import { AgentInstructions } from "./AgentInstructions.js"; import { AgentLimits } from "./AgentLimits.js"; import { AgentTermPreview } from "../terminal/AgentTermPreview.js"; import { FRESHNESS, type AgentRow } from "../AgentTypes.js"; @@ -103,6 +104,10 @@ export function AgentDetail({
+
instructions
+
+ +
resource caps
diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css new file mode 100644 index 00000000..01ae1d16 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.css @@ -0,0 +1,46 @@ +/* — the AGENTS.md editor in a dialog. */ +.ui-agent-instructions-body, +.ui-agent-instructions-form { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 0.75em; +} +.ui-agent-instructions-body { + margin-top: 1em; +} +.ui-agent-instructions-text textarea, +.ui-agent-instructions-theirs { + font-family: var(--mono, ui-monospace, monospace); + font-size: 0.9em; + width: 100%; + box-sizing: border-box; +} +.ui-agent-instructions-theirs { + margin: 0; + max-height: 16em; + overflow: auto; + white-space: pre-wrap; + padding: 0.5em; + border: 1px solid var(--border, currentColor); + border-radius: 4px; +} +.ui-agent-instructions-status { + margin: 0; + color: var(--muted); +} +.ui-agent-instructions-stale { + display: flex; + flex-direction: column; + gap: 0.75em; + margin: 0; + padding: 0.5em 0.75em; + border: 1px solid var(--red); + border-radius: 4px; +} +.ui-agent-instructions-actions { + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75em; +} diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx new file mode 100644 index 00000000..9880f17c --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInstructions.tsx @@ -0,0 +1,311 @@ +// — an editor for an agent's AGENTS.md, in a dialog opened +// from the agent's detail panel. +// +// It loads the copy the agent's harness last published +// (`GET /api/agents/{name}/agents-md`, so a stopped agent shows the text it +// left) into a plain text box. Save sends a unified diff against the revision +// that was loaded (`PUT`), and the agent applies it with git to the file it +// really reads. The agent may have edited the file in the meantime; then the +// swarm refuses with 409 and the editor keeps the operator's text, shows the +// agent's version beside it, and offers the two ways on: save over it, or take +// the agent's version. +// +// Closing the dialog does not lose an unsaved edit: the wrapper keeps it, and +// the next open starts from it (and notices if the agent moved on). +import { useEffect, useRef, useState } from "preact/hooks"; +import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; +import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js"; +import { Badge } from "@hive/shared/badge.js"; +import { Button } from "../../ui/button/Button.js"; +import { Dialog } from "../../ui/dialog/Dialog.js"; +import { Panel } from "../../ui/panel/Panel.js"; +import { TextAreaField } from "../../ui/text-area-field/TextAreaField.js"; +import { + editBody, + instructionsUrl, + parseInstructions, + STALE_STATUS, + type Instructions, +} from "./instructions.js"; +import "./AgentInstructions.css"; + +// An edit that was not saved: the text, and the version it was made on. +interface Unsaved { + base: Instructions; + text: string; +} + +export function AgentInstructions({ agent }: { agent: string }) { + const [editing, setEditing] = useState(false); + const unsaved = useRef(null); + return ( + <> + setEditing(true)} + /> + setEditing(false)} + label={`edit ${agent}'s AGENTS.md`} + plain + > + {/* Remounted per open, so it starts from the kept edit or a fresh read. */} + {editing ? ( + { + unsaved.current = u; + }} + onClose={() => setEditing(false)} + /> + ) : null} + + + ); +} + +type Load = + | { status: "loading" } + | { status: "error"; problem: ProblemDetails } + | { status: "ready" }; + +async function fetchInstructions(agent: string): Promise { + const r = await fetch(instructionsUrl(agent)); + if (!r.ok) throw await readApiError(r); + const parsed = parseInstructions(await r.json()); + if (parsed === null) { + throw { detail: "the swarm's answer was not an AGENTS.md" }; + } + return parsed; +} + +function asProblem(e: unknown): ProblemDetails { + if (typeof e === "object" && e !== null) return e as ProblemDetails; + return { detail: String(e) }; +} + +function InstructionsForm({ + agent, + kept, + onKeep, + onClose, +}: { + agent: string; + kept: Unsaved | null; + onKeep: (u: Unsaved | null) => void; + onClose: () => void; +}) { + const [load, setLoad] = useState({ status: "loading" }); + // The version the draft is made against, and the draft. + const [base, setBase] = useState(null); + const [draft, setDraft] = useState(""); + // The agent's version when it has moved past `base`: set when a save was + // refused as stale, or when the kept edit is older than the file. + const [theirs, setTheirs] = useState(null); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(null); + const [saved, setSaved] = useState(false); + + useEffect(() => { + let cancelled = false; + fetchInstructions(agent) + .then((current) => { + if (cancelled) return; + if (kept === null) { + setBase(current); + setDraft(current.content); + } else { + setBase(kept.base); + setDraft(kept.text); + if (current.revision !== kept.base.revision) setTheirs(current); + } + setLoad({ status: "ready" }); + }) + .catch((e: unknown) => { + if (!cancelled) setLoad({ status: "error", problem: asProblem(e) }); + }); + return () => { + cancelled = true; + }; + // `kept` is only the starting point, read once when the form mounts. + }, [agent]); + + const dirty = base !== null && draft !== base.content; + useEffect(() => { + if (base !== null) onKeep(dirty ? { base, text: draft } : null); + }, [base, draft, dirty]); + + async function save(e: Event) { + e.preventDefault(); + if (base === null) return; + const body = editBody(base, draft); + if (body === null) return; + setBusy(true); + setProblem(null); + setSaved(false); + try { + const r = await fetch(instructionsUrl(agent), { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + if (r.status === STALE_STATUS) { + // The agent changed the file: keep the draft, fetch what it is now. + setTheirs(await fetchInstructions(agent)); + return; + } + if (!r.ok) { + setProblem(await readApiError(r)); + return; + } + const answer = (await r.json()) as { revision?: unknown }; + if (typeof answer.revision !== "string") { + setProblem({ detail: "the swarm's answer named no revision" }); + return; + } + setBase({ content: draft, revision: answer.revision }); + setSaved(true); + } catch (err: unknown) { + setProblem(asProblem(err)); + } finally { + setBusy(false); + } + } + + function keepMine() { + if (theirs === null) return; + setBase(theirs); + setTheirs(null); + } + + function takeTheirs() { + if (theirs === null) return; + setBase(theirs); + setDraft(theirs.content); + setTheirs(null); + } + + function reload() { + setLoad({ status: "loading" }); + fetchInstructions(agent) + .then((current) => { + setBase(current); + setDraft(current.content); + setTheirs(null); + setProblem(null); + setLoad({ status: "ready" }); + }) + .catch((e: unknown) => + setLoad({ status: "error", problem: asProblem(e) }), + ); + } + + return ( + +

+ The instructions {agent} reads. This is the copy the agent last + published, so a stopped agent still shows. Saving needs the agent + running: it applies your change to its own file, and refuses when it + edited the file since you opened this. +

+
+

+ {load.status === "loading" + ? "loading…" + : busy + ? "saving…" + : saved + ? "saved" + : ""} +

+ {load.status === "error" ? ( + <> + +
+ +
+ + ) : null} + {load.status === "ready" ? ( +
+
+ { + setDraft(text); + setSaved(false); + }} + /> +
+ {theirs !== null ? ( + + ) : null} + {problem ? ( + + ) : null} +
+ + + +
+ + ) : null} +
+
+ ); +} diff --git a/frontend/packages/swarm-ui/src/agent/detail/instructions.ts b/frontend/packages/swarm-ui/src/agent/detail/instructions.ts new file mode 100644 index 00000000..6643aae6 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/instructions.ts @@ -0,0 +1,44 @@ +// What the AGENTS.md editor sends and reads. The routes are +// `GET` and `PUT /api/agents/{name}/agents-md` (swarm-controller-api's +// `AgentsMdView`, `AgentsMdEdit` and `AgentsMdSaved`). +import { unifiedDiff } from "./unified-diff.js"; + +/** What `GET` answers: the text as the agent last published it, and its id. */ +export interface Instructions { + content: string; + /** The git blob id of `content`, which an edit names as its base. */ + revision: string; +} + +/** The body of a `PUT`. */ +export interface InstructionsEdit { + base_revision: string; + diff: string; +} + +export function instructionsUrl(agent: string): string { + return `/api/agents/${encodeURIComponent(agent)}/agents-md`; +} + +/** An answer of the wrong shape is no answer: the editor shows an error. */ +export function parseInstructions(json: unknown): Instructions | null { + if (typeof json !== "object" || json === null) return null; + const { content, revision } = json as Record; + if (typeof content !== "string" || typeof revision !== "string") return null; + return { content, revision }; +} + +/** + * The `PUT` body that turns `base` into `draft`, or `null` when they are the + * same text and there is nothing to save. + */ +export function editBody( + base: Instructions, + draft: string, +): InstructionsEdit | null { + const diff = unifiedDiff(base.content, draft); + return diff === "" ? null : { base_revision: base.revision, diff }; +} + +/** The status the swarm answers a `PUT` with when the file moved on. */ +export const STALE_STATUS = 409; diff --git a/frontend/packages/swarm-ui/src/agent/detail/unified-diff.ts b/frontend/packages/swarm-ui/src/agent/detail/unified-diff.ts new file mode 100644 index 00000000..2184bc36 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/unified-diff.ts @@ -0,0 +1,163 @@ +// A unified diff of one text against another, in the form `git diff` writes +// and `git apply` reads: `--- a/`, `+++ b/`, then `@@` hunks with +// three lines of context. The AGENTS.md editor sends one of these to the +// swarm, which applies it with git on the agent's own copy of the file. +// +// Lines are compared with their line ending, so a changed final newline is a +// change. A last line with no newline is followed by git's +// `\ No newline at end of file` marker, as `git diff` writes it. + +const CONTEXT = 3; + +// The middle of two texts that differ in more than this many line pairs is +// not diffed line by line (the table would be too large); it becomes one +// replaced block, which is still a valid, only coarser, diff. +const MAX_CELLS = 4_000_000; + +interface Line { + text: string; + /** Whether a newline ends the line. Only a text's last line may lack one. */ + eol: boolean; +} + +type Op = { kind: " " | "-" | "+"; line: Line }; + +function splitLines(text: string): Line[] { + if (text === "") return []; + const parts = text.split("\n"); + const lines: Line[] = parts.map((t) => ({ text: t, eol: true })); + // `split` leaves a trailing "" when the text ends with a newline. + if (parts[parts.length - 1] === "") lines.pop(); + else lines[lines.length - 1].eol = false; + return lines; +} + +function same(a: Line, b: Line): boolean { + return a.text === b.text && a.eol === b.eol; +} + +// The edit script turning `a` into `b`: the shared head and tail are kept, +// and the middle is diffed by longest common subsequence. +function script(a: Line[], b: Line[]): Op[] { + let head = 0; + while (head < a.length && head < b.length && same(a[head], b[head])) head++; + let tail = 0; + while ( + tail < a.length - head && + tail < b.length - head && + same(a[a.length - 1 - tail], b[b.length - 1 - tail]) + ) { + tail++; + } + const midA = a.slice(head, a.length - tail); + const midB = b.slice(head, b.length - tail); + + const ops: Op[] = a.slice(0, head).map((line) => ({ kind: " ", line })); + ops.push(...middle(midA, midB)); + ops.push( + ...a.slice(a.length - tail).map((line): Op => ({ kind: " ", line })), + ); + return ops; +} + +function middle(a: Line[], b: Line[]): Op[] { + if (a.length === 0) return b.map((line) => ({ kind: "+", line })); + if (b.length === 0) return a.map((line) => ({ kind: "-", line })); + if ((a.length + 1) * (b.length + 1) > MAX_CELLS) { + return [ + ...a.map((line): Op => ({ kind: "-", line })), + ...b.map((line): Op => ({ kind: "+", line })), + ]; + } + + // lcs[i][j]: length of the longest common subsequence of a[i..] and b[j..]. + const w = b.length + 1; + const lcs = new Uint32Array((a.length + 1) * w); + for (let i = a.length - 1; i >= 0; i--) { + for (let j = b.length - 1; j >= 0; j--) { + lcs[i * w + j] = same(a[i], b[j]) + ? lcs[(i + 1) * w + j + 1] + 1 + : Math.max(lcs[(i + 1) * w + j], lcs[i * w + j + 1]); + } + } + + const ops: Op[] = []; + let i = 0; + let j = 0; + while (i < a.length && j < b.length) { + if (same(a[i], b[j])) { + ops.push({ kind: " ", line: a[i] }); + i++; + j++; + } else if (lcs[(i + 1) * w + j] >= lcs[i * w + j + 1]) { + ops.push({ kind: "-", line: a[i++] }); + } else { + ops.push({ kind: "+", line: b[j++] }); + } + } + while (i < a.length) ops.push({ kind: "-", line: a[i++] }); + while (j < b.length) ops.push({ kind: "+", line: b[j++] }); + return ops; +} + +function render(op: Op): string { + const body = `${op.kind}${op.line.text}\n`; + return op.line.eol ? body : `${body}\\ No newline at end of file\n`; +} + +// `start,count` as a hunk header writes it: a count of 1 is left off, and an +// empty range starts one line earlier than it would otherwise. +function range(start: number, count: number): string { + if (count === 1) return String(start); + return `${count === 0 ? start - 1 : start},${count}`; +} + +/** + * The diff turning `before` into `after` for the file `name`, or `""` when + * they are the same text. + */ +export function unifiedDiff( + before: string, + after: string, + name = "AGENTS.md", +): string { + if (before === after) return ""; + const ops = script(splitLines(before), splitLines(after)); + + // Hunks: each change with CONTEXT lines either side, merged when the + // context of two changes would overlap. + const changed: number[] = []; + ops.forEach((op, i) => { + if (op.kind !== " ") changed.push(i); + }); + const spans: [number, number][] = []; + for (const i of changed) { + const from = Math.max(0, i - CONTEXT); + const to = Math.min(ops.length, i + CONTEXT + 1); + const last = spans[spans.length - 1]; + if (last && from <= last[1]) last[1] = Math.max(last[1], to); + else spans.push([from, to]); + } + + // Line numbers (1-based) of the first line of each op in either text. + const oldAt: number[] = []; + const newAt: number[] = []; + let o = 1; + let n = 1; + for (const op of ops) { + oldAt.push(o); + newAt.push(n); + if (op.kind !== "+") o++; + if (op.kind !== "-") n++; + } + + let out = `--- a/${name}\n+++ b/${name}\n`; + for (const [from, to] of spans) { + const slice = ops.slice(from, to); + const oldCount = slice.filter((op) => op.kind !== "+").length; + const newCount = slice.filter((op) => op.kind !== "-").length; + out += `@@ -${range(oldAt[from], oldCount)} +${range(newAt[from], newCount)} @@\n`; + out += slice.map(render).join(""); + } + return out; +} diff --git a/frontend/packages/swarm-ui/test/unified-diff.test.mjs b/frontend/packages/swarm-ui/test/unified-diff.test.mjs new file mode 100644 index 00000000..28912fa9 --- /dev/null +++ b/frontend/packages/swarm-ui/test/unified-diff.test.mjs @@ -0,0 +1,79 @@ +// The AGENTS.md editor's diff builder. 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. The expected strings are what `git diff` writes for the +// same two texts, and `git apply` reads back. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { unifiedDiff } from "../src/agent/detail/unified-diff.ts"; + +test("the same text has no diff", () => { + assert.equal(unifiedDiff("a\nb\n", "a\nb\n"), ""); + assert.equal(unifiedDiff("", ""), ""); +}); + +test("a changed middle line carries three lines of context", () => { + const before = "1\n2\n3\n4\n5\n6\n7\n8\n9\n"; + const after = "1\n2\n3\n4\nfive\n6\n7\n8\n9\n"; + assert.equal( + unifiedDiff(before, after), + "--- a/AGENTS.md\n+++ b/AGENTS.md\n" + + "@@ -2,7 +2,7 @@\n 2\n 3\n 4\n-5\n+five\n 6\n 7\n 8\n", + ); +}); + +test("two far apart changes make two hunks, near ones merge", () => { + const lines = Array.from({ length: 20 }, (_, i) => `l${i + 1}`); + const far = [...lines]; + far[1] = "X"; + far[17] = "Y"; + const farDiff = unifiedDiff(lines.join("\n") + "\n", far.join("\n") + "\n"); + assert.equal(farDiff.match(/^@@/gm)?.length, 2); + + const near = [...lines]; + near[1] = "X"; + near[6] = "Y"; + const nearDiff = unifiedDiff(lines.join("\n") + "\n", near.join("\n") + "\n"); + assert.equal(nearDiff.match(/^@@/gm)?.length, 1); +}); + +test("writing into an empty file adds every line", () => { + assert.equal( + unifiedDiff("", "hello\nworld\n"), + "--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -0,0 +1,2 @@\n+hello\n+world\n", + ); +}); + +test("emptying a file removes every line", () => { + assert.equal( + unifiedDiff("hello\n", ""), + "--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -1 +0,0 @@\n-hello\n", + ); +}); + +test("a missing final newline is marked as git marks it", () => { + assert.equal( + unifiedDiff("a\nb", "a\nb\n"), + "--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -1,2 +1,2 @@\n a\n" + + "-b\n\\ No newline at end of file\n+b\n", + ); + assert.equal( + unifiedDiff("a\nb\n", "a\nc"), + "--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -1,2 +1,2 @@\n a\n" + + "-b\n+c\n\\ No newline at end of file\n", + ); +}); + +test("a pure insertion keeps the lines around it as context", () => { + assert.equal( + unifiedDiff("a\nc\n", "a\nb\nc\n"), + "--- a/AGENTS.md\n+++ b/AGENTS.md\n@@ -1,2 +1,3 @@\n a\n+b\n c\n", + ); +}); + +test("the file name is the one asked for", () => { + assert.ok( + unifiedDiff("a\n", "b\n", "CLAUDE.md").startsWith( + "--- a/CLAUDE.md\n+++ b/CLAUDE.md\n", + ), + ); +});