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.
This commit is contained in:
parent
0f85c93848
commit
0d04bd7295
5 changed files with 373 additions and 10 deletions
|
|
@ -26,7 +26,8 @@ swarmctl agent inference clear iris
|
||||||
running on, each with where it comes from.
|
running on, each with where it comes from.
|
||||||
- `clear` removes every field; the agent goes back to its own values.
|
- `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
|
## What an agent runs on
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -7,15 +7,15 @@ domain, covers host-level detail for one hive.
|
||||||
|
|
||||||
## What it shows
|
## What it shows
|
||||||
|
|
||||||
| route | what |
|
| route | what |
|
||||||
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
| ---------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||||
| `/` | the hive directory, each hive with its last reported status |
|
| `/` | 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` | every agent: status, config PR, wanted state, inference settings; create agents, link forge, matrix and GitHub accounts |
|
||||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||||
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
||||||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||||
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
| `/schedules` | the scheduled prompts: add, edit, pause, resume and remove |
|
||||||
| `/issues` | a cross-repo issue report |
|
| `/issues` | a cross-repo issue report |
|
||||||
|
|
||||||
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
||||||
An agent created here or with `swarmctl agent create` starts `paused`; set it
|
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.
|
||||||
|
|
||||||
</details>
|
</details>
|
||||||
|
|
||||||
|
### 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
|
### 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
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,40 @@
|
||||||
|
/* <AgentInference> — 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;
|
||||||
|
}
|
||||||
301
frontend/packages/swarm-ui/src/pages/agents/AgentInference.tsx
Normal file
301
frontend/packages/swarm-ui/src/pages/agents/AgentInference.tsx
Normal file
|
|
@ -0,0 +1,301 @@
|
||||||
|
// <AgentInference> — 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<Source, string> = {
|
||||||
|
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<View | null>(null);
|
||||||
|
const [error, setError] = useState<ProblemDetails | null>(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<View>;
|
||||||
|
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 (
|
||||||
|
<Badge
|
||||||
|
tone="negative"
|
||||||
|
value="inference unavailable"
|
||||||
|
title={error.detail ?? "reading the inference settings failed"}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (view === null) return <span class="ui-agent-inference-muted">…</span>;
|
||||||
|
|
||||||
|
const { configured, effective } = view;
|
||||||
|
const swarmModels = configured?.subagent_models ?? [];
|
||||||
|
return (
|
||||||
|
<div class="ui-agent-inference">
|
||||||
|
<ul class="ui-agent-inference-list">
|
||||||
|
<li>
|
||||||
|
{effective ? (
|
||||||
|
<>
|
||||||
|
<Badge label="model" value={effective.model} />
|
||||||
|
<span class="ui-agent-inference-source">
|
||||||
|
{SOURCE_LABEL[effective.model_source]}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
label="model"
|
||||||
|
value={configured?.model ?? "its own"}
|
||||||
|
title="the agent has not reported what it runs on yet"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
{effective ? (
|
||||||
|
<>
|
||||||
|
<Badge label="effort" value={effective.effort} />
|
||||||
|
<span class="ui-agent-inference-source">
|
||||||
|
{SOURCE_LABEL[effective.effort_source]}
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<Badge
|
||||||
|
label="effort"
|
||||||
|
value={configured?.effort ?? "its own"}
|
||||||
|
title="the agent has not reported what it runs on yet"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Badge
|
||||||
|
label="subagent models"
|
||||||
|
value={swarmModels.length > 0 ? swarmModels.join(", ") : "its own"}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<Badge
|
||||||
|
variant="quiet"
|
||||||
|
value="edit"
|
||||||
|
title={`set the swarm's model, effort and subagent models for ${agent}`}
|
||||||
|
onClick={() => setEditing(true)}
|
||||||
|
/>
|
||||||
|
<Dialog
|
||||||
|
open={editing}
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
label="edit inference settings"
|
||||||
|
plain
|
||||||
|
>
|
||||||
|
{/* Remounted per open, so a cancelled edit leaves nothing behind. */}
|
||||||
|
{editing ? (
|
||||||
|
<InferenceForm
|
||||||
|
agent={agent}
|
||||||
|
configured={configured}
|
||||||
|
onClose={() => setEditing(false)}
|
||||||
|
onSaved={() => {
|
||||||
|
setEditing(false);
|
||||||
|
setReload((n) => n + 1);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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<ProblemDetails | null>(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 (
|
||||||
|
<Panel title={`inference — ${agent}`} icon="🧠" onClose={onClose}>
|
||||||
|
<p>
|
||||||
|
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.
|
||||||
|
</p>
|
||||||
|
<form class="ui-agent-inference-form" onSubmit={save}>
|
||||||
|
<TextField
|
||||||
|
id="inference-model"
|
||||||
|
label="model"
|
||||||
|
value={model}
|
||||||
|
placeholder="e.g. opus"
|
||||||
|
onInput={setModel}
|
||||||
|
/>
|
||||||
|
<SelectField
|
||||||
|
id="inference-effort"
|
||||||
|
label="effort"
|
||||||
|
value={effort}
|
||||||
|
options={EFFORT_OPTIONS}
|
||||||
|
onChange={setEffort}
|
||||||
|
/>
|
||||||
|
<TextField
|
||||||
|
id="inference-subagent-models"
|
||||||
|
label="subagent models"
|
||||||
|
value={subagents}
|
||||||
|
placeholder="e.g. haiku, sonnet"
|
||||||
|
onInput={setSubagents}
|
||||||
|
/>
|
||||||
|
{modelProblem ? (
|
||||||
|
<p class="ui-agent-inference-problem">{modelProblem}</p>
|
||||||
|
) : null}
|
||||||
|
{problem ? (
|
||||||
|
<ApiErrorPanel context="saving failed" problem={problem} />
|
||||||
|
) : null}
|
||||||
|
<div class="ui-agent-inference-actions">
|
||||||
|
<Button
|
||||||
|
variant="primary"
|
||||||
|
type="submit"
|
||||||
|
disabled={busy || empty || modelProblem !== null}
|
||||||
|
>
|
||||||
|
{busy ? "saving…" : "save"}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
disabled={busy || configured === null}
|
||||||
|
onClick={() => void send({ method: "DELETE" })}
|
||||||
|
>
|
||||||
|
clear the swarm's setting
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Panel>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ import { AgentCard } from "./AgentCard.js";
|
||||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||||
import { SubagentTerms } from "./SubagentTerms.js";
|
import { SubagentTerms } from "./SubagentTerms.js";
|
||||||
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
||||||
|
import { AgentInference } from "./AgentInference.js";
|
||||||
import { LinkedAccounts } from "./LinkedAccounts.js";
|
import { LinkedAccounts } from "./LinkedAccounts.js";
|
||||||
import { LinkAccountMenu } from "./LinkAccountMenu.js";
|
import { LinkAccountMenu } from "./LinkAccountMenu.js";
|
||||||
import { Button } from "../../ui/button/Button.js";
|
import { Button } from "../../ui/button/Button.js";
|
||||||
|
|
@ -588,6 +589,13 @@ export function AgentsPage() {
|
||||||
"—"
|
"—"
|
||||||
)}
|
)}
|
||||||
</dd>
|
</dd>
|
||||||
|
<dt>inference</dt>
|
||||||
|
<dd>
|
||||||
|
<AgentInference
|
||||||
|
key={detailTarget.name}
|
||||||
|
agent={detailTarget.name}
|
||||||
|
/>
|
||||||
|
</dd>
|
||||||
<dt>accounts</dt>
|
<dt>accounts</dt>
|
||||||
<dd>
|
<dd>
|
||||||
{detailTarget.hive ? (
|
{detailTarget.hive ? (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue