Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-13 19:24:13 +02:00 • committed by mara
commit 9a71d1e094
10 changed files with 393 additions and 94 deletions

View file

@ -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>