From 9a71d1e094dfc7628c9b5a66ad1d51479afae6e3 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 13 Oct 2026 19:24:13 +0200 Subject: [PATCH 1/3] 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`. 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. --- docs/swarm/inference.md | 21 +- docs/swarm/ui.md | 9 +- docs/tools/subagent.md | 20 ++ .../src/agent/detail/AgentInference.tsx | 56 +++- hive-agent/src/swarm_inference.rs | 1 + hive-subagent-mcp/src/session.rs | 244 ++++++++++++++---- hive-subagent-mcp/src/swarm_models.rs | 42 +-- swarm-controller/src/inference.rs | 47 +++- swarm-queue-client/src/agent_inference.rs | 19 +- swarmctl/src/inference.rs | 28 +- 10 files changed, 393 insertions(+), 94 deletions(-) diff --git a/docs/swarm/inference.md b/docs/swarm/inference.md index 50115aeb..c4fd4505 100644 --- a/docs/swarm/inference.md +++ b/docs/swarm/inference.md @@ -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.` ([`README.md`](README.md)), which carries diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 06cd4ad4..f20a9e2b 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -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. diff --git a/docs/tools/subagent.md b/docs/tools/subagent.md index f75aeace..9c1ad469 100644 --- a/docs/tools/subagent.md +++ b/docs/tools/subagent.md @@ -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 `/` 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 diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx index f23936d3..92c5fd74 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx @@ -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"} /> +
  • + +
  • setEditing(true)} /> (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({

    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.

    - {modelProblem ? ( -

    {modelProblem}

    + + {formProblem ? ( +

    {formProblem}

    ) : null} {problem ? ( @@ -284,7 +322,7 @@ function InferenceForm({ diff --git a/hive-agent/src/swarm_inference.rs b/hive-agent/src/swarm_inference.rs index 57d96420..5d4dd6fa 100644 --- a/hive-agent/src/swarm_inference.rs +++ b/hive-agent/src/swarm_inference.rs @@ -227,6 +227,7 @@ mod tests { model: model.map(str::to_owned), effort, subagent_models: None, + max_subagents: None, } } diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index 43ca24b6..f6377784 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -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 { /// 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, - /// 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, /// 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) -> 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> { 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) -> 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, local: Option) -> 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, local: Option) -> ); 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 { + 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, req: StartRequest) -> anyhow::Result { 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, } -/// 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 { 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) -> 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 { )) } -/// `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. diff --git a/hive-subagent-mcp/src/swarm_models.rs b/hive-subagent-mcp/src/swarm_models.rs index 2c4595c0..9cf482a8 100644 --- a/hive-subagent-mcp/src/swarm_models.rs +++ b/hive-subagent-mcp/src/swarm_models.rs @@ -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), - /// 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) -> 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, client: Arc { - 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, client: Arc>, + /// 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, } impl From for InferenceSettings { @@ -55,6 +65,7 @@ impl From 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() { diff --git a/swarm-queue-client/src/agent_inference.rs b/swarm-queue-client/src/agent_inference.rs index 35e26686..ad69f0f8 100644 --- a/swarm-queue-client/src/agent_inference.rs +++ b/swarm-queue-client/src/agent_inference.rs @@ -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>, + /// 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, } /// 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::(&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 [ diff --git a/swarmctl/src/inference.rs b/swarmctl/src/inference.rs index 840e1508..6fc6f252 100644 --- a/swarmctl/src/inference.rs +++ b/swarmctl/src/inference.rs @@ -24,6 +24,8 @@ struct Settings { effort: Option, #[serde(default, skip_serializing_if = "Option::is_none")] subagent_models: Option>, + #[serde(default, skip_serializing_if = "Option::is_none")] + max_subagents: Option, } /// 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}" ); From 461795b2cf5a6f4acf79513021758cd27c55baa4 Mon Sep 17 00:00:00 2001 From: atlas Date: Tue, 13 Oct 2026 19:46:59 +0200 Subject: [PATCH 2/3] swarm-ui: fix the stray doc text, name the agent in the cap-read warning docs/swarm/inference.md had a table-row suffix pasted before the H1 by a bad substitution, and the PUT row had lost it; the text is back in the row. A failed read of the agent-inference key already applied no cap (an unreachable swarm must not block subagent work) and warned, but the warning did not say which agent. It now names the agent and the error, a test pins that, and subagent.md says a failed read starts anyway. The max-subagents field stays a text field with a digit pattern: the review-settings page's numeric field (concurrent reviews) is a TextField with pattern="[0-9]+" and validation, and no field in swarm-ui uses type="number". The new field gets the same pattern. --- docs/swarm/inference.md | 12 +++--- docs/tools/subagent.md | 2 +- .../src/agent/detail/AgentInference.tsx | 1 + hive-subagent-mcp/src/session.rs | 37 +++++++++++++++---- hive-subagent-mcp/src/swarm_models.rs | 10 +++++ 5 files changed, 48 insertions(+), 14 deletions(-) diff --git a/docs/swarm/inference.md b/docs/swarm/inference.md index c4fd4505..00c5b6ab 100644 --- a/docs/swarm/inference.md +++ b/docs/swarm/inference.md @@ -1,4 +1,4 @@ -or a model name with whitespace or a leading `-`, or a `max_subagents` outside 1 to 32 |# Per-agent inference settings +# 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 and how many subagents it may run @@ -103,11 +103,11 @@ a stored value with no fields. A stored value the controller can't decode, such as one a newer build wrote, also answers `"configured": null`, with a warning in its log, until the next `PUT` replaces it. -| method | path | body | -| -------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/agents//inference` | β€”; answers `{"configured": , "effective": }` | -| `PUT` | `/api/agents//inference` | the whole value, replacing the stored one; the controller refuses unknown fields, an unknown effort, or a model name with whitespace or a leading `-` | -| `DELETE` | `/api/agents//inference` | β€”; answers 204 | +| method | path | body | +| -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/agents//inference` | β€”; answers `{"configured": , "effective": }` | +| `PUT` | `/api/agents//inference` | the whole value, replacing the stored one; the controller refuses unknown fields, an unknown effort, or a model name with whitespace or a leading `-`, or a `max_subagents` outside 1 to 32 | +| `DELETE` | `/api/agents//inference` | β€”; answers 204 | An agent's own queue credential grants it `$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.`, the diff --git a/docs/tools/subagent.md b/docs/tools/subagent.md index 9c1ad469..287bff8d 100644 --- a/docs/tools/subagent.md +++ b/docs/tools/subagent.md @@ -355,7 +355,7 @@ 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. +when the read fails, no cap applies. A failed read logs a warning naming the agent and the error, and the start proceeds: an unreachable swarm must not block subagent work. ## Runtime diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx index 92c5fd74..b7ebca60 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx @@ -310,6 +310,7 @@ function InferenceForm({ label="max subagents" value={maxSubagents} placeholder="empty for no cap" + pattern="[0-9]*" onInput={setMaxSubagents} /> {formProblem ? ( diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index f6377784..202be430 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -560,7 +560,11 @@ impl State { Some(reader) => Some(reader.read().await), None => None, }; - let cap = subagent_cap(read.as_ref()); + let agent = self + .models + .as_ref() + .map_or("", crate::swarm_models::Reader::agent); + let cap = subagent_cap(read.as_ref(), agent); if model.is_some() { check_model(model, &model_list(read, (self.local_models)()))?; } @@ -1089,13 +1093,14 @@ fn model_list(read: Option, local: Option) -> /// 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 { +/// or when the read failed, which is logged as a warning naming the agent and the error. +fn subagent_cap(read: Option<&crate::swarm_models::Read>, agent: &str) -> Option { use crate::swarm_models::Read; match read? { Read::Current(settings) => settings.max_subagents, Read::Failed(error) => { tracing::warn!( + agent, %error, "reading this agent's subagent settings from the swarm failed; applying no \ subagent cap" @@ -4697,11 +4702,29 @@ mod tests { #[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); + assert_eq!(subagent_cap(Some(&capped(3)), "a"), Some(3)); + assert_eq!(subagent_cap(Some(&unset()), "a"), None); + assert_eq!(subagent_cap(None, "a"), None); let failed = crate::swarm_models::Read::Failed("no answer".to_owned()); - assert_eq!(subagent_cap(Some(&failed)), None); + assert_eq!(subagent_cap(Some(&failed), "a"), None); + } + + #[test] + fn a_failed_cap_read_warns_naming_the_agent_and_the_error() { + let logs = CapturedLogs::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(logs.clone()) + .with_ansi(false) + .finish(); + let failed = crate::swarm_models::Read::Failed("no answer".to_owned()); + let cap = + tracing::subscriber::with_default(subscriber, || subagent_cap(Some(&failed), "iris")); + assert_eq!(cap, None); + let logged = logs.text(); + assert!( + logged.contains("WARN") && logged.contains("iris") && logged.contains("no answer"), + "{logged}" + ); } /// `start` and `continue` read the cap live: the swarm's cap of 1 refuses diff --git a/hive-subagent-mcp/src/swarm_models.rs b/hive-subagent-mcp/src/swarm_models.rs index 9cf482a8..e6be9a4b 100644 --- a/hive-subagent-mcp/src/swarm_models.rs +++ b/hive-subagent-mcp/src/swarm_models.rs @@ -105,6 +105,16 @@ impl Reader { (Self { source }, reads) } + /// The agent whose key is read; `"test"` for a fixed reader. + #[must_use] + pub fn agent(&self) -> &str { + match &self.source { + Source::Queue { agent, .. } => agent, + #[cfg(test)] + Source::Fixed { .. } => "test", + } + } + /// This agent's settings as the swarm holds them now. pub async fn read(&self) -> Read { match &self.source { From 0754b740b7ff36dd8e1979c360f184f10e544056 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 14 Oct 2026 02:03:35 +0200 Subject: [PATCH 3/3] swarm-ui: name the subagent cap in the three descriptions that list inference fields swarm-controller README, the swarmctl inference doc comment (and the regenerated swarmctl-cli.md) and the hive-agent swarm_inference module doc still listed only model, effort and subagent models. --- docs/tools/swarmctl-cli.md | 6 ++++-- hive-agent/src/swarm_inference.rs | 2 +- swarm-controller/README.md | 5 +++-- swarmctl/src/main.rs | 7 +++++-- 4 files changed, 13 insertions(+), 7 deletions(-) diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index a769ec59..dd6d4c1f 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -65,7 +65,7 @@ Manage agents across the swarm * `create` β€” Queue creation of a new agent on a hive in this swarm * `mint-forge-token` β€” Check one agent's forge token, and mint it if it's missing or stale -* `inference` β€” Show or change the model, effort and subagent models the swarm sets for an agent +* `inference` β€” Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models @@ -122,7 +122,9 @@ Queues and returns, the same way `agent create` does β€” watch the swarm UI's jo ## `swarmctl agent inference` -Show or change the model, effort and subagent models the swarm sets for an agent. +Show the model, effort, subagent models and subagent cap the swarm sets for an agent, or change the model, effort and subagent models. + +`get` prints the subagent cap (`max_subagents`); `set` has no flag for it and keeps a stored one. Only swarm-ui sets it. A field the swarm sets wins over the agent's own value (its agent.nix or a `hyperhive-model`/`hyperhive-effort` file) from its next turn on; the agent reads its settings every ten seconds. A field the swarm doesn't set, or clears, leaves the agent on its own value. diff --git a/hive-agent/src/swarm_inference.rs b/hive-agent/src/swarm_inference.rs index 5d4dd6fa..b899d3fc 100644 --- a/hive-agent/src/swarm_inference.rs +++ b/hive-agent/src/swarm_inference.rs @@ -20,7 +20,7 @@ //! A failed read changes nothing. Until the first successful read after a //! start, the agent runs on its local values. //! -//! `subagent_models` is not read here. +//! `subagent_models` and `max_subagents` are not read here. use std::time::Duration; diff --git a/swarm-controller/README.md b/swarm-controller/README.md index dd8bf723..16b9b4f8 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -46,8 +46,9 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients. `agent-schedules`, both of which this daemon alone writes (`GET`/`POST /api/schedules`, `PATCH`/`DELETE /api/schedules/{id}`); each agent fires them itself β†’ [`docs/swarm/schedules.md`](../docs/swarm/schedules.md). -- **Inference settings** β€” each agent's model, effort and subagent models, - one key per agent in the `agent-inference` KV bucket, which this daemon +- **Inference settings** β€” each agent's model, effort, subagent models and + cap on concurrently running subagents, one key per agent in the + `agent-inference` KV bucket, which this daemon alone writes (`GET`/`PUT`/`DELETE /api/agents/{name}/inference`); each agent applies them itself. `GET` also answers the model and effort the agent last reported in its turn-state header, kept in memory β†’ diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index b2836536..e6276b16 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -246,8 +246,11 @@ enum AgentVerb { /// Queues and returns, the same way `agent create` does β€” watch the /// swarm UI's job view for the outcome. MintForgeToken(AgentMintForgeTokenArgs), - /// Show or change the model, effort and subagent models the swarm sets - /// for an agent. + /// Show the model, effort, subagent models and subagent cap the swarm sets + /// for an agent, or change the model, effort and subagent models. + /// + /// `get` prints the subagent cap (`max_subagents`); `set` has no flag for + /// it and keeps a stored one. Only swarm-ui sets it. /// /// A field the swarm sets wins over the agent's own value (its agent.nix /// or a `hyperhive-model`/`hyperhive-effort` file) from its next turn on;