Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx
iris e727774dc8 swarm-ui: show agent panel read failures as text and keep loaded data
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.
2026-10-21 22:57:51 +02:00

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>
);
}