Set resource caps and inference when creating an agent
POST /api/agents takes optional limits and inference objects, checked by the same rules as the two PUT routes before anything is queued and stored ahead of the job graph. The create form gets collapsed sections for both, reusing the fields of the detail-panel editors.
This commit is contained in:
parent
aaac754356
commit
e36a15de91
8 changed files with 389 additions and 100 deletions
|
|
@ -88,6 +88,100 @@ 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,
|
||||
}: {
|
||||
draft: InferenceDraft;
|
||||
onChange: (d: InferenceDraft) => void;
|
||||
}) {
|
||||
const problem = draftProblem(draft);
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
id="inference-model"
|
||||
label="model"
|
||||
value={draft.model}
|
||||
placeholder="e.g. opus"
|
||||
onInput={(model) => onChange({ ...draft, model })}
|
||||
/>
|
||||
<SelectField
|
||||
id="inference-effort"
|
||||
label="effort"
|
||||
value={draft.effort}
|
||||
options={EFFORT_OPTIONS}
|
||||
onChange={(effort) => onChange({ ...draft, effort })}
|
||||
/>
|
||||
<TextField
|
||||
id="inference-subagent-models"
|
||||
label="subagent models"
|
||||
value={draft.subagents}
|
||||
placeholder="e.g. haiku, sonnet"
|
||||
onInput={(subagents) => onChange({ ...draft, subagents })}
|
||||
/>
|
||||
<TextField
|
||||
id="inference-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);
|
||||
|
|
@ -219,29 +313,17 @@ function InferenceForm({
|
|||
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 [maxSubagents, setMaxSubagents] = useState(
|
||||
configured?.max_subagents?.toString() ?? "",
|
||||
);
|
||||
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 subagentList = parseModels(subagents);
|
||||
const formProblem =
|
||||
modelNameProblem(model.trim()) ??
|
||||
subagentList.map(modelNameProblem).find((p) => p !== null) ??
|
||||
(maxSubagentsValue(maxSubagents) === null
|
||||
? `max subagents is a whole number from 1 to ${MAX_SUBAGENTS}, or empty for no cap`
|
||||
: null);
|
||||
const empty =
|
||||
model.trim() === "" &&
|
||||
effort === "" &&
|
||||
subagentList.length === 0 &&
|
||||
maxSubagents.trim() === "";
|
||||
const formProblem = draftProblem(draft);
|
||||
const empty = draftIsEmpty(draft);
|
||||
|
||||
async function send(init: RequestInit) {
|
||||
setBusy(true);
|
||||
|
|
@ -261,16 +343,10 @@ function InferenceForm({
|
|||
|
||||
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;
|
||||
const max = maxSubagentsValue(maxSubagents);
|
||||
if (typeof max === "number") body.max_subagents = max;
|
||||
void send({
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(draftBody(draft)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -284,38 +360,7 @@ function InferenceForm({
|
|||
running.
|
||||
</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}
|
||||
/>
|
||||
<TextField
|
||||
id="inference-max-subagents"
|
||||
label="max subagents"
|
||||
value={maxSubagents}
|
||||
placeholder="empty for no cap"
|
||||
pattern="[0-9]*"
|
||||
onInput={setMaxSubagents}
|
||||
/>
|
||||
{formProblem ? (
|
||||
<p class="ui-agent-inference-problem">{formProblem}</p>
|
||||
) : null}
|
||||
<InferenceFields draft={draft} onChange={setDraft} />
|
||||
{problem ? (
|
||||
<ApiErrorPanel context="saving failed" problem={problem} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,55 @@ function limitsUrl(agent: string): string {
|
|||
return `/api/agents/${encodeURIComponent(agent)}/limits`;
|
||||
}
|
||||
|
||||
// The caps as typed, as the body the route takes: only the filled fields, so
|
||||
// an empty box leaves that field to the hive.
|
||||
export function capsBody(cpu: string, memory: string): Caps {
|
||||
const body: Caps = {};
|
||||
if (cpu.trim() !== "") body.cpu_quota = cpu.trim();
|
||||
if (memory.trim() !== "") body.memory_max = memory.trim();
|
||||
return body;
|
||||
}
|
||||
|
||||
// The two cap boxes with their hints, shared by the editor here and the
|
||||
// create-agent form.
|
||||
export function LimitsFields({
|
||||
cpu,
|
||||
memory,
|
||||
onCpu,
|
||||
onMemory,
|
||||
}: {
|
||||
cpu: string;
|
||||
memory: string;
|
||||
onCpu: (v: string) => void;
|
||||
onMemory: (v: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<>
|
||||
<TextField
|
||||
id="limits-cpu-quota"
|
||||
label="cpu quota"
|
||||
value={cpu}
|
||||
placeholder="e.g. 400%"
|
||||
onInput={onCpu}
|
||||
/>
|
||||
<p class="ui-agent-limits-hint">
|
||||
A percentage of one core: 100% is one core, 400% is four.
|
||||
</p>
|
||||
<TextField
|
||||
id="limits-memory-max"
|
||||
label="memory max"
|
||||
value={memory}
|
||||
placeholder="e.g. 8G"
|
||||
onInput={onMemory}
|
||||
/>
|
||||
<p class="ui-agent-limits-hint">
|
||||
A size such as 512M or 8G, a percentage of the machine's memory such as
|
||||
50%, or infinity.
|
||||
</p>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentLimits({ agent }: { agent: string }) {
|
||||
const [caps, setCaps] = useState<Caps | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
|
|
@ -152,13 +201,10 @@ function LimitsForm({
|
|||
e.preventDefault();
|
||||
// Only the filled fields: a PUT replaces the stored value whole, so an
|
||||
// empty box is cleared and the hive decides that field again.
|
||||
const body: Caps = {};
|
||||
if (cpu.trim() !== "") body.cpu_quota = cpu.trim();
|
||||
if (memory.trim() !== "") body.memory_max = memory.trim();
|
||||
void send({
|
||||
method: "PUT",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
body: JSON.stringify(capsBody(cpu, memory)),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -171,27 +217,12 @@ function LimitsForm({
|
|||
agent takes it at its next spawn or rebuild.
|
||||
</p>
|
||||
<form class="ui-agent-limits-form" onSubmit={save}>
|
||||
<TextField
|
||||
id="limits-cpu-quota"
|
||||
label="cpu quota"
|
||||
value={cpu}
|
||||
placeholder="e.g. 400%"
|
||||
onInput={setCpu}
|
||||
<LimitsFields
|
||||
cpu={cpu}
|
||||
memory={memory}
|
||||
onCpu={setCpu}
|
||||
onMemory={setMemory}
|
||||
/>
|
||||
<p class="ui-agent-limits-hint">
|
||||
A percentage of one core: 100% is one core, 400% is four.
|
||||
</p>
|
||||
<TextField
|
||||
id="limits-memory-max"
|
||||
label="memory max"
|
||||
value={memory}
|
||||
placeholder="e.g. 8G"
|
||||
onInput={setMemory}
|
||||
/>
|
||||
<p class="ui-agent-limits-hint">
|
||||
A size such as 512M or 8G, a percentage of the machine's memory such
|
||||
as 50%, or infinity.
|
||||
</p>
|
||||
{problem ? (
|
||||
<ApiErrorPanel context="the caps were not saved" problem={problem} />
|
||||
) : null}
|
||||
|
|
|
|||
|
|
@ -48,6 +48,21 @@
|
|||
align-items: flex-start;
|
||||
gap: 0.75em;
|
||||
}
|
||||
/* Optional settings, collapsed so the plain name-and-hive form stays short. */
|
||||
.create-agent-section {
|
||||
align-self: stretch;
|
||||
}
|
||||
.create-agent-section > summary {
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
}
|
||||
.create-agent-section-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.75em;
|
||||
margin-top: 0.75em;
|
||||
}
|
||||
.create-agent-result {
|
||||
margin-top: 1em;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -36,6 +36,14 @@ import {
|
|||
type SelectOption,
|
||||
} from "../ui/select-field/SelectField.js";
|
||||
import { Button } from "../ui/button/Button.js";
|
||||
import { capsBody, LimitsFields } from "../agent/detail/AgentLimits.js";
|
||||
import {
|
||||
draftBody,
|
||||
draftProblem,
|
||||
EMPTY_INFERENCE_DRAFT,
|
||||
InferenceFields,
|
||||
type InferenceDraft,
|
||||
} from "../agent/detail/AgentInference.js";
|
||||
import "./CreateAgentForm.css";
|
||||
|
||||
// Mirrors swarm-controller's `HiveEntry` — same shape `HivesPage`
|
||||
|
|
@ -88,6 +96,13 @@ export function CreateAgentForm({
|
|||
const [hives, setHives] = useState<HiveEntry[] | null>(null);
|
||||
const [hivesError, setHivesError] = useState<ProblemDetails | null>(null);
|
||||
const [result, setResult] = useState<SubmitState>({ status: "idle" });
|
||||
// The optional settings sections, left empty unless the operator fills them.
|
||||
const [cpu, setCpu] = useState("");
|
||||
const [memory, setMemory] = useState("");
|
||||
const [inference, setInference] = useState<InferenceDraft>(
|
||||
EMPTY_INFERENCE_DRAFT,
|
||||
);
|
||||
const inferenceProblem = draftProblem(inference);
|
||||
|
||||
// Loaded once, not re-fetched on submit: the roster changes rarely
|
||||
// enough (a nix-level swarm config change) that staleness within one
|
||||
|
|
@ -119,10 +134,19 @@ export function CreateAgentForm({
|
|||
e.preventDefault();
|
||||
setResult({ status: "submitting" });
|
||||
try {
|
||||
// A section left empty is left out, so the request is the plain
|
||||
// name-and-hive one unless the operator set something.
|
||||
const limits = capsBody(cpu, memory);
|
||||
const settings = draftBody(inference);
|
||||
const r = await fetch("/api/agents", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name, hive }),
|
||||
body: JSON.stringify({
|
||||
name,
|
||||
hive,
|
||||
...(Object.keys(limits).length > 0 ? { limits } : {}),
|
||||
...(Object.keys(settings).length > 0 ? { inference: settings } : {}),
|
||||
}),
|
||||
});
|
||||
if (!r.ok) {
|
||||
setResult({ status: "error", problem: await readApiError(r) });
|
||||
|
|
@ -142,7 +166,9 @@ export function CreateAgentForm({
|
|||
<Panel title="create agent" icon="🤖" onClose={onClose}>
|
||||
<p class="create-agent-intro">
|
||||
Create a new agent's swarm-level identity. This only queues the job
|
||||
— check <Link href="/jobs">jobs</Link> to watch it settle.
|
||||
— check <Link href="/jobs">jobs</Link> to watch it settle. Resource
|
||||
caps and inference are optional; leave them closed to keep the
|
||||
defaults.
|
||||
</p>
|
||||
{hivesError && (
|
||||
<ApiErrorPanel
|
||||
|
|
@ -176,10 +202,31 @@ export function CreateAgentForm({
|
|||
: "select a hive"
|
||||
}
|
||||
/>
|
||||
<details class="create-agent-section">
|
||||
<summary>resource caps</summary>
|
||||
<div class="create-agent-section-body">
|
||||
<LimitsFields
|
||||
cpu={cpu}
|
||||
memory={memory}
|
||||
onCpu={setCpu}
|
||||
onMemory={setMemory}
|
||||
/>
|
||||
</div>
|
||||
</details>
|
||||
<details class="create-agent-section">
|
||||
<summary>inference</summary>
|
||||
<div class="create-agent-section-body">
|
||||
<InferenceFields draft={inference} onChange={setInference} />
|
||||
</div>
|
||||
</details>
|
||||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={result.status === "submitting" || !hivesReady}
|
||||
disabled={
|
||||
result.status === "submitting" ||
|
||||
!hivesReady ||
|
||||
inferenceProblem !== null
|
||||
}
|
||||
>
|
||||
{result.status === "submitting" ? "creating…" : "create"}
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue