Inference, resource caps and linked accounts showed a failed read as a badge with the reason in a hover title, which keyboard and touch users cannot read. They now use ApiErrorPanel, and a list that already loaded stays on screen under a failed refetch.
384 lines
12 KiB
TypeScript
384 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";
|
|
import { MAX_SUBAGENTS, maxSubagentsValue } from "./max-subagents.js";
|
|
|
|
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;
|
|
}
|
|
|
|
// 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 0 (no subagents) 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]);
|
|
|
|
// Shown as text, not a hover title. Settings already loaded stay under a
|
|
// failed refetch, so a save that worked is not hidden by a read that did not.
|
|
const errorPanel = error ? (
|
|
<ApiErrorPanel
|
|
context="reading the inference settings failed"
|
|
problem={error}
|
|
/>
|
|
) : null;
|
|
if (view === null) {
|
|
return errorPanel ?? <span class="ui-agent-inference-muted">…</span>;
|
|
}
|
|
|
|
const { configured, effective } = view;
|
|
const swarmModels = configured?.subagent_models ?? [];
|
|
return (
|
|
<div class="ui-agent-inference">
|
|
{errorPanel}
|
|
<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={
|
|
configured?.max_subagents === 0
|
|
? "0 (no subagents)"
|
|
: 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>
|
|
);
|
|
}
|