From 0d04bd7295876e75fd48288e74be43cbc3a3d13a Mon Sep 17 00:00:00 2001 From: iris Date: Sat, 10 Oct 2026 02:10:34 +0200 Subject: [PATCH] swarm-ui: per-agent inference panel and editor on the agents page The agent detail panel shows the model and effort an agent runs on with where each comes from, plus the swarm's subagent model list. An edit dialog sets the swarm value (PUT) and a clear action removes it (DELETE). Reads the configured/effective view, and shows the stored values alone until an agent has reported what it runs on. --- docs/swarm/inference.md | 3 +- docs/swarm/ui.md | 31 +- .../src/pages/agents/AgentInference.css | 40 +++ .../src/pages/agents/AgentInference.tsx | 301 ++++++++++++++++++ .../swarm-ui/src/pages/agents/AgentsPage.tsx | 8 + 5 files changed, 373 insertions(+), 10 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentInference.css create mode 100644 frontend/packages/swarm-ui/src/pages/agents/AgentInference.tsx diff --git a/docs/swarm/inference.md b/docs/swarm/inference.md index 1b71df78..7c75b76e 100644 --- a/docs/swarm/inference.md +++ b/docs/swarm/inference.md @@ -26,7 +26,8 @@ swarmctl agent inference clear iris running on, each with where it comes from. - `clear` removes every field; the agent goes back to its own values. -The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). +The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). The +swarm UI's agents page edits the same settings; see [UI](ui.md). ## What an agent runs on diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 87fd4865..7924dbe3 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -7,15 +7,15 @@ domain, covers host-level detail for one hive. ## What it shows -| route | what | -| ---------------------------------------------- | --------------------------------------------------------------------------------------------------- | -| `/` | the hive directory, each hive with its last reported status | -| `/agents` | every agent: status, config PR, wanted state; create agents, link forge, matrix and GitHub accounts | -| `/agents//terminal` | one agent's live terminal | -| `/agents//subagents//terminal` | one of that agent's subagents' live terminal | -| `/jobs` | the controller's job graph — where agent creation and credential mints show progress | -| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | -| `/issues` | a cross-repo issue report | +| route | what | +| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- | +| `/` | the hive directory, each hive with its last reported status | +| `/agents` | every agent: status, config PR, wanted state, inference settings; create agents, link forge, matrix and GitHub accounts | +| `/agents//terminal` | one agent's live terminal | +| `/agents//subagents//terminal` | one of that agent's subagents' live terminal | +| `/jobs` | the controller's job graph — where agent creation and credential mints show progress | +| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove | +| `/issues` | a cross-repo issue report | Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). An agent created here or with `swarmctl agent create` starts `paused`; set it @@ -102,6 +102,19 @@ store identity, or no queue address, publishes no subagent terminals. +### Inference settings + +The agent's detail panel shows its model and effort under **inference**, each +with where it comes from: the swarm, its agent.nix, its own pick, or the +default. Below them is the subagent model list the swarm sets, or "its own" +when it sets none. **edit** opens a dialog with the three fields, filled with +what the swarm stores; saving does a `PUT /api/agents/{agent}/inference` of +the whole value, and a field left empty is left out, so that field stays the +agent's own. **clear the swarm's setting** sends the `DELETE`. The values +shown come from `GET /api/agents/{agent}/inference`; before the agent's first +report reaches the controller, the panel shows the swarm's stored values only. +The settings themselves are described in [inference](inference.md). + ### Linking external accounts Each agent on `/agents` opens three dialogs that write a credential for it diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentInference.css b/frontend/packages/swarm-ui/src/pages/agents/AgentInference.css new file mode 100644 index 00000000..f7f0a2b7 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentInference.css @@ -0,0 +1,40 @@ +/* — one setting per line: badge, then where it comes from. */ +.ui-agent-inference { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.3em; +} +.ui-agent-inference-list { + list-style: none; + margin: 0; + padding: 0; + display: grid; + gap: 0.3em; +} +.ui-agent-inference-list li { + display: flex; + align-items: center; + gap: 0.5em; + flex-wrap: wrap; +} +.ui-agent-inference-source, +.ui-agent-inference-muted { + color: var(--muted); +} +.ui-agent-inference-form { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 0.75em; + margin-top: 1em; +} +.ui-agent-inference-problem { + margin: 0; + color: var(--red); +} +.ui-agent-inference-actions { + display: flex; + align-items: center; + gap: 0.75em; +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentInference.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentInference.tsx new file mode 100644 index 00000000..f23936d3 --- /dev/null +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentInference.tsx @@ -0,0 +1,301 @@ +// — one agent's inference settings on the detail panel: the +// model and effort it runs on now, with where each came from, and an editor +// for the value the swarm sets. +// +// Reads `GET /api/agents/{name}/inference`, which answers +// `{ configured, effective }`: `configured` is the swarm's stored value (null +// when none), `effective` what the agent reported running on (null until its +// first report reaches the controller). The editor PUTs the whole value and +// the clear action DELETEs it; a cleared field puts the agent back on its own +// value, which is why an empty field is left out of the body rather than sent. +// +// Its state belongs to one agent: callers key it by agent name, so switching +// agents remounts it instead of showing the previous agent's settings. +import { useEffect, 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 { Dialog } from "../../ui/dialog/Dialog.js"; +import { Panel } from "../../ui/panel/Panel.js"; +import { SelectField } from "../../ui/select-field/SelectField.js"; +import { TextField } from "../../ui/text-field/TextField.js"; +import { Button } from "../../ui/button/Button.js"; +import "./AgentInference.css"; + +type Source = "swarm" | "agent_nix" | "local_file" | "default"; + +// Mirrors the swarm's `InferenceSettings`; every field is optional. +interface Settings { + model?: string; + effort?: string; + subagent_models?: string[]; +} + +// Mirrors `EffectiveInference`. +interface Effective { + model: string; + effort: string; + model_source: Source; + effort_source: Source; +} + +interface View { + configured: Settings | null; + effective: Effective | null; +} + +// The values the swarm accepts for `effort`. +const EFFORTS = ["low", "medium", "high", "xhigh", "max"]; +const EFFORT_OPTIONS = [ + { value: "", label: "the agent's own" }, + ...EFFORTS.map((e) => ({ value: e, label: e })), +]; + +const SOURCE_LABEL: Record = { + swarm: "swarm", + agent_nix: "agent.nix", + local_file: "its own pick", + default: "default", +}; + +// The same checks the swarm makes on a model name, so an obviously bad one +// is refused before the round trip; the swarm stays the real gate. +function modelNameProblem(name: string): string | null { + if (/\s/.test(name)) return "a model name has no whitespace"; + if (name.startsWith("-")) return "a model name does not start with -"; + return null; +} + +// Subagent models are typed as one list, split on commas and whitespace. +function parseModels(text: string): string[] { + return text.split(/[\s,]+/).filter((m) => m !== ""); +} + +function inferenceUrl(agent: string): string { + return `/api/agents/${encodeURIComponent(agent)}/inference`; +} + +export function AgentInference({ agent }: { agent: string }) { + const [view, setView] = useState(null); + const [error, setError] = useState(null); + // Bumped after a save or a clear, so the settings are fetched again. + const [reload, setReload] = useState(0); + const [editing, setEditing] = useState(false); + + useEffect(() => { + let cancelled = false; + (async () => { + const r = await fetch(inferenceUrl(agent)); + if (!r.ok) { + if (!cancelled) setError(await readApiError(r)); + return; + } + const body = (await r.json()) as Partial; + if (cancelled) return; + setView({ + configured: body.configured ?? null, + effective: body.effective ?? null, + }); + setError(null); + })().catch((e: unknown) => { + if (!cancelled) setError({ detail: String(e) }); + }); + return () => { + cancelled = true; + }; + }, [agent, reload]); + + if (error) { + return ( + + ); + } + if (view === null) return …; + + const { configured, effective } = view; + const swarmModels = configured?.subagent_models ?? []; + return ( +
+
    +
  • + {effective ? ( + <> + + + {SOURCE_LABEL[effective.model_source]} + + + ) : ( + + )} +
  • +
  • + {effective ? ( + <> + + + {SOURCE_LABEL[effective.effort_source]} + + + ) : ( + + )} +
  • +
  • + 0 ? swarmModels.join(", ") : "its own"} + /> +
  • +
+ setEditing(true)} + /> + setEditing(false)} + label="edit inference settings" + plain + > + {/* Remounted per open, so a cancelled edit leaves nothing behind. */} + {editing ? ( + setEditing(false)} + onSaved={() => { + setEditing(false); + setReload((n) => n + 1); + }} + /> + ) : null} + +
+ ); +} + +function InferenceForm({ + agent, + configured, + onClose, + onSaved, +}: { + agent: string; + configured: Settings | null; + onClose: () => void; + onSaved: () => void; +}) { + const [model, setModel] = useState(configured?.model ?? ""); + const [effort, setEffort] = useState(configured?.effort ?? ""); + const [subagents, setSubagents] = useState( + (configured?.subagent_models ?? []).join(", "), + ); + const [busy, setBusy] = useState(false); + const [problem, setProblem] = useState(null); + + const subagentList = parseModels(subagents); + const modelProblem = + modelNameProblem(model.trim()) ?? + subagentList.map(modelNameProblem).find((p) => p !== null) ?? + null; + const empty = + model.trim() === "" && effort === "" && subagentList.length === 0; + + async function send(init: RequestInit) { + setBusy(true); + try { + const r = await fetch(inferenceUrl(agent), init); + if (!r.ok) { + setProblem(await readApiError(r)); + return; + } + onSaved(); + } catch (e: unknown) { + setProblem({ detail: String(e) }); + } finally { + setBusy(false); + } + } + + function save(e: Event) { + e.preventDefault(); + const body: Settings = {}; + if (model.trim() !== "") body.model = model.trim(); + if (effort !== "") body.effort = effort; + if (subagentList.length > 0) body.subagent_models = subagentList; + void send({ + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }); + } + + return ( + +

+ Sets what the swarm makes {agent} run on. A field left empty stays the + agent's own value. The agent picks a change up within seconds; a turn + already running finishes on what it started with. +

+
+ + + + {modelProblem ? ( +

{modelProblem}

+ ) : null} + {problem ? ( + + ) : null} +
+ + +
+ +
+ ); +} diff --git a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx index 28faff3f..3328cebd 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx +++ b/frontend/packages/swarm-ui/src/pages/agents/AgentsPage.tsx @@ -34,6 +34,7 @@ import { AgentCard } from "./AgentCard.js"; import { AgentTermPreview } from "./AgentTermPreview.js"; import { SubagentTerms } from "./SubagentTerms.js"; import { FRESHNESS, type AgentRow } from "./AgentTypes.js"; +import { AgentInference } from "./AgentInference.js"; import { LinkedAccounts } from "./LinkedAccounts.js"; import { LinkAccountMenu } from "./LinkAccountMenu.js"; import { Button } from "../../ui/button/Button.js"; @@ -588,6 +589,13 @@ export function AgentsPage() { "—" )} +
inference
+
+ +
accounts
{detailTarget.hive ? (