swarm-ui: per-agent cap on concurrently running subagents
Nothing capped how many subagents an agent runs at once; the operator wants to set that per agent from swarm-ui (#5234, shape (b)). AgentInference gains `max_subagents: Option<u32>`. An absent field decodes as None, so every stored value still parses and means "no cap", which is today's behaviour. swarm-controller accepts 1..=32 on PUT. 0 would refuse every start; 32 is far more concurrent claude sessions than one agent container has memory for, so anything above it is a typo, not a cap. The subagent daemon already read the agent's agent-inference key for subagent_models; it now reads it on every start and continue (not only those naming a model) and refuses a new run once the cap's worth of names is tracked, counted under the same lock as the name reservation. Every tracked slot counts: running, starting, a goal run between turns, and an interrupted one still exiting. A goal re-prompt never goes through the reservation, so it is never refused. A failed read applies no cap, as it falls back for models. swarmctl's `inference set` does GET, merge, PUT; its mirror type now carries the field so a set from the CLI keeps a cap set in swarm-ui instead of dropping it, and `get` prints it.
This commit is contained in:
parent
6d7fc0dd99
commit
9a71d1e094
10 changed files with 393 additions and 94 deletions
|
|
@ -29,6 +29,7 @@ interface Settings {
|
|||
model?: string;
|
||||
effort?: string;
|
||||
subagent_models?: string[];
|
||||
max_subagents?: number;
|
||||
}
|
||||
|
||||
// Mirrors `EffectiveInference`.
|
||||
|
|
@ -66,6 +67,18 @@ function modelNameProblem(name: string): string | null {
|
|||
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 !== "");
|
||||
|
|
@ -159,11 +172,17 @@ export function AgentInference({ agent }: { agent: string }) {
|
|||
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 and subagent models for ${agent}`}
|
||||
title={`set the swarm's model, effort, subagent models and subagent cap for ${agent}`}
|
||||
onClick={() => setEditing(true)}
|
||||
/>
|
||||
<Dialog
|
||||
|
|
@ -205,16 +224,24 @@ function InferenceForm({
|
|||
const [subagents, setSubagents] = useState(
|
||||
(configured?.subagent_models ?? []).join(", "),
|
||||
);
|
||||
const [maxSubagents, setMaxSubagents] = useState(
|
||||
configured?.max_subagents?.toString() ?? "",
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [problem, setProblem] = useState<ProblemDetails | null>(null);
|
||||
|
||||
const subagentList = parseModels(subagents);
|
||||
const modelProblem =
|
||||
const formProblem =
|
||||
modelNameProblem(model.trim()) ??
|
||||
subagentList.map(modelNameProblem).find((p) => p !== null) ??
|
||||
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;
|
||||
model.trim() === "" &&
|
||||
effort === "" &&
|
||||
subagentList.length === 0 &&
|
||||
maxSubagents.trim() === "";
|
||||
|
||||
async function send(init: RequestInit) {
|
||||
setBusy(true);
|
||||
|
|
@ -238,6 +265,8 @@ function InferenceForm({
|
|||
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" },
|
||||
|
|
@ -249,8 +278,10 @@ function InferenceForm({
|
|||
<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.
|
||||
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}>
|
||||
<TextField
|
||||
|
|
@ -274,8 +305,15 @@ function InferenceForm({
|
|||
placeholder="e.g. haiku, sonnet"
|
||||
onInput={setSubagents}
|
||||
/>
|
||||
{modelProblem ? (
|
||||
<p class="ui-agent-inference-problem">{modelProblem}</p>
|
||||
<TextField
|
||||
id="inference-max-subagents"
|
||||
label="max subagents"
|
||||
value={maxSubagents}
|
||||
placeholder="empty for no cap"
|
||||
onInput={setMaxSubagents}
|
||||
/>
|
||||
{formProblem ? (
|
||||
<p class="ui-agent-inference-problem">{formProblem}</p>
|
||||
) : null}
|
||||
{problem ? (
|
||||
<ApiErrorPanel context="saving failed" problem={problem} />
|
||||
|
|
@ -284,7 +322,7 @@ function InferenceForm({
|
|||
<Button
|
||||
variant="primary"
|
||||
type="submit"
|
||||
disabled={busy || empty || modelProblem !== null}
|
||||
disabled={busy || empty || formProblem !== null}
|
||||
>
|
||||
{busy ? "saving…" : "save"}
|
||||
</Button>
|
||||
|
|
|
|||
Loading…
Reference in a new issue