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 ? (