create_agent puts back the caps and inference settings it replaced if a later store or the job insert fails, so a refused creation leaves nothing behind for the name. The shared field components take an id prefix so the create dialog and an open editor never share a DOM id. The queue check now follows the hive roster check, the 422 is documented, and the copy says empty rather than closed and that destroy keeps stored settings.
389 lines
12 KiB
TypeScript
389 lines
12 KiB
TypeScript
// <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[];
|
|
max_subagents?: number;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// The highest `max_subagents` the swarm accepts.
|
|
const MAX_SUBAGENTS = 32;
|
|
|
|
// `max_subagents` as typed: empty is no cap, else a whole number in range.
|
|
function maxSubagentsValue(text: string): number | null | undefined {
|
|
const t = text.trim();
|
|
if (t === "") return undefined;
|
|
if (!/^\d+$/.test(t)) return null;
|
|
const n = Number(t);
|
|
return n >= 1 && n <= MAX_SUBAGENTS ? n : 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`;
|
|
}
|
|
|
|
// The four inference boxes as typed. Shared by the editor here and the
|
|
// create-agent form, which send what `draftBody` makes of them.
|
|
export interface InferenceDraft {
|
|
model: string;
|
|
effort: string;
|
|
subagents: string;
|
|
maxSubagents: string;
|
|
}
|
|
|
|
export const EMPTY_INFERENCE_DRAFT: InferenceDraft = {
|
|
model: "",
|
|
effort: "",
|
|
subagents: "",
|
|
maxSubagents: "",
|
|
};
|
|
|
|
// Why the draft would be refused, or null when it is fine to send.
|
|
export function draftProblem(d: InferenceDraft): string | null {
|
|
return (
|
|
modelNameProblem(d.model.trim()) ??
|
|
parseModels(d.subagents)
|
|
.map(modelNameProblem)
|
|
.find((p) => p !== null) ??
|
|
(maxSubagentsValue(d.maxSubagents) === null
|
|
? `max subagents is a whole number from 1 to ${MAX_SUBAGENTS}, or empty for no cap`
|
|
: null)
|
|
);
|
|
}
|
|
|
|
export function draftIsEmpty(d: InferenceDraft): boolean {
|
|
return (
|
|
d.model.trim() === "" &&
|
|
d.effort === "" &&
|
|
parseModels(d.subagents).length === 0 &&
|
|
d.maxSubagents.trim() === ""
|
|
);
|
|
}
|
|
|
|
// Only the filled fields: an empty one leaves the agent on its own value.
|
|
export function draftBody(d: InferenceDraft): Settings {
|
|
const body: Settings = {};
|
|
if (d.model.trim() !== "") body.model = d.model.trim();
|
|
if (d.effort !== "") body.effort = d.effort;
|
|
const models = parseModels(d.subagents);
|
|
if (models.length > 0) body.subagent_models = models;
|
|
const max = maxSubagentsValue(d.maxSubagents);
|
|
if (typeof max === "number") body.max_subagents = max;
|
|
return body;
|
|
}
|
|
|
|
export function InferenceFields({
|
|
draft,
|
|
onChange,
|
|
idPrefix = "inference",
|
|
}: {
|
|
draft: InferenceDraft;
|
|
onChange: (d: InferenceDraft) => void;
|
|
// Ids must be unique on the page, and the agents page can hold the editor
|
|
// and the create dialog at once.
|
|
idPrefix?: string;
|
|
}) {
|
|
const problem = draftProblem(draft);
|
|
return (
|
|
<>
|
|
<TextField
|
|
id={`${idPrefix}-model`}
|
|
label="model"
|
|
value={draft.model}
|
|
placeholder="e.g. opus"
|
|
onInput={(model) => onChange({ ...draft, model })}
|
|
/>
|
|
<SelectField
|
|
id={`${idPrefix}-effort`}
|
|
label="effort"
|
|
value={draft.effort}
|
|
options={EFFORT_OPTIONS}
|
|
onChange={(effort) => onChange({ ...draft, effort })}
|
|
/>
|
|
<TextField
|
|
id={`${idPrefix}-subagent-models`}
|
|
label="subagent models"
|
|
value={draft.subagents}
|
|
placeholder="e.g. haiku, sonnet"
|
|
onInput={(subagents) => onChange({ ...draft, subagents })}
|
|
/>
|
|
<TextField
|
|
id={`${idPrefix}-max-subagents`}
|
|
label="max subagents"
|
|
value={draft.maxSubagents}
|
|
placeholder="empty for no cap"
|
|
pattern="[0-9]*"
|
|
onInput={(maxSubagents) => onChange({ ...draft, maxSubagents })}
|
|
/>
|
|
{problem ? <p class="ui-agent-inference-problem">{problem}</p> : null}
|
|
</>
|
|
);
|
|
}
|
|
|
|
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>
|
|
<li>
|
|
<Badge
|
|
label="max subagents"
|
|
value={String(configured?.max_subagents ?? "no cap")}
|
|
/>
|
|
</li>
|
|
</ul>
|
|
<Badge
|
|
variant="quiet"
|
|
value="edit"
|
|
title={`set the swarm's model, effort, subagent models and subagent cap 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 [draft, setDraft] = useState<InferenceDraft>({
|
|
model: configured?.model ?? "",
|
|
effort: configured?.effort ?? "",
|
|
subagents: (configured?.subagent_models ?? []).join(", "),
|
|
maxSubagents: configured?.max_subagents?.toString() ?? "",
|
|
});
|
|
const [busy, setBusy] = useState(false);
|
|
const [problem, setProblem] = useState<ProblemDetails | null>(null);
|
|
|
|
const formProblem = draftProblem(draft);
|
|
const empty = draftIsEmpty(draft);
|
|
|
|
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();
|
|
void send({
|
|
method: "PUT",
|
|
headers: { "content-type": "application/json" },
|
|
body: JSON.stringify(draftBody(draft)),
|
|
});
|
|
}
|
|
|
|
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; an empty max subagents is no cap. The agent picks a
|
|
change up within seconds; a turn already running finishes on what it
|
|
started with, and subagents already running past a lowered cap keep
|
|
running.
|
|
</p>
|
|
<form class="ui-agent-inference-form" onSubmit={save}>
|
|
<InferenceFields draft={draft} onChange={setDraft} />
|
|
{problem ? (
|
|
<ApiErrorPanel context="saving failed" problem={problem} />
|
|
) : null}
|
|
<div class="ui-agent-inference-actions">
|
|
<Button
|
|
variant="primary"
|
|
type="submit"
|
|
disabled={busy || empty || formProblem !== null}
|
|
>
|
|
{busy ? "saving…" : "save"}
|
|
</Button>
|
|
<Button
|
|
disabled={busy || configured === null}
|
|
onClick={() => void send({ method: "DELETE" })}
|
|
>
|
|
clear the swarm's setting
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
</Panel>
|
|
);
|
|
}
|