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,
|
||||
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,
|
||||
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
|
||||
`continue` calls may name, read on each call
|
||||
([`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
|
||||
nothing for the agent), and then the model and effort the agent reports
|
||||
running on, each with where it comes from.
|
||||
|
|
@ -39,6 +44,7 @@ command.
|
|||
model: opus
|
||||
effort: (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 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:
|
||||
|
||||
```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;
|
||||
|
|
@ -108,8 +119,8 @@ in-memory model or effort without writing `hyperhive-model` or
|
|||
`hyperhive-effort`, which keep the agent's own value.
|
||||
|
||||
The subagent daemon (`hive-subagent-mcp`'s `swarm_models`) reads
|
||||
`subagent_models` with the same direct get, under the same credential, on
|
||||
each `start` or `continue` that names a model.
|
||||
`subagent_models` and `max_subagents` with the same direct get, under the
|
||||
same credential, on each `start` or `continue`.
|
||||
|
||||
`effective` comes from the agent's turn-state header on
|
||||
`$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
|
||||
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"
|
||||
when it sets none. **edit** opens a dialog with the three fields, filled with
|
||||
what the swarm stores; saving does a `PUT /api/agents/{agent}/inference` of
|
||||
default. Below them are the subagent model list the swarm sets, or "its own"
|
||||
when it sets none, and the subagent cap, which reads "no cap" when unset.
|
||||
**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
|
||||
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
|
||||
report reaches the controller, it shows the swarm's stored values only.
|
||||
[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
|
||||
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
|
||||
|
||||
A subagent runs on its parent agent's runtime
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -227,6 +227,7 @@ mod tests {
|
|||
model: model.map(str::to_owned),
|
||||
effort,
|
||||
subagent_models: None,
|
||||
max_subagents: None,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -99,6 +99,7 @@ use hive_runtime::{
|
|||
AcpCommand, AcpRuntime, PermissionAsk, PermissionPolicy, Runtime as _, RuntimeSpec, TurnStop,
|
||||
};
|
||||
use hive_sh4re::permissions::ToolGroup;
|
||||
use swarm_queue_client::agent_inference::AgentInference;
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::swarm_term::Output;
|
||||
|
|
@ -250,6 +251,15 @@ enum Occupancy {
|
|||
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`
|
||||
/// blocks on before it answers.
|
||||
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
|
||||
/// claims the name. `Starting` closes a TOCTOU window: checking "is `name`
|
||||
/// 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
|
||||
/// 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
|
||||
/// agent can publish there ([`State::publishing_to`]).
|
||||
term: Option<crate::swarm_term::Publisher>,
|
||||
/// Where the swarm's `subagent_models` for this agent is read from, when
|
||||
/// this agent can read it ([`State::reading_models_from`]).
|
||||
/// Where the swarm's `subagent_models` and `max_subagents` for this agent
|
||||
/// are read from, when this agent can read them
|
||||
/// ([`State::reading_models_from`]).
|
||||
models: Option<crate::swarm_models::Reader>,
|
||||
/// The agent's own model list, checked when the swarm sets none:
|
||||
/// [`available_models`] outside tests.
|
||||
|
|
@ -483,8 +494,9 @@ impl State {
|
|||
self
|
||||
}
|
||||
|
||||
/// Check every named `model` against the swarm's `subagent_models` as
|
||||
/// `models` reads it, where the swarm sets one.
|
||||
/// Check every named `model` against the swarm's `subagent_models`, and
|
||||
/// every new run against its `max_subagents`, as `models` reads them,
|
||||
/// where the swarm sets them.
|
||||
#[must_use]
|
||||
pub fn reading_models_from(mut self, models: Option<crate::swarm_models::Reader>) -> Self {
|
||||
self.models = models;
|
||||
|
|
@ -540,17 +552,19 @@ impl State {
|
|||
command
|
||||
}
|
||||
|
||||
/// Refuse a `model` outside the list in effect for this agent (see
|
||||
/// `check_model`). The swarm key is read only for a named model.
|
||||
async fn allow_model(&self, model: Option<&str>) -> anyhow::Result<()> {
|
||||
if model.is_none() {
|
||||
return Ok(());
|
||||
}
|
||||
/// Read the swarm's key once, refuse a `model` outside the list in effect
|
||||
/// for this agent (see `check_model`), and return the subagent cap in
|
||||
/// effect (see `subagent_cap`).
|
||||
async fn swarm_limits(&self, model: Option<&str>) -> anyhow::Result<Option<u32>> {
|
||||
let read = match &self.models {
|
||||
Some(reader) => Some(reader.read().await),
|
||||
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
|
||||
|
|
@ -640,19 +654,39 @@ impl State {
|
|||
/// 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 concurrent-run hazard this module's doc warns about).
|
||||
/// Returns `false` (reserving nothing) if `name` is already reserved
|
||||
/// or running.
|
||||
fn reserve(&self, name: &str) -> bool {
|
||||
/// Refuses (reserving nothing) a `name` already tracked, or any name
|
||||
/// once `cap` names are tracked. The count is checked under the same
|
||||
/// 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);
|
||||
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);
|
||||
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
|
||||
/// 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
|
||||
/// ever clears a still-`Starting` placeholder, never a live process.
|
||||
///
|
||||
|
|
@ -782,7 +816,7 @@ impl State {
|
|||
/// could.
|
||||
///
|
||||
/// ⚠️ **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
|
||||
/// 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
|
||||
|
|
@ -1037,7 +1071,10 @@ enum ModelList {
|
|||
fn model_list(read: Option<crate::swarm_models::Read>, local: Option<String>) -> ModelList {
|
||||
use crate::swarm_models::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)) => {
|
||||
tracing::warn!(
|
||||
%error,
|
||||
|
|
@ -1046,7 +1083,25 @@ fn model_list(read: Option<crate::swarm_models::Read>, local: Option<String>) ->
|
|||
);
|
||||
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
|
||||
///
|
||||
/// 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
|
||||
/// 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
|
||||
|
|
@ -1355,15 +1411,20 @@ pub struct StartRequest {
|
|||
pub async fn start(state: &Arc<State>, req: StartRequest) -> anyhow::Result<String> {
|
||||
let name = req.name.as_str();
|
||||
validate_name(name)?;
|
||||
state.allow_model(req.model.as_deref()).await?;
|
||||
// Before `reserve`, so a role this agent does not have costs the caller
|
||||
let cap = state.swarm_limits(req.model.as_deref()).await?;
|
||||
// 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
|
||||
// process spawned.
|
||||
let role = req.role.as_deref().map(crate::role::load).transpose()?;
|
||||
if !state.reserve(name) {
|
||||
return Err(name_taken(state, name, "use `continue` or `interrupt`"));
|
||||
if let Err(refusal) = state.reserve_under(name, cap) {
|
||||
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,
|
||||
// and the same reasoning governs the three records below it.
|
||||
let dir = state.resolve_dir(name, req.dir.as_deref());
|
||||
|
|
@ -1412,7 +1473,7 @@ struct SpawnSpec {
|
|||
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
|
||||
/// reservation on any error path here without duplicating that logic per
|
||||
/// failure site.
|
||||
|
|
@ -1526,7 +1587,8 @@ fn start_on_runtime(
|
|||
/// # Errors
|
||||
///
|
||||
/// 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
|
||||
/// `RESUME_GRACE`.
|
||||
pub async fn continue_(
|
||||
|
|
@ -1538,11 +1600,12 @@ pub async fn continue_(
|
|||
dir: Option<&str>,
|
||||
) -> anyhow::Result<String> {
|
||||
validate_name(name)?;
|
||||
state.allow_model(model.as_deref()).await?;
|
||||
if !state.reserve(name) {
|
||||
return Err(name_taken(
|
||||
let cap = state.swarm_limits(model.as_deref()).await?;
|
||||
if let Err(refusal) = state.reserve_under(name, cap) {
|
||||
return Err(refused(
|
||||
state,
|
||||
name,
|
||||
&refusal,
|
||||
"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
|
||||
/// `start_reserved`. `dir` is the caller's own, not yet resolved against the
|
||||
/// 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
|
||||
/// hasn't exited yet gets its own answer.
|
||||
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
|
||||
// `start` used to commit its `dir` before the reservation check
|
||||
// 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
|
||||
// all, so `dirs` stays exactly as a caller left it.
|
||||
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 {
|
||||
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
|
||||
|
|
@ -4580,13 +4736,10 @@ mod tests {
|
|||
#[test]
|
||||
fn without_a_swarm_list_the_local_one_applies() {
|
||||
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(Some(crate::swarm_models::Read::Unset), None),
|
||||
local(None)
|
||||
);
|
||||
assert_eq!(model_list(Some(unset()), None), local(None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -4630,7 +4783,7 @@ mod tests {
|
|||
|
||||
/// 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
|
||||
/// 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(
|
||||
read: crate::swarm_models::Read,
|
||||
name: &str,
|
||||
|
|
@ -4666,7 +4819,7 @@ mod tests {
|
|||
.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]
|
||||
async fn start_and_continue_refuse_a_model_outside_the_swarm_list_before_reserving() {
|
||||
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]
|
||||
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 started = start(&state, start_on("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();
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
//! `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
|
||||
//! `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
|
||||
//! (`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
|
||||
|
|
@ -13,14 +15,15 @@
|
|||
//!
|
||||
//! **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
|
||||
//! [`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)]
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use std::sync::{Arc, OnceLock};
|
||||
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};
|
||||
|
||||
|
|
@ -32,13 +35,12 @@ const RETRY_AFTER: Duration = Duration::from_mins(1);
|
|||
/// misgranted agent's `start` waits.
|
||||
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)]
|
||||
pub enum Read {
|
||||
/// The swarm sets the list.
|
||||
Set(Vec<String>),
|
||||
/// The key, the field, or the bucket is absent.
|
||||
Unset,
|
||||
/// The settings the swarm holds; an absent key or bucket has every field
|
||||
/// unset.
|
||||
Current(AgentInference),
|
||||
/// Nothing could be read, and why.
|
||||
Failed(String),
|
||||
}
|
||||
|
|
@ -70,7 +72,7 @@ impl Reader {
|
|||
Ok(None) => {
|
||||
tracing::info!(
|
||||
"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;
|
||||
}
|
||||
|
|
@ -78,7 +80,7 @@ impl Reader {
|
|||
tracing::warn!(
|
||||
error = format!("{e:#}"),
|
||||
"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;
|
||||
}
|
||||
|
|
@ -103,7 +105,7 @@ impl Reader {
|
|||
(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 {
|
||||
match &self.source {
|
||||
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:?}")),
|
||||
Ok(Err(e)) => Read::Failed(e.to_string()),
|
||||
Ok(Ok(agent_inference::Read::NoBucket)) => {
|
||||
tracing::debug!("subagent models: the agent-inference bucket does not answer");
|
||||
Read::Unset
|
||||
}
|
||||
Ok(Ok(agent_inference::Read::Current(current))) => {
|
||||
current.subagent_models.map_or(Read::Unset, Read::Set)
|
||||
tracing::debug!("subagent settings: the agent-inference bucket does not answer");
|
||||
Read::Current(AgentInference::default())
|
||||
}
|
||||
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 {
|
||||
match connect(&target).await {
|
||||
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);
|
||||
return;
|
||||
}
|
||||
|
|
@ -150,8 +150,8 @@ async fn connect_until_up(target: Arc<Target>, client: Arc<OnceLock<async_nats::
|
|||
tracing::warn!(
|
||||
error = format!("{e:#}"),
|
||||
retry_in = ?RETRY_AFTER,
|
||||
"subagent models: connecting with this agent's own queue credential failed; \
|
||||
HIVE_AVAILABLE_MODELS applies until a connect succeeds"
|
||||
"subagent settings: connecting with this agent's own queue credential failed; \
|
||||
HIVE_AVAILABLE_MODELS and no subagent cap apply until a connect succeeds"
|
||||
);
|
||||
tokio::time::sleep(RETRY_AFTER).await;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
//! `/api/agents/{name}/inference` — the model, effort and subagent models the
|
||||
//! swarm sets for one agent, and what the agent reports running on.
|
||||
//! `/api/agents/{name}/inference` — the model, effort, subagent models and
|
||||
//! 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`
|
||||
//! (`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`.
|
||||
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,
|
||||
/// and the agent keeps its own value for it.
|
||||
#[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.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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 {
|
||||
|
|
@ -55,6 +65,7 @@ impl From<AgentInference> for InferenceSettings {
|
|||
model: v.model,
|
||||
effort: v.effort,
|
||||
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 {
|
||||
model: self.model,
|
||||
effort: self.effort,
|
||||
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 \
|
||||
ones whole", body = InferenceSettings),
|
||||
(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 \
|
||||
(problem+json)",
|
||||
model name is empty, has whitespace, starts with `-`, or is listed \
|
||||
twice, or `max_subagents` is outside 1 to 32 (problem+json)",
|
||||
body = String),
|
||||
(status = 422, description = "an unknown field, or an effort that is not one of \
|
||||
the five levels"),
|
||||
|
|
@ -363,6 +382,7 @@ mod tests {
|
|||
model: Some("opus".to_owned()),
|
||||
effort: Some(Effort::High),
|
||||
subagent_models: Some(vec!["haiku".to_owned()]),
|
||||
max_subagents: None,
|
||||
}
|
||||
);
|
||||
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.
|
||||
#[test]
|
||||
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
|
||||
//! 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
|
||||
//! 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.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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.
|
||||
|
|
@ -196,11 +200,12 @@ mod tests {
|
|||
model: Some("opus".to_owned()),
|
||||
effort: Some(Effort::Xhigh),
|
||||
subagent_models: Some(vec!["haiku".to_owned(), "sonnet".to_owned()]),
|
||||
max_subagents: Some(3),
|
||||
};
|
||||
let encoded = serde_json::to_string(&value).unwrap();
|
||||
assert_eq!(
|
||||
encoded,
|
||||
r#"{"model":"opus","effort":"xhigh","subagent_models":["haiku","sonnet"]}"#
|
||||
r#"{"model":"opus","effort":"xhigh","subagent_models":["haiku","sonnet"],"max_subagents":3}"#
|
||||
);
|
||||
assert_eq!(
|
||||
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]
|
||||
fn every_effort_is_stored_as_its_cli_spelling() {
|
||||
for effort in [
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ struct Settings {
|
|||
effort: Option<String>,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
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.
|
||||
|
|
@ -59,6 +61,7 @@ impl SetFlags {
|
|||
model: self.model.or(current.model),
|
||||
effort: self.effort.or(current.effort),
|
||||
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 {
|
||||
const UNSET: &str = "(not set; the agent's own)";
|
||||
format!(
|
||||
"model: {}\neffort: {}\nsubagent_models: {}\n",
|
||||
"model: {}\neffort: {}\nsubagent_models: {}\nmax_subagents: {}\n",
|
||||
s.model.as_deref().unwrap_or(UNSET),
|
||||
s.effort.as_deref().unwrap_or(UNSET),
|
||||
s.subagent_models
|
||||
.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"));
|
||||
}
|
||||
|
||||
/// `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]
|
||||
fn set_with_no_flag_is_refused() {
|
||||
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();
|
||||
assert_eq!(
|
||||
render_configured(empty.configured.as_ref()).lines().count(),
|
||||
3
|
||||
4
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_empty_value_renders_every_field_as_unset() {
|
||||
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!(
|
||||
row.lines()
|
||||
.take(3)
|
||||
.all(|l| l.ends_with("(not set; the agent's own)")),
|
||||
"{row}"
|
||||
);
|
||||
|
|
|
|||
Loading…
Reference in a new issue