From a1f99c77f88dd5bdf7a686b7bd2ae1b6b1849ab6 Mon Sep 17 00:00:00 2001 From: damocles Date: Thu, 15 Oct 2026 18:48:22 +0200 Subject: [PATCH] allow a max_subagents of 0: no subagents for the agent --- docs/swarm/inference.md | 12 ++++----- docs/tools/subagent.md | 4 +-- .../src/agent/detail/AgentInference.tsx | 21 +++++---------- .../src/agent/detail/max-subagents.ts | 17 ++++++++++++ .../swarm-ui/test/max-subagents.test.mjs | 26 +++++++++++++++++++ hive-subagent-mcp/src/session.rs | 15 +++++++++++ swarm-controller/src/inference.rs | 21 ++++++++------- 7 files changed, 85 insertions(+), 31 deletions(-) create mode 100644 frontend/packages/swarm-ui/src/agent/detail/max-subagents.ts create mode 100644 frontend/packages/swarm-ui/test/max-subagents.test.mjs diff --git a/docs/swarm/inference.md b/docs/swarm/inference.md index 00c5b6ab..8ed30eb1 100644 --- a/docs/swarm/inference.md +++ b/docs/swarm/inference.md @@ -23,7 +23,7 @@ swarmctl agent inference clear iris ([`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 + number from 0 to 32, where 0 allows no subagents; 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 @@ -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 `-`, or a `max_subagents` outside 1 to 32 | -| `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` above 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 3d35c8f7..b0832325 100644 --- a/docs/tools/subagent.md +++ b/docs/tools/subagent.md @@ -354,8 +354,8 @@ 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. A failed read logs a warning naming the agent and the error, and the start proceeds: an unreachable swarm must not block subagent work. +`continue`, so a change applies to the next call. A cap of 0 refuses every +`start` and `continue`, and the message says the cap is 0. Without a swarm value, or 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 4bea9102..f59c520e 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx @@ -21,6 +21,7 @@ import { SelectField } from "../../ui/select-field/SelectField.js"; import { TextField } from "../../ui/text-field/TextField.js"; import { Button } from "../../ui/button/Button.js"; import "./AgentInference.css"; +import { MAX_SUBAGENTS, maxSubagentsValue } from "./max-subagents.js"; type Source = "swarm" | "agent_nix" | "local_file" | "default"; @@ -67,18 +68,6 @@ 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 !== ""); @@ -112,7 +101,7 @@ export function draftProblem(d: InferenceDraft): string | null { .map(modelNameProblem) .find((p) => p !== null) ?? (maxSubagentsValue(d.maxSubagents) === null - ? `max subagents is a whole number from 1 to ${MAX_SUBAGENTS}, or empty for no cap` + ? `max subagents is a whole number from 0 (no subagents) to ${MAX_SUBAGENTS}, or empty for no cap` : null) ); } @@ -273,7 +262,11 @@ export function AgentInference({ agent }: { agent: string }) {
  • diff --git a/frontend/packages/swarm-ui/src/agent/detail/max-subagents.ts b/frontend/packages/swarm-ui/src/agent/detail/max-subagents.ts new file mode 100644 index 00000000..4ad8a158 --- /dev/null +++ b/frontend/packages/swarm-ui/src/agent/detail/max-subagents.ts @@ -0,0 +1,17 @@ +// The create form's and the inference dialog's `max_subagents` field, kept out +// of the component so `npm test` can run it. + +/** The highest `max_subagents` the swarm accepts. */ +export const MAX_SUBAGENTS = 32; + +/** + * `max_subagents` as typed: `undefined` for empty (no cap), the number for a + * whole number from 0 (no subagents) to [`MAX_SUBAGENTS`], else `null`. + */ +export 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 <= MAX_SUBAGENTS ? n : null; +} diff --git a/frontend/packages/swarm-ui/test/max-subagents.test.mjs b/frontend/packages/swarm-ui/test/max-subagents.test.mjs new file mode 100644 index 00000000..df39d37c --- /dev/null +++ b/frontend/packages/swarm-ui/test/max-subagents.test.mjs @@ -0,0 +1,26 @@ +// The max-subagents field: empty is no cap, 0 is no subagents, and anything +// that is not a whole number up to the limit is refused. Run by `npm test`. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { + MAX_SUBAGENTS, + maxSubagentsValue, +} from "../src/agent/detail/max-subagents.ts"; + +test("empty is no cap, and 0 is a cap of none rather than empty", () => { + assert.equal(maxSubagentsValue(""), undefined); + assert.equal(maxSubagentsValue(" "), undefined); + assert.equal(maxSubagentsValue("0"), 0); +}); + +test("a whole number up to the limit is taken as typed", () => { + assert.equal(maxSubagentsValue("1"), 1); + assert.equal(maxSubagentsValue(" 3 "), 3); + assert.equal(maxSubagentsValue(String(MAX_SUBAGENTS)), MAX_SUBAGENTS); +}); + +test("above the limit, negative, fractional or not a number is refused", () => { + for (const text of [String(MAX_SUBAGENTS + 1), "-1", "1.5", "abc", "1e1"]) { + assert.equal(maxSubagentsValue(text), null, text); + } +}); diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index 6231dec6..33e38989 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -2590,6 +2590,10 @@ pub fn interrupt(state: &State, name: &str) -> anyhow::Result { fn refused(state: &State, name: &str, refusal: &Refusal, hint: &str) -> anyhow::Error { match refusal { Refusal::Taken => name_taken(state, name, hint), + Refusal::AtCap { cap: 0, .. } => anyhow::anyhow!( + "subagents are disabled for this agent: the cap is 0 (set in swarm-ui → this \ + agent → inference)" + ), 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", @@ -4688,6 +4692,17 @@ mod tests { ); } + #[test] + fn a_cap_of_zero_refuses_every_start() { + let state = State::new(PathBuf::from("/dev/null"), signal_url()); + assert_eq!( + state.reserve_under("a", Some(0)), + Err(Refusal::AtCap { running: 0, cap: 0 }) + ); + let msg = refused(&state, "a", &Refusal::AtCap { running: 0, cap: 0 }, "").to_string(); + assert!(msg.contains("disabled"), "{msg}"); + } + /// A run between turns and one winding down both still count. #[test] fn every_tracked_slot_counts_toward_the_cap() { diff --git a/swarm-controller/src/inference.rs b/swarm-controller/src/inference.rs index 73cab8b1..71130a9c 100644 --- a/swarm-controller/src/inference.rs +++ b/swarm-controller/src/inference.rs @@ -53,8 +53,8 @@ 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>, - /// How many subagents the agent may have running at once, from 1 to - /// [`MAX_SUBAGENTS`]. Absent is no cap. + /// How many subagents the agent may have running at once, from 0 (no + /// subagents) to [`MAX_SUBAGENTS`]. Absent is no cap. #[serde(default, skip_serializing_if = "Option::is_none")] max_subagents: Option, } @@ -107,10 +107,10 @@ impl InferenceSettings { } } if let Some(max) = self.max_subagents - && !(1..=MAX_SUBAGENTS).contains(&max) + && max > MAX_SUBAGENTS { return Err(format!( - "max_subagents is {max}; it is from 1 to {MAX_SUBAGENTS}, or left out for no cap" + "max_subagents is {max}; it is from 0 (no subagents) to {MAX_SUBAGENTS}, or left out for no cap" )); } Ok(AgentInference { @@ -286,7 +286,7 @@ pub(crate) async fn get_agent_inference( 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, or `max_subagents` is outside 1 to 32 (problem+json)", + twice, or `max_subagents` is above 32 (problem+json)", body = String), (status = 422, description = "an unknown field, or an effort that is not one of \ the five levels"), @@ -558,15 +558,18 @@ 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":33}"#) + .unwrap() + .into_stored() + .is_err() + ); assert!(settings(r#"{"max_subagents":-1}"#).is_err()); } #[test] fn max_subagents_in_range_is_stored() { - for max in [1, 3, MAX_SUBAGENTS] { + for max in [0, 1, 3, MAX_SUBAGENTS] { let stored = settings(&format!(r#"{{"max_subagents":{max}}}"#)) .unwrap() .into_stored()