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
|
|
@ -1,7 +1,8 @@
|
||||||
# Per-agent inference settings
|
or a model name with whitespace or a leading `-`, or a `max_subagents` outside 1 to 32 |# Per-agent inference settings
|
||||||
|
|
||||||
The swarm can set the model and reasoning effort each agent's turns run on,
|
The swarm can set the model and reasoning effort each agent's turns run on,
|
||||||
plus the models it may start subagents on. A field you set wins over the
|
plus the models it may start subagents on and how many subagents it may run
|
||||||
|
at once. A field you set wins over the
|
||||||
agent's own value; a field you leave unset keeps the agent on its own value,
|
agent's own value; a field you leave unset keeps the agent on its own value,
|
||||||
so an agent you set nothing for runs as it does today.
|
so an agent you set nothing for runs as it does today.
|
||||||
|
|
||||||
|
|
@ -20,6 +21,10 @@ swarmctl agent inference clear iris
|
||||||
agent's `availableModels` as the models its subagent `start` and
|
agent's `availableModels` as the models its subagent `start` and
|
||||||
`continue` calls may name, read on each call
|
`continue` calls may name, read on each call
|
||||||
([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)).
|
([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)).
|
||||||
|
- You set the subagent cap, `max_subagents`, in the swarm UI's inference
|
||||||
|
dialog; `set` has no flag for it and keeps the stored one. It's a whole
|
||||||
|
number from 1 to 32; unset means no cap
|
||||||
|
([`subagent.md`](../tools/subagent.md#how-many-subagents-may-run-at-once)).
|
||||||
- `get` shows each field, naming the unset ones (or says the swarm sets
|
- `get` shows each field, naming the unset ones (or says the swarm sets
|
||||||
nothing for the agent), and then the model and effort the agent reports
|
nothing for the agent), and then the model and effort the agent reports
|
||||||
running on, each with where it comes from.
|
running on, each with where it comes from.
|
||||||
|
|
@ -39,6 +44,7 @@ command.
|
||||||
model: opus
|
model: opus
|
||||||
effort: (not set; the agent's own)
|
effort: (not set; the agent's own)
|
||||||
subagent_models: (not set; the agent's own)
|
subagent_models: (not set; the agent's own)
|
||||||
|
max_subagents: (not set; no cap)
|
||||||
running model: opus (swarm)
|
running model: opus (swarm)
|
||||||
running effort: medium (local_file)
|
running effort: medium (local_file)
|
||||||
```
|
```
|
||||||
|
|
@ -83,7 +89,12 @@ The settings live in the `agent-inference` KV bucket, one key per agent,
|
||||||
which swarm-controller alone writes:
|
which swarm-controller alone writes:
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{ "model": "opus", "effort": "high", "subagent_models": ["haiku", "sonnet"] }
|
{
|
||||||
|
"model": "opus",
|
||||||
|
"effort": "high",
|
||||||
|
"subagent_models": ["haiku", "sonnet"],
|
||||||
|
"max_subagents": 3
|
||||||
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Every field is optional. The agent runs the same with `{}` as with no key;
|
Every field is optional. The agent runs the same with `{}` as with no key;
|
||||||
|
|
@ -108,8 +119,8 @@ in-memory model or effort without writing `hyperhive-model` or
|
||||||
`hyperhive-effort`, which keep the agent's own value.
|
`hyperhive-effort`, which keep the agent's own value.
|
||||||
|
|
||||||
The subagent daemon (`hive-subagent-mcp`'s `swarm_models`) reads
|
The subagent daemon (`hive-subagent-mcp`'s `swarm_models`) reads
|
||||||
`subagent_models` with the same direct get, under the same credential, on
|
`subagent_models` and `max_subagents` with the same direct get, under the
|
||||||
each `start` or `continue` that names a model.
|
same credential, on each `start` or `continue`.
|
||||||
|
|
||||||
`effective` comes from the agent's turn-state header on
|
`effective` comes from the agent's turn-state header on
|
||||||
`$SWARM.agent-state.<agent>` ([`README.md`](README.md)), which carries
|
`$SWARM.agent-state.<agent>` ([`README.md`](README.md)), which carries
|
||||||
|
|
|
||||||
|
|
@ -221,11 +221,12 @@ live rows only.
|
||||||
|
|
||||||
The agent's detail panel shows its model and effort under **inference**, each
|
The agent's detail panel shows its model and effort under **inference**, each
|
||||||
with where it comes from: the swarm, its agent.nix, its own pick, or the
|
with where it comes from: the swarm, its agent.nix, its own pick, or the
|
||||||
default. Below them is the subagent model list the swarm sets, or "its own"
|
default. Below them are the subagent model list the swarm sets, or "its own"
|
||||||
when it sets none. **edit** opens a dialog with the three fields, filled with
|
when it sets none, and the subagent cap, which reads "no cap" when unset.
|
||||||
what the swarm stores; saving does a `PUT /api/agents/{agent}/inference` of
|
**edit** opens a dialog with the four fields, filled with what the swarm
|
||||||
|
stores; saving does a `PUT /api/agents/{agent}/inference` of
|
||||||
the whole value. A field you leave empty stays out of it, so the agent keeps
|
the whole value. A field you leave empty stays out of it, so the agent keeps
|
||||||
its own value for that field. **clear the swarm's setting** sends the `DELETE`.
|
its own value for that field; an empty max subagents is no cap. **clear the swarm's setting** sends the `DELETE`.
|
||||||
The panel reads `GET /api/agents/{agent}/inference`; before the agent's first
|
The panel reads `GET /api/agents/{agent}/inference`; before the agent's first
|
||||||
report reaches the controller, it shows the swarm's stored values only.
|
report reaches the controller, it shows the swarm's stored values only.
|
||||||
[inference](inference.md) describes the settings themselves.
|
[inference](inference.md) describes the settings themselves.
|
||||||
|
|
|
||||||
|
|
@ -337,6 +337,26 @@ happens until the daemon's first connection to the swarm queue succeeds.
|
||||||
The match ignores case. On an ACP agent the names are the agent's own, such
|
The match ignores case. On an ACP agent the names are the agent's own, such
|
||||||
as `<provider>/<model>` on `opencode`.
|
as `<provider>/<model>` on `opencode`.
|
||||||
|
|
||||||
|
## How many subagents may run at once
|
||||||
|
|
||||||
|
When the swarm sets `max_subagents` for the agent
|
||||||
|
([`inference.md`](../swarm/inference.md)), the daemon refuses a `start`, or a
|
||||||
|
`continue` of a session that isn't running, once that many subagents are
|
||||||
|
running, before anything spawns:
|
||||||
|
|
||||||
|
```text
|
||||||
|
3 subagents running, cap is 3 (set in swarm-ui → this agent → inference) — wait for one to finish, or `interrupt` one
|
||||||
|
```
|
||||||
|
|
||||||
|
Every subagent the daemon tracks counts, whatever its role: one running a
|
||||||
|
turn, one starting, a goal run between turns, and an interrupted one whose
|
||||||
|
process hasn't exited yet. A goal run's next turn isn't a new start, and the
|
||||||
|
cap never stops it. Lowering the cap stops nothing already running.
|
||||||
|
|
||||||
|
The daemon reads the cap with the model list, on every `start` and
|
||||||
|
`continue`, so a change applies to the next call. Without a swarm value, or
|
||||||
|
when the read fails, no cap applies.
|
||||||
|
|
||||||
## Runtime
|
## Runtime
|
||||||
|
|
||||||
A subagent runs on its parent agent's runtime
|
A subagent runs on its parent agent's runtime
|
||||||
|
|
|
||||||
|
|
@ -29,6 +29,7 @@ interface Settings {
|
||||||
model?: string;
|
model?: string;
|
||||||
effort?: string;
|
effort?: string;
|
||||||
subagent_models?: string[];
|
subagent_models?: string[];
|
||||||
|
max_subagents?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mirrors `EffectiveInference`.
|
// Mirrors `EffectiveInference`.
|
||||||
|
|
@ -66,6 +67,18 @@ function modelNameProblem(name: string): string | null {
|
||||||
return 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.
|
// Subagent models are typed as one list, split on commas and whitespace.
|
||||||
function parseModels(text: string): string[] {
|
function parseModels(text: string): string[] {
|
||||||
return text.split(/[\s,]+/).filter((m) => m !== "");
|
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"}
|
value={swarmModels.length > 0 ? swarmModels.join(", ") : "its own"}
|
||||||
/>
|
/>
|
||||||
</li>
|
</li>
|
||||||
|
<li>
|
||||||
|
<Badge
|
||||||
|
label="max subagents"
|
||||||
|
value={String(configured?.max_subagents ?? "no cap")}
|
||||||
|
/>
|
||||||
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
<Badge
|
<Badge
|
||||||
variant="quiet"
|
variant="quiet"
|
||||||
value="edit"
|
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)}
|
onClick={() => setEditing(true)}
|
||||||
/>
|
/>
|
||||||
<Dialog
|
<Dialog
|
||||||
|
|
@ -205,16 +224,24 @@ function InferenceForm({
|
||||||
const [subagents, setSubagents] = useState(
|
const [subagents, setSubagents] = useState(
|
||||||
(configured?.subagent_models ?? []).join(", "),
|
(configured?.subagent_models ?? []).join(", "),
|
||||||
);
|
);
|
||||||
|
const [maxSubagents, setMaxSubagents] = useState(
|
||||||
|
configured?.max_subagents?.toString() ?? "",
|
||||||
|
);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
const [problem, setProblem] = useState<ProblemDetails | null>(null);
|
const [problem, setProblem] = useState<ProblemDetails | null>(null);
|
||||||
|
|
||||||
const subagentList = parseModels(subagents);
|
const subagentList = parseModels(subagents);
|
||||||
const modelProblem =
|
const formProblem =
|
||||||
modelNameProblem(model.trim()) ??
|
modelNameProblem(model.trim()) ??
|
||||||
subagentList.map(modelNameProblem).find((p) => p !== null) ??
|
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 =
|
const empty =
|
||||||
model.trim() === "" && effort === "" && subagentList.length === 0;
|
model.trim() === "" &&
|
||||||
|
effort === "" &&
|
||||||
|
subagentList.length === 0 &&
|
||||||
|
maxSubagents.trim() === "";
|
||||||
|
|
||||||
async function send(init: RequestInit) {
|
async function send(init: RequestInit) {
|
||||||
setBusy(true);
|
setBusy(true);
|
||||||
|
|
@ -238,6 +265,8 @@ function InferenceForm({
|
||||||
if (model.trim() !== "") body.model = model.trim();
|
if (model.trim() !== "") body.model = model.trim();
|
||||||
if (effort !== "") body.effort = effort;
|
if (effort !== "") body.effort = effort;
|
||||||
if (subagentList.length > 0) body.subagent_models = subagentList;
|
if (subagentList.length > 0) body.subagent_models = subagentList;
|
||||||
|
const max = maxSubagentsValue(maxSubagents);
|
||||||
|
if (typeof max === "number") body.max_subagents = max;
|
||||||
void send({
|
void send({
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "content-type": "application/json" },
|
headers: { "content-type": "application/json" },
|
||||||
|
|
@ -249,8 +278,10 @@ function InferenceForm({
|
||||||
<Panel title={`inference — ${agent}`} icon="🧠" onClose={onClose}>
|
<Panel title={`inference — ${agent}`} icon="🧠" onClose={onClose}>
|
||||||
<p>
|
<p>
|
||||||
Sets what the swarm makes {agent} run on. A field left empty stays the
|
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
|
agent's own value; an empty max subagents is no cap. The agent picks a
|
||||||
already running finishes on what it started with.
|
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>
|
</p>
|
||||||
<form class="ui-agent-inference-form" onSubmit={save}>
|
<form class="ui-agent-inference-form" onSubmit={save}>
|
||||||
<TextField
|
<TextField
|
||||||
|
|
@ -274,8 +305,15 @@ function InferenceForm({
|
||||||
placeholder="e.g. haiku, sonnet"
|
placeholder="e.g. haiku, sonnet"
|
||||||
onInput={setSubagents}
|
onInput={setSubagents}
|
||||||
/>
|
/>
|
||||||
{modelProblem ? (
|
<TextField
|
||||||
<p class="ui-agent-inference-problem">{modelProblem}</p>
|
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}
|
) : null}
|
||||||
{problem ? (
|
{problem ? (
|
||||||
<ApiErrorPanel context="saving failed" problem={problem} />
|
<ApiErrorPanel context="saving failed" problem={problem} />
|
||||||
|
|
@ -284,7 +322,7 @@ function InferenceForm({
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={busy || empty || modelProblem !== null}
|
disabled={busy || empty || formProblem !== null}
|
||||||
>
|
>
|
||||||
{busy ? "saving…" : "save"}
|
{busy ? "saving…" : "save"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
|
||||||
|
|
@ -227,6 +227,7 @@ mod tests {
|
||||||
model: model.map(str::to_owned),
|
model: model.map(str::to_owned),
|
||||||
effort,
|
effort,
|
||||||
subagent_models: None,
|
subagent_models: None,
|
||||||
|
max_subagents: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,7 @@ use hive_runtime::{
|
||||||
AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop,
|
AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop,
|
||||||
};
|
};
|
||||||
use hive_sh4re::permissions::ToolGroup;
|
use hive_sh4re::permissions::ToolGroup;
|
||||||
|
use swarm_queue_client::agent_inference::AgentInference;
|
||||||
use tokio::sync::oneshot;
|
use tokio::sync::oneshot;
|
||||||
|
|
||||||
use crate::swarm_term::Output;
|
use crate::swarm_term::Output;
|
||||||
|
|
@ -250,6 +251,15 @@ enum Occupancy {
|
||||||
Stopping,
|
Stopping,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Why [`State::reserve_under`] claimed nothing.
|
||||||
|
#[derive(Debug, PartialEq, Eq)]
|
||||||
|
enum Refusal {
|
||||||
|
/// The name is already tracked.
|
||||||
|
Taken,
|
||||||
|
/// `running` names are tracked, and the swarm caps this agent at `cap`.
|
||||||
|
AtCap { running: usize, cap: u32 },
|
||||||
|
}
|
||||||
|
|
||||||
/// What settled a resumed turn's bounded wait — the one thing `continue`
|
/// What settled a resumed turn's bounded wait — the one thing `continue`
|
||||||
/// blocks on before it answers.
|
/// blocks on before it answers.
|
||||||
enum ResumeVerdict {
|
enum ResumeVerdict {
|
||||||
|
|
@ -354,7 +364,7 @@ fn searched_location(config: &Config) -> Option<String> {
|
||||||
/// means it was interrupted and its process is still winding down. Each
|
/// means it was interrupted and its process is still winding down. Each
|
||||||
/// claims the name. `Starting` closes a TOCTOU window: checking "is `name`
|
/// claims the name. `Starting` closes a TOCTOU window: checking "is `name`
|
||||||
/// free" and committing to it are two different lock acquisitions unless
|
/// free" and committing to it are two different lock acquisitions unless
|
||||||
/// the check *is* the reservation — see `reserve`.
|
/// the check *is* the reservation — see `reserve_under`.
|
||||||
///
|
///
|
||||||
/// `killed` is the other half of that map's story: `running` says what is in
|
/// `killed` is the other half of that map's story: `running` says what is in
|
||||||
/// flight *now*, `killed` remembers the names whose last turn ended on a
|
/// flight *now*, `killed` remembers the names whose last turn ended on a
|
||||||
|
|
@ -401,8 +411,9 @@ pub struct State {
|
||||||
/// Where every subagent's output lines go up to the swarm, when this
|
/// Where every subagent's output lines go up to the swarm, when this
|
||||||
/// agent can publish there ([`State::publishing_to`]).
|
/// agent can publish there ([`State::publishing_to`]).
|
||||||
term: Option<crate::swarm_term::Publisher>,
|
term: Option<crate::swarm_term::Publisher>,
|
||||||
/// Where the swarm's `subagent_models` for this agent is read from, when
|
/// Where the swarm's `subagent_models` and `max_subagents` for this agent
|
||||||
/// this agent can read it ([`State::reading_models_from`]).
|
/// are read from, when this agent can read them
|
||||||
|
/// ([`State::reading_models_from`]).
|
||||||
models: Option<crate::swarm_models::Reader>,
|
models: Option<crate::swarm_models::Reader>,
|
||||||
/// The agent's own model list, checked when the swarm sets none:
|
/// The agent's own model list, checked when the swarm sets none:
|
||||||
/// [`available_models`] outside tests.
|
/// [`available_models`] outside tests.
|
||||||
|
|
@ -483,8 +494,9 @@ impl State {
|
||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check every named `model` against the swarm's `subagent_models` as
|
/// Check every named `model` against the swarm's `subagent_models`, and
|
||||||
/// `models` reads it, where the swarm sets one.
|
/// every new run against its `max_subagents`, as `models` reads them,
|
||||||
|
/// where the swarm sets them.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn reading_models_from(mut self, models: Option<crate::swarm_models::Reader>) -> Self {
|
pub fn reading_models_from(mut self, models: Option<crate::swarm_models::Reader>) -> Self {
|
||||||
self.models = models;
|
self.models = models;
|
||||||
|
|
@ -540,17 +552,19 @@ impl State {
|
||||||
command
|
command
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Refuse a `model` outside the list in effect for this agent (see
|
/// Read the swarm's key once, refuse a `model` outside the list in effect
|
||||||
/// `check_model`). The swarm key is read only for a named model.
|
/// for this agent (see `check_model`), and return the subagent cap in
|
||||||
async fn allow_model(&self, model: Option<&str>) -> anyhow::Result<()> {
|
/// effect (see `subagent_cap`).
|
||||||
if model.is_none() {
|
async fn swarm_limits(&self, model: Option<&str>) -> anyhow::Result<Option<u32>> {
|
||||||
return Ok(());
|
|
||||||
}
|
|
||||||
let read = match &self.models {
|
let read = match &self.models {
|
||||||
Some(reader) => Some(reader.read().await),
|
Some(reader) => Some(reader.read().await),
|
||||||
None => None,
|
None => None,
|
||||||
};
|
};
|
||||||
check_model(model, &model_list(read, (self.local_models)()))
|
let cap = subagent_cap(read.as_ref());
|
||||||
|
if model.is_some() {
|
||||||
|
check_model(model, &model_list(read, (self.local_models)()))?;
|
||||||
|
}
|
||||||
|
Ok(cap)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Run every subagent on `runtime` — the daemon passes the parent
|
/// Run every subagent on `runtime` — the daemon passes the parent
|
||||||
|
|
@ -640,19 +654,39 @@ impl State {
|
||||||
/// owning it" in the *same* lock acquisition, so two calls racing the
|
/// owning it" in the *same* lock acquisition, so two calls racing the
|
||||||
/// same name can't both pass a check before either commits (the exact
|
/// same name can't both pass a check before either commits (the exact
|
||||||
/// same-name concurrent-run hazard this module's doc warns about).
|
/// same-name concurrent-run hazard this module's doc warns about).
|
||||||
/// Returns `false` (reserving nothing) if `name` is already reserved
|
/// Refuses (reserving nothing) a `name` already tracked, or any name
|
||||||
/// or running.
|
/// once `cap` names are tracked. The count is checked under the same
|
||||||
fn reserve(&self, name: &str) -> bool {
|
/// lock, so two racing calls can't both take the last place.
|
||||||
|
///
|
||||||
|
/// Every tracked name counts toward `cap`, a goal run between turns
|
||||||
|
/// and an interrupted one still winding down included: each holds a
|
||||||
|
/// process or is about to. A goal's next turn never comes through here,
|
||||||
|
/// so a run already counted is never refused its re-prompt.
|
||||||
|
fn reserve_under(&self, name: &str, cap: Option<u32>) -> Result<(), Refusal> {
|
||||||
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
|
let mut running = self.running.lock().unwrap_or_else(PoisonError::into_inner);
|
||||||
if running.contains_key(name) {
|
if running.contains_key(name) {
|
||||||
return false;
|
return Err(Refusal::Taken);
|
||||||
|
}
|
||||||
|
if let Some(cap) = cap
|
||||||
|
&& running.len() >= usize::try_from(cap).unwrap_or(usize::MAX)
|
||||||
|
{
|
||||||
|
return Err(Refusal::AtCap {
|
||||||
|
running: running.len(),
|
||||||
|
cap,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
running.insert(name.to_owned(), Slot::Starting);
|
running.insert(name.to_owned(), Slot::Starting);
|
||||||
true
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// [`State::reserve_under`] with no cap: `false` if `name` is taken.
|
||||||
|
#[cfg(test)]
|
||||||
|
fn reserve(&self, name: &str) -> bool {
|
||||||
|
self.reserve_under(name, None).is_ok()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Release a reservation that never made it to a real spawn (an error
|
/// Release a reservation that never made it to a real spawn (an error
|
||||||
/// on the slow path between `reserve` and `Claude::spawn` succeeding).
|
/// on the slow path between `reserve_under` and `Claude::spawn` succeeding).
|
||||||
/// A no-op if the entry was already upgraded to `Running` — this only
|
/// A no-op if the entry was already upgraded to `Running` — this only
|
||||||
/// ever clears a still-`Starting` placeholder, never a live process.
|
/// ever clears a still-`Starting` placeholder, never a live process.
|
||||||
///
|
///
|
||||||
|
|
@ -782,7 +816,7 @@ impl State {
|
||||||
/// could.
|
/// could.
|
||||||
///
|
///
|
||||||
/// ⚠️ **Callers must only call this once the call is known to proceed**
|
/// ⚠️ **Callers must only call this once the call is known to proceed**
|
||||||
/// — after `reserve` in `start`, after the session lookup in a
|
/// — after `reserve_under` in `start`, after the session lookup in a
|
||||||
/// `continue` (`begin_continue`) — so a refused call never changes what
|
/// `continue` (`begin_continue`) — so a refused call never changes what
|
||||||
/// a later bare `continue` resolves to. `peek_dir` is the non-committing
|
/// a later bare `continue` resolves to. `peek_dir` is the non-committing
|
||||||
/// variant for a call that might still be refused, or that shouldn't
|
/// variant for a call that might still be refused, or that shouldn't
|
||||||
|
|
@ -1037,7 +1071,10 @@ enum ModelList {
|
||||||
fn model_list(read: Option<crate::swarm_models::Read>, local: Option<String>) -> ModelList {
|
fn model_list(read: Option<crate::swarm_models::Read>, local: Option<String>) -> ModelList {
|
||||||
use crate::swarm_models::Read;
|
use crate::swarm_models::Read;
|
||||||
match read {
|
match read {
|
||||||
Some(Read::Set(models)) => ModelList::Swarm(models),
|
Some(Read::Current(AgentInference {
|
||||||
|
subagent_models: Some(models),
|
||||||
|
..
|
||||||
|
})) => ModelList::Swarm(models),
|
||||||
Some(Read::Failed(error)) => {
|
Some(Read::Failed(error)) => {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
%error,
|
%error,
|
||||||
|
|
@ -1046,7 +1083,25 @@ fn model_list(read: Option<crate::swarm_models::Read>, local: Option<String>) ->
|
||||||
);
|
);
|
||||||
ModelList::Local(local)
|
ModelList::Local(local)
|
||||||
}
|
}
|
||||||
Some(Read::Unset) | None => ModelList::Local(local),
|
Some(Read::Current(_)) | None => ModelList::Local(local),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The swarm's `max_subagents` for this agent, or `None`, no cap: when it
|
||||||
|
/// sets none, when `read` is `None` because this daemon has no swarm queue,
|
||||||
|
/// or when the read failed, which is logged.
|
||||||
|
fn subagent_cap(read: Option<&crate::swarm_models::Read>) -> Option<u32> {
|
||||||
|
use crate::swarm_models::Read;
|
||||||
|
match read? {
|
||||||
|
Read::Current(settings) => settings.max_subagents,
|
||||||
|
Read::Failed(error) => {
|
||||||
|
tracing::warn!(
|
||||||
|
%error,
|
||||||
|
"reading this agent's subagent settings from the swarm failed; applying no \
|
||||||
|
subagent cap"
|
||||||
|
);
|
||||||
|
None
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1347,7 +1402,8 @@ pub struct StartRequest {
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// A name already running, an invalid name, a `model` the operator hasn't
|
/// A name already running, an invalid name, a `model` the operator hasn't
|
||||||
/// made available (see `check_model`), an archive failure, or the
|
/// made available (see `check_model`), this agent at its subagent cap (see
|
||||||
|
/// `subagent_cap`), an archive failure, or the
|
||||||
/// underlying `Claude::spawn` failing (binary missing, etc.) — the last
|
/// underlying `Claude::spawn` failing (binary missing, etc.) — the last
|
||||||
/// case is the only one that can happen *after* commit-to-run, and it's
|
/// case is the only one that can happen *after* commit-to-run, and it's
|
||||||
/// exactly why nothing is registered in `running` until spawn actually
|
/// exactly why nothing is registered in `running` until spawn actually
|
||||||
|
|
@ -1355,15 +1411,20 @@ pub struct StartRequest {
|
||||||
pub async fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
|
pub async fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
|
||||||
let name = req.name.as_str();
|
let name = req.name.as_str();
|
||||||
validate_name(name)?;
|
validate_name(name)?;
|
||||||
state.allow_model(req.model.as_deref()).await?;
|
let cap = state.swarm_limits(req.model.as_deref()).await?;
|
||||||
// Before `reserve`, so a role this agent does not have costs the caller
|
// Before `reserve_under`, so a role this agent does not have costs the caller
|
||||||
// an error and nothing else: no name claimed, no session archived, no
|
// an error and nothing else: no name claimed, no session archived, no
|
||||||
// process spawned.
|
// process spawned.
|
||||||
let role = req.role.as_deref().map(crate::role::load).transpose()?;
|
let role = req.role.as_deref().map(crate::role::load).transpose()?;
|
||||||
if !state.reserve(name) {
|
if let Err(refusal) = state.reserve_under(name, cap) {
|
||||||
return Err(name_taken(state, name, "use `continue` or `interrupt`"));
|
return Err(refused(
|
||||||
|
state,
|
||||||
|
name,
|
||||||
|
&refusal,
|
||||||
|
"use `continue` or `interrupt`",
|
||||||
|
));
|
||||||
}
|
}
|
||||||
// Only commit the remembered `dir` now that `reserve` has actually
|
// Only commit the remembered `dir` now that `reserve_under` has actually
|
||||||
// claimed `name` — see `resolve_dir`'s doc for why the order matters,
|
// claimed `name` — see `resolve_dir`'s doc for why the order matters,
|
||||||
// and the same reasoning governs the three records below it.
|
// and the same reasoning governs the three records below it.
|
||||||
let dir = state.resolve_dir(name, req.dir.as_deref());
|
let dir = state.resolve_dir(name, req.dir.as_deref());
|
||||||
|
|
@ -1412,7 +1473,7 @@ struct SpawnSpec {
|
||||||
role: Option<String>,
|
role: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The slow, fallible part of `start`, run only after `reserve` has
|
/// The slow, fallible part of `start`, run only after `reserve_under` has
|
||||||
/// already closed the TOCTOU window — split out so `start` can release the
|
/// already closed the TOCTOU window — split out so `start` can release the
|
||||||
/// reservation on any error path here without duplicating that logic per
|
/// reservation on any error path here without duplicating that logic per
|
||||||
/// failure site.
|
/// failure site.
|
||||||
|
|
@ -1526,7 +1587,8 @@ fn start_on_runtime(
|
||||||
/// # Errors
|
/// # Errors
|
||||||
///
|
///
|
||||||
/// An invalid name, one already running, a `model` the operator hasn't made
|
/// An invalid name, one already running, a `model` the operator hasn't made
|
||||||
/// available (see `check_model`), no session titled `name` in the project
|
/// available (see `check_model`), this agent at its subagent cap (see
|
||||||
|
/// `subagent_cap`), no session titled `name` in the project
|
||||||
/// dir searched, `Claude::spawn` failing, or the resumed turn failing within
|
/// dir searched, `Claude::spawn` failing, or the resumed turn failing within
|
||||||
/// `RESUME_GRACE`.
|
/// `RESUME_GRACE`.
|
||||||
pub async fn continue_(
|
pub async fn continue_(
|
||||||
|
|
@ -1538,11 +1600,12 @@ pub async fn continue_(
|
||||||
dir: Option<&str>,
|
dir: Option<&str>,
|
||||||
) -> anyhow::Result<String> {
|
) -> anyhow::Result<String> {
|
||||||
validate_name(name)?;
|
validate_name(name)?;
|
||||||
state.allow_model(model.as_deref()).await?;
|
let cap = state.swarm_limits(model.as_deref()).await?;
|
||||||
if !state.reserve(name) {
|
if let Err(refusal) = state.reserve_under(name, cap) {
|
||||||
return Err(name_taken(
|
return Err(refused(
|
||||||
state,
|
state,
|
||||||
name,
|
name,
|
||||||
|
&refusal,
|
||||||
"use `interrupt` first if you meant to redirect it",
|
"use `interrupt` first if you meant to redirect it",
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
@ -1617,7 +1680,7 @@ fn note_resumed_after_kill(msg: &str, killed: Option<i32>) -> String {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The slow, fallible part of `continue_`, run only after `reserve` has
|
/// The slow, fallible part of `continue_`, run only after `reserve_under` has
|
||||||
/// already closed the TOCTOU window — same split rationale as
|
/// already closed the TOCTOU window — same split rationale as
|
||||||
/// `start_reserved`. `dir` is the caller's own, not yet resolved against the
|
/// `start_reserved`. `dir` is the caller's own, not yet resolved against the
|
||||||
/// remembered one.
|
/// remembered one.
|
||||||
|
|
@ -2509,7 +2572,24 @@ pub fn interrupt(state: &State, name: &str) -> anyhow::Result<String> {
|
||||||
))
|
))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// `start`'s and `continue`'s refusal for a name `reserve` found claimed:
|
/// `start`'s and `continue`'s refusal for a name `reserve_under` would not
|
||||||
|
/// claim.
|
||||||
|
fn refused(state: &State, name: &str, refusal: &Refusal, hint: &str) -> anyhow::Error {
|
||||||
|
match refusal {
|
||||||
|
Refusal::Taken => name_taken(state, name, hint),
|
||||||
|
Refusal::AtCap { running, cap } => anyhow::anyhow!(
|
||||||
|
"{running} {} running, cap is {cap} (set in swarm-ui → this agent → \
|
||||||
|
inference) — wait for one to finish, or `interrupt` one",
|
||||||
|
if *running == 1 {
|
||||||
|
"subagent"
|
||||||
|
} else {
|
||||||
|
"subagents"
|
||||||
|
}
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `start`'s and `continue`'s refusal for a name `reserve_under` found claimed:
|
||||||
/// `hint` says what to do about a live session, and an interrupted one that
|
/// `hint` says what to do about a live session, and an interrupted one that
|
||||||
/// hasn't exited yet gets its own answer.
|
/// hasn't exited yet gets its own answer.
|
||||||
fn name_taken(state: &State, name: &str, hint: &str) -> anyhow::Error {
|
fn name_taken(state: &State, name: &str, hint: &str) -> anyhow::Error {
|
||||||
|
|
@ -3013,7 +3093,7 @@ mod tests {
|
||||||
// The exact scenario argus caught in review: a rejected concurrent
|
// The exact scenario argus caught in review: a rejected concurrent
|
||||||
// `start` used to commit its `dir` before the reservation check
|
// `start` used to commit its `dir` before the reservation check
|
||||||
// refused it, corrupting what a later bare `continue` would resolve
|
// refused it, corrupting what a later bare `continue` would resolve
|
||||||
// to. Reordering `reserve` before `resolve_dir` in `start` closes it
|
// to. Reordering `reserve_under` before `resolve_dir` in `start` closes it
|
||||||
// — a name that's already reserved must never reach `resolve_dir` at
|
// — a name that's already reserved must never reach `resolve_dir` at
|
||||||
// all, so `dirs` stays exactly as a caller left it.
|
// all, so `dirs` stays exactly as a caller left it.
|
||||||
let state = Arc::new(State::new(PathBuf::from("/dev/null"), signal_url()));
|
let state = Arc::new(State::new(PathBuf::from("/dev/null"), signal_url()));
|
||||||
|
|
@ -4563,7 +4643,83 @@ mod tests {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn swarm(models: &[&str]) -> crate::swarm_models::Read {
|
fn swarm(models: &[&str]) -> crate::swarm_models::Read {
|
||||||
crate::swarm_models::Read::Set(models.iter().map(|m| (*m).to_owned()).collect())
|
crate::swarm_models::Read::Current(AgentInference {
|
||||||
|
subagent_models: Some(models.iter().map(|m| (*m).to_owned()).collect()),
|
||||||
|
..AgentInference::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unset() -> crate::swarm_models::Read {
|
||||||
|
crate::swarm_models::Read::Current(AgentInference::default())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capped(max: u32) -> crate::swarm_models::Read {
|
||||||
|
crate::swarm_models::Read::Current(AgentInference {
|
||||||
|
max_subagents: Some(max),
|
||||||
|
..AgentInference::default()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reserve_under_refuses_a_new_name_at_the_cap_and_takes_one_below_it() {
|
||||||
|
let state = State::new(PathBuf::from("/dev/null"), signal_url());
|
||||||
|
assert_eq!(state.reserve_under("a", Some(2)), Ok(()));
|
||||||
|
assert_eq!(state.reserve_under("b", Some(2)), Ok(()));
|
||||||
|
assert_eq!(
|
||||||
|
state.reserve_under("c", Some(2)),
|
||||||
|
Err(Refusal::AtCap { running: 2, cap: 2 })
|
||||||
|
);
|
||||||
|
assert_eq!(state.reserve_under("c", None), Ok(()), "no cap, no limit");
|
||||||
|
assert_eq!(
|
||||||
|
state.reserve_under("a", Some(3)),
|
||||||
|
Err(Refusal::Taken),
|
||||||
|
"a taken name at the cap is refused as taken, not as over the cap"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A run between turns and one winding down both still count.
|
||||||
|
#[test]
|
||||||
|
fn every_tracked_slot_counts_toward_the_cap() {
|
||||||
|
let state = State::new(PathBuf::from("/dev/null"), signal_url());
|
||||||
|
assert!(state.reserve("a"));
|
||||||
|
state.between_turns("a");
|
||||||
|
assert!(state.reserve("b"));
|
||||||
|
state
|
||||||
|
.running
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.insert("b".to_owned(), Slot::Stopping);
|
||||||
|
assert_eq!(
|
||||||
|
state.reserve_under("c", Some(2)),
|
||||||
|
Err(Refusal::AtCap { running: 2, cap: 2 })
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_cap_is_the_swarm_value_and_none_otherwise() {
|
||||||
|
assert_eq!(subagent_cap(Some(&capped(3))), Some(3));
|
||||||
|
assert_eq!(subagent_cap(Some(&unset())), None);
|
||||||
|
assert_eq!(subagent_cap(None), None);
|
||||||
|
let failed = crate::swarm_models::Read::Failed("no answer".to_owned());
|
||||||
|
assert_eq!(subagent_cap(Some(&failed)), None);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `start` and `continue` read the cap live: the swarm's cap of 1 refuses
|
||||||
|
/// a second name, naming the count and the cap.
|
||||||
|
#[tokio::test]
|
||||||
|
async fn start_and_continue_refuse_a_new_run_at_the_swarm_cap() {
|
||||||
|
let (state, reads) = state_reading(capped(1), "busy");
|
||||||
|
let started = start(&state, start_on("other", None)).await;
|
||||||
|
let continued = continue_on(&state, "other", None).await;
|
||||||
|
for result in [started, continued] {
|
||||||
|
let err = result.expect_err("one running, cap is 1").to_string();
|
||||||
|
assert!(
|
||||||
|
err.contains("1 subagent running, cap is 1") && err.contains("swarm-ui"),
|
||||||
|
"{err}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
assert_eq!(reads.load(std::sync::atomic::Ordering::Relaxed), 2);
|
||||||
|
assert_eq!(state.occupancy("other"), None, "nothing was reserved");
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The swarm's list replaces the local one, so it can widen it as well
|
/// The swarm's list replaces the local one, so it can widen it as well
|
||||||
|
|
@ -4580,13 +4736,10 @@ mod tests {
|
||||||
#[test]
|
#[test]
|
||||||
fn without_a_swarm_list_the_local_one_applies() {
|
fn without_a_swarm_list_the_local_one_applies() {
|
||||||
let env = || Some("haiku,sonnet".to_owned());
|
let env = || Some("haiku,sonnet".to_owned());
|
||||||
for read in [None, Some(crate::swarm_models::Read::Unset)] {
|
for read in [None, Some(unset())] {
|
||||||
assert_eq!(model_list(read, env()), local(Some("haiku,sonnet")));
|
assert_eq!(model_list(read, env()), local(Some("haiku,sonnet")));
|
||||||
}
|
}
|
||||||
assert_eq!(
|
assert_eq!(model_list(Some(unset()), None), local(None));
|
||||||
model_list(Some(crate::swarm_models::Read::Unset), None),
|
|
||||||
local(None)
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
@ -4630,7 +4783,7 @@ mod tests {
|
||||||
|
|
||||||
/// A state whose swarm read answers `read` and whose own list is `haiku`,
|
/// A state whose swarm read answers `read` and whose own list is `haiku`,
|
||||||
/// with `name` already reserved: a call that gets past the model check
|
/// with `name` already reserved: a call that gets past the model check
|
||||||
/// stops at `reserve` and spawns nothing. Also the reader's read count.
|
/// stops at `reserve_under` and spawns nothing. Also the reader's read count.
|
||||||
fn state_reading(
|
fn state_reading(
|
||||||
read: crate::swarm_models::Read,
|
read: crate::swarm_models::Read,
|
||||||
name: &str,
|
name: &str,
|
||||||
|
|
@ -4666,7 +4819,7 @@ mod tests {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
/// The refusal comes from the model check, ahead of `reserve`'s.
|
/// The refusal comes from the model check, ahead of `reserve_under`'s.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn start_and_continue_refuse_a_model_outside_the_swarm_list_before_reserving() {
|
async fn start_and_continue_refuse_a_model_outside_the_swarm_list_before_reserving() {
|
||||||
let (state, reads) = state_reading(swarm(&["sonnet"]), "n");
|
let (state, reads) = state_reading(swarm(&["sonnet"]), "n");
|
||||||
|
|
@ -4692,8 +4845,9 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The key is read without a model too, for the cap; no model is checked.
|
||||||
#[tokio::test]
|
#[tokio::test]
|
||||||
async fn start_and_continue_without_a_model_do_not_read_the_swarm() {
|
async fn start_and_continue_without_a_model_skip_the_model_check() {
|
||||||
let (state, reads) = state_reading(swarm(&["sonnet"]), "n");
|
let (state, reads) = state_reading(swarm(&["sonnet"]), "n");
|
||||||
let started = start(&state, start_on("n", None)).await;
|
let started = start(&state, start_on("n", None)).await;
|
||||||
let continued = continue_on(&state, "n", None).await;
|
let continued = continue_on(&state, "n", None).await;
|
||||||
|
|
@ -4701,7 +4855,7 @@ mod tests {
|
||||||
let err = result.expect_err("the name is reserved").to_string();
|
let err = result.expect_err("the name is reserved").to_string();
|
||||||
assert!(err.contains("already running"), "{err}");
|
assert!(err.contains("already running"), "{err}");
|
||||||
}
|
}
|
||||||
assert_eq!(reads.load(std::sync::atomic::Ordering::Relaxed), 0);
|
assert_eq!(reads.load(std::sync::atomic::Ordering::Relaxed), 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A `tracing` writer whose output a test can read back.
|
/// A `tracing` writer whose output a test can read back.
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,13 @@
|
||||||
//! Reads the subagent model list the swarm sets for this agent.
|
//! Reads the subagent settings the swarm sets for this agent.
|
||||||
//!
|
//!
|
||||||
//! swarm-controller keeps the agent's settings under its key in the
|
//! swarm-controller keeps the agent's settings under its key in the
|
||||||
//! `agent-inference` KV bucket. Its `subagent_models`, when set, is the list
|
//! `agent-inference` KV bucket. Its `subagent_models`, when set, is the list
|
||||||
//! a `start` or `continue` naming a `model` is checked against, in place of
|
//! a `start` or `continue` naming a `model` is checked against, in place of
|
||||||
//! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`).
|
//! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`). Its
|
||||||
|
//! `max_subagents`, when set, caps how many subagents run at once
|
||||||
|
//! (`crate::session::State::reserve_under`).
|
||||||
//!
|
//!
|
||||||
//! **Read on every such call**, never cached, with the one subject the
|
//! **Read on every `start` and `continue`**, never cached, with the one subject the
|
||||||
//! agent's own queue credential is granted in that bucket
|
//! agent's own queue credential is granted in that bucket
|
||||||
//! (`swarm_queue_client::agent_inference::read_own`). The connection is made
|
//! (`swarm_queue_client::agent_inference::read_own`). The connection is made
|
||||||
//! the way [`crate::swarm_term`] makes its own: as the agent, never as the
|
//! the way [`crate::swarm_term`] makes its own: as the agent, never as the
|
||||||
|
|
@ -13,14 +15,15 @@
|
||||||
//!
|
//!
|
||||||
//! **A subagent start never waits on the queue.** Before the first connect
|
//! **A subagent start never waits on the queue.** Before the first connect
|
||||||
//! succeeds, and when a read fails or outlasts `READ_TIMEOUT`, the read is
|
//! succeeds, and when a read fails or outlasts `READ_TIMEOUT`, the read is
|
||||||
//! [`Read::Failed`] and the caller checks against `HIVE_AVAILABLE_MODELS`.
|
//! [`Read::Failed`]: the caller checks against `HIVE_AVAILABLE_MODELS` and
|
||||||
|
//! applies no cap.
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
use std::sync::{Arc, OnceLock};
|
use std::sync::{Arc, OnceLock};
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
|
|
||||||
use swarm_queue_client::agent_inference::{self, read_own};
|
use swarm_queue_client::agent_inference::{self, AgentInference, read_own};
|
||||||
|
|
||||||
use crate::swarm_term::{Target, connect};
|
use crate::swarm_term::{Target, connect};
|
||||||
|
|
||||||
|
|
@ -32,13 +35,12 @@ const RETRY_AFTER: Duration = Duration::from_mins(1);
|
||||||
/// misgranted agent's `start` waits.
|
/// misgranted agent's `start` waits.
|
||||||
const READ_TIMEOUT: Duration = Duration::from_secs(2);
|
const READ_TIMEOUT: Duration = Duration::from_secs(2);
|
||||||
|
|
||||||
/// What one read of this agent's key said about `subagent_models`.
|
/// What one read of this agent's key found.
|
||||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Read {
|
pub enum Read {
|
||||||
/// The swarm sets the list.
|
/// The settings the swarm holds; an absent key or bucket has every field
|
||||||
Set(Vec<String>),
|
/// unset.
|
||||||
/// The key, the field, or the bucket is absent.
|
Current(AgentInference),
|
||||||
Unset,
|
|
||||||
/// Nothing could be read, and why.
|
/// Nothing could be read, and why.
|
||||||
Failed(String),
|
Failed(String),
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +72,7 @@ impl Reader {
|
||||||
Ok(None) => {
|
Ok(None) => {
|
||||||
tracing::info!(
|
tracing::info!(
|
||||||
"no swarm queue address or no secret store for this agent; subagent models \
|
"no swarm queue address or no secret store for this agent; subagent models \
|
||||||
are checked against HIVE_AVAILABLE_MODELS only"
|
are checked against HIVE_AVAILABLE_MODELS only, and no subagent cap applies"
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -78,7 +80,7 @@ impl Reader {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = format!("{e:#}"),
|
error = format!("{e:#}"),
|
||||||
"this agent's secret store coordinates are incomplete; subagent models are \
|
"this agent's secret store coordinates are incomplete; subagent models are \
|
||||||
checked against HIVE_AVAILABLE_MODELS only"
|
checked against HIVE_AVAILABLE_MODELS only, and no subagent cap applies"
|
||||||
);
|
);
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
|
|
@ -103,7 +105,7 @@ impl Reader {
|
||||||
(Self { source }, reads)
|
(Self { source }, reads)
|
||||||
}
|
}
|
||||||
|
|
||||||
/// This agent's `subagent_models` as the swarm holds it now.
|
/// This agent's settings as the swarm holds them now.
|
||||||
pub async fn read(&self) -> Read {
|
pub async fn read(&self) -> Read {
|
||||||
match &self.source {
|
match &self.source {
|
||||||
Source::Queue { agent, client } => read_queue(agent, client).await,
|
Source::Queue { agent, client } => read_queue(agent, client).await,
|
||||||
|
|
@ -128,12 +130,10 @@ async fn read_queue(agent: &str, client: &OnceLock<async_nats::Client>) -> Read
|
||||||
Err(_) => Read::Failed(format!("no answer within {READ_TIMEOUT:?}")),
|
Err(_) => Read::Failed(format!("no answer within {READ_TIMEOUT:?}")),
|
||||||
Ok(Err(e)) => Read::Failed(e.to_string()),
|
Ok(Err(e)) => Read::Failed(e.to_string()),
|
||||||
Ok(Ok(agent_inference::Read::NoBucket)) => {
|
Ok(Ok(agent_inference::Read::NoBucket)) => {
|
||||||
tracing::debug!("subagent models: the agent-inference bucket does not answer");
|
tracing::debug!("subagent settings: the agent-inference bucket does not answer");
|
||||||
Read::Unset
|
Read::Current(AgentInference::default())
|
||||||
}
|
|
||||||
Ok(Ok(agent_inference::Read::Current(current))) => {
|
|
||||||
current.subagent_models.map_or(Read::Unset, Read::Set)
|
|
||||||
}
|
}
|
||||||
|
Ok(Ok(agent_inference::Read::Current(current))) => Read::Current(current),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -142,7 +142,7 @@ async fn connect_until_up(target: Arc<Target>, client: Arc<OnceLock<async_nats::
|
||||||
loop {
|
loop {
|
||||||
match connect(&target).await {
|
match connect(&target).await {
|
||||||
Ok(connected) => {
|
Ok(connected) => {
|
||||||
tracing::info!(url = %target.url, "subagent models: connected as this agent");
|
tracing::info!(url = %target.url, "subagent settings: connected as this agent");
|
||||||
let _already_set = client.set(connected);
|
let _already_set = client.set(connected);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -150,8 +150,8 @@ async fn connect_until_up(target: Arc<Target>, client: Arc<OnceLock<async_nats::
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
error = format!("{e:#}"),
|
error = format!("{e:#}"),
|
||||||
retry_in = ?RETRY_AFTER,
|
retry_in = ?RETRY_AFTER,
|
||||||
"subagent models: connecting with this agent's own queue credential failed; \
|
"subagent settings: connecting with this agent's own queue credential failed; \
|
||||||
HIVE_AVAILABLE_MODELS applies until a connect succeeds"
|
HIVE_AVAILABLE_MODELS and no subagent cap apply until a connect succeeds"
|
||||||
);
|
);
|
||||||
tokio::time::sleep(RETRY_AFTER).await;
|
tokio::time::sleep(RETRY_AFTER).await;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
//! `/api/agents/{name}/inference` — the model, effort and subagent models the
|
//! `/api/agents/{name}/inference` — the model, effort, subagent models and
|
||||||
//! swarm sets for one agent, and what the agent reports running on.
|
//! subagent cap the swarm sets for one agent, and what the agent reports
|
||||||
|
//! running on.
|
||||||
//!
|
//!
|
||||||
//! Each agent's settings are one value under its own key of `agent-inference`
|
//! Each agent's settings are one value under its own key of `agent-inference`
|
||||||
//! (`swarm_queue_client::agent_inference`), which this daemon alone writes.
|
//! (`swarm_queue_client::agent_inference`), which this daemon alone writes.
|
||||||
|
|
@ -32,6 +33,11 @@ use crate::{AppState, error_problem, wanted_error_status};
|
||||||
/// `wanted::MAX_ATTEMPTS`.
|
/// `wanted::MAX_ATTEMPTS`.
|
||||||
const MAX_ATTEMPTS: usize = 5;
|
const MAX_ATTEMPTS: usize = 5;
|
||||||
|
|
||||||
|
/// The highest `max_subagents` a `PUT` may set. Far more concurrent claude
|
||||||
|
/// sessions than one agent container has memory for, so a value above it is a
|
||||||
|
/// typo rather than a cap.
|
||||||
|
const MAX_SUBAGENTS: u32 = 32;
|
||||||
|
|
||||||
/// One agent's inference settings. An absent field is not set at swarm level,
|
/// One agent's inference settings. An absent field is not set at swarm level,
|
||||||
/// and the agent keeps its own value for it.
|
/// and the agent keeps its own value for it.
|
||||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||||
|
|
@ -47,6 +53,10 @@ pub struct InferenceSettings {
|
||||||
/// The models the agent may start its subagents on. At least one.
|
/// The models the agent may start its subagents on. At least one.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
subagent_models: Option<Vec<String>>,
|
subagent_models: Option<Vec<String>>,
|
||||||
|
/// How many subagents the agent may have running at once, from 1 to
|
||||||
|
/// [`MAX_SUBAGENTS`]. Absent is no cap.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
max_subagents: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl From<AgentInference> for InferenceSettings {
|
impl From<AgentInference> for InferenceSettings {
|
||||||
|
|
@ -55,6 +65,7 @@ impl From<AgentInference> for InferenceSettings {
|
||||||
model: v.model,
|
model: v.model,
|
||||||
effort: v.effort,
|
effort: v.effort,
|
||||||
subagent_models: v.subagent_models,
|
subagent_models: v.subagent_models,
|
||||||
|
max_subagents: v.max_subagents,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -95,10 +106,18 @@ impl InferenceSettings {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
if let Some(max) = self.max_subagents
|
||||||
|
&& !(1..=MAX_SUBAGENTS).contains(&max)
|
||||||
|
{
|
||||||
|
return Err(format!(
|
||||||
|
"max_subagents is {max}; it is from 1 to {MAX_SUBAGENTS}, or left out for no cap"
|
||||||
|
));
|
||||||
|
}
|
||||||
Ok(AgentInference {
|
Ok(AgentInference {
|
||||||
model: self.model,
|
model: self.model,
|
||||||
effort: self.effort,
|
effort: self.effort,
|
||||||
subagent_models: self.subagent_models,
|
subagent_models: self.subagent_models,
|
||||||
|
max_subagents: self.max_subagents,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -266,8 +285,8 @@ pub(crate) async fn get_agent_inference(
|
||||||
(status = 200, description = "the settings as stored; they replace the previous \
|
(status = 200, description = "the settings as stored; they replace the previous \
|
||||||
ones whole", body = InferenceSettings),
|
ones whole", body = InferenceSettings),
|
||||||
(status = 400, description = "not an identifier, not declared on any hive, or a \
|
(status = 400, description = "not an identifier, not declared on any hive, or a \
|
||||||
model name is empty, has whitespace, starts with `-`, or is listed twice \
|
model name is empty, has whitespace, starts with `-`, or is listed \
|
||||||
(problem+json)",
|
twice, or `max_subagents` is outside 1 to 32 (problem+json)",
|
||||||
body = String),
|
body = String),
|
||||||
(status = 422, description = "an unknown field, or an effort that is not one of \
|
(status = 422, description = "an unknown field, or an effort that is not one of \
|
||||||
the five levels"),
|
the five levels"),
|
||||||
|
|
@ -363,6 +382,7 @@ mod tests {
|
||||||
model: Some("opus".to_owned()),
|
model: Some("opus".to_owned()),
|
||||||
effort: Some(Effort::High),
|
effort: Some(Effort::High),
|
||||||
subagent_models: Some(vec!["haiku".to_owned()]),
|
subagent_models: Some(vec!["haiku".to_owned()]),
|
||||||
|
max_subagents: None,
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|
@ -489,6 +509,25 @@ mod tests {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn max_subagents_outside_its_range_is_refused() {
|
||||||
|
for json in [r#"{"max_subagents":0}"#, r#"{"max_subagents":33}"#] {
|
||||||
|
assert!(settings(json).unwrap().into_stored().is_err(), "{json}");
|
||||||
|
}
|
||||||
|
assert!(settings(r#"{"max_subagents":-1}"#).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn max_subagents_in_range_is_stored() {
|
||||||
|
for max in [1, 3, MAX_SUBAGENTS] {
|
||||||
|
let stored = settings(&format!(r#"{{"max_subagents":{max}}}"#))
|
||||||
|
.unwrap()
|
||||||
|
.into_stored()
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(stored.max_subagents, Some(max));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Control for the leading-dash refusal: full model ids have dashes inside.
|
/// Control for the leading-dash refusal: full model ids have dashes inside.
|
||||||
#[test]
|
#[test]
|
||||||
fn a_dash_inside_a_model_name_is_accepted() {
|
fn a_dash_inside_a_model_name_is_accepted() {
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
//! The per-agent inference-settings KV bucket: one key per agent, holding the
|
//! The per-agent inference-settings KV bucket: one key per agent, holding the
|
||||||
//! model, effort and subagent model list the swarm sets for it.
|
//! model, effort, subagent model list and subagent cap the swarm sets for it.
|
||||||
//!
|
//!
|
||||||
//! **swarm-controller is the only writer**, and creates the bucket on its
|
//! **swarm-controller is the only writer**, and creates the bucket on its
|
||||||
//! first write. Every field is optional: an absent field leaves the agent on
|
//! first write. Every field is optional: an absent field leaves the agent on
|
||||||
|
|
@ -46,6 +46,10 @@ pub struct AgentInference {
|
||||||
/// The models the agent may start its subagents on.
|
/// The models the agent may start its subagents on.
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
pub subagent_models: Option<Vec<String>>,
|
pub subagent_models: Option<Vec<String>>,
|
||||||
|
/// How many subagents the agent may have running at once. `None` is no
|
||||||
|
/// cap.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub max_subagents: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// A claude `--effort` level, ascending.
|
/// A claude `--effort` level, ascending.
|
||||||
|
|
@ -196,11 +200,12 @@ mod tests {
|
||||||
model: Some("opus".to_owned()),
|
model: Some("opus".to_owned()),
|
||||||
effort: Some(Effort::Xhigh),
|
effort: Some(Effort::Xhigh),
|
||||||
subagent_models: Some(vec!["haiku".to_owned(), "sonnet".to_owned()]),
|
subagent_models: Some(vec!["haiku".to_owned(), "sonnet".to_owned()]),
|
||||||
|
max_subagents: Some(3),
|
||||||
};
|
};
|
||||||
let encoded = serde_json::to_string(&value).unwrap();
|
let encoded = serde_json::to_string(&value).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
encoded,
|
encoded,
|
||||||
r#"{"model":"opus","effort":"xhigh","subagent_models":["haiku","sonnet"]}"#
|
r#"{"model":"opus","effort":"xhigh","subagent_models":["haiku","sonnet"],"max_subagents":3}"#
|
||||||
);
|
);
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
serde_json::from_str::<AgentInference>(&encoded).unwrap(),
|
serde_json::from_str::<AgentInference>(&encoded).unwrap(),
|
||||||
|
|
@ -225,6 +230,16 @@ mod tests {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A value written before `max_subagents` existed decodes with no cap.
|
||||||
|
#[test]
|
||||||
|
fn a_value_without_max_subagents_has_no_cap() {
|
||||||
|
let decoded: AgentInference =
|
||||||
|
serde_json::from_str(r#"{"model":"opus","effort":"high","subagent_models":["haiku"]}"#)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(decoded.max_subagents, None);
|
||||||
|
assert_eq!(decoded.model.as_deref(), Some("opus"));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn every_effort_is_stored_as_its_cli_spelling() {
|
fn every_effort_is_stored_as_its_cli_spelling() {
|
||||||
for effort in [
|
for effort in [
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,8 @@ struct Settings {
|
||||||
effort: Option<String>,
|
effort: Option<String>,
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
subagent_models: Option<Vec<String>>,
|
subagent_models: Option<Vec<String>>,
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
max_subagents: Option<u32>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// What `GET` answers: the settings, and what the agent reported running on.
|
/// What `GET` answers: the settings, and what the agent reported running on.
|
||||||
|
|
@ -59,6 +61,7 @@ impl SetFlags {
|
||||||
model: self.model.or(current.model),
|
model: self.model.or(current.model),
|
||||||
effort: self.effort.or(current.effort),
|
effort: self.effort.or(current.effort),
|
||||||
subagent_models: self.subagent_models.or(current.subagent_models),
|
subagent_models: self.subagent_models.or(current.subagent_models),
|
||||||
|
max_subagents: current.max_subagents,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -145,12 +148,14 @@ fn render_configured(s: Option<&Settings>) -> String {
|
||||||
fn render(s: &Settings) -> String {
|
fn render(s: &Settings) -> String {
|
||||||
const UNSET: &str = "(not set; the agent's own)";
|
const UNSET: &str = "(not set; the agent's own)";
|
||||||
format!(
|
format!(
|
||||||
"model: {}\neffort: {}\nsubagent_models: {}\n",
|
"model: {}\neffort: {}\nsubagent_models: {}\nmax_subagents: {}\n",
|
||||||
s.model.as_deref().unwrap_or(UNSET),
|
s.model.as_deref().unwrap_or(UNSET),
|
||||||
s.effort.as_deref().unwrap_or(UNSET),
|
s.effort.as_deref().unwrap_or(UNSET),
|
||||||
s.subagent_models
|
s.subagent_models
|
||||||
.as_ref()
|
.as_ref()
|
||||||
.map_or_else(|| UNSET.to_owned(), |m| m.join(", "))
|
.map_or_else(|| UNSET.to_owned(), |m| m.join(", ")),
|
||||||
|
s.max_subagents
|
||||||
|
.map_or_else(|| "(not set; no cap)".to_owned(), |n| n.to_string())
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -208,6 +213,19 @@ mod tests {
|
||||||
assert_eq!(merged.effort.as_deref(), Some("high"));
|
assert_eq!(merged.effort.as_deref(), Some("high"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// `set` has no flag for the cap, and a stored one survives it.
|
||||||
|
#[test]
|
||||||
|
fn set_keeps_the_stored_max_subagents() {
|
||||||
|
let current: Settings = serde_json::from_str(r#"{"max_subagents":3}"#).unwrap();
|
||||||
|
let merged = SetFlags {
|
||||||
|
model: Some("opus".to_owned()),
|
||||||
|
..flags()
|
||||||
|
}
|
||||||
|
.merge(current)
|
||||||
|
.unwrap();
|
||||||
|
assert_eq!(merged.max_subagents, Some(3));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn set_with_no_flag_is_refused() {
|
fn set_with_no_flag_is_refused() {
|
||||||
assert!(flags().merge(Settings::default()).is_err());
|
assert!(flags().merge(Settings::default()).is_err());
|
||||||
|
|
@ -236,16 +254,18 @@ mod tests {
|
||||||
let empty: View = serde_json::from_str(r#"{"configured":{},"effective":null}"#).unwrap();
|
let empty: View = serde_json::from_str(r#"{"configured":{},"effective":null}"#).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
render_configured(empty.configured.as_ref()).lines().count(),
|
render_configured(empty.configured.as_ref()).lines().count(),
|
||||||
3
|
4
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn an_empty_value_renders_every_field_as_unset() {
|
fn an_empty_value_renders_every_field_as_unset() {
|
||||||
let row = render(&serde_json::from_str("{}").unwrap());
|
let row = render(&serde_json::from_str("{}").unwrap());
|
||||||
assert_eq!(row.lines().count(), 3);
|
assert_eq!(row.lines().count(), 4);
|
||||||
|
assert!(row.ends_with("max_subagents: (not set; no cap)\n"), "{row}");
|
||||||
assert!(
|
assert!(
|
||||||
row.lines()
|
row.lines()
|
||||||
|
.take(3)
|
||||||
.all(|l| l.ends_with("(not set; the agent's own)")),
|
.all(|l| l.ends_with("(not set; the agent's own)")),
|
||||||
"{row}"
|
"{row}"
|
||||||
);
|
);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue