Watch
0
0
Fork
You've already forked hyperhive
0

allow a max_subagents of 0: no subagents for the agent

This commit is contained in:
damocles 2026-10-15 18:48:22 +02:00 • committed by mara
commit a1f99c77f8
7 changed files with 85 additions and 31 deletions

View file

@ -23,7 +23,7 @@ swarmctl agent inference clear iris
([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)). ([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)).
- You set the subagent cap, `max_subagents`, in the swarm UI's inference - 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 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)). ([`subagent.md`](../tools/subagent.md#how-many-subagents-may-run-at-once)).
- `get` shows each field, naming the unset ones (or says the swarm sets - `get` shows each field, naming the unset ones (or says the swarm sets
nothing for the agent), and then the model and effort the agent reports nothing for the agent), and then the model and effort the agent reports
@ -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 such as one a newer build wrote, also answers `"configured": null`, with a
warning in its log, until the next `PUT` replaces it. warning in its log, until the next `PUT` replaces it.
| method | path | body | | method | path | body |
| -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/api/agents/<agent>/inference` | —; answers `{"configured": <the stored value, or null>, "effective": <what the agent reported, or null>}` | | `GET` | `/api/agents/<agent>/inference` | —; answers `{"configured": <the stored value, or null>, "effective": <what the agent reported, or null>}` |
| `PUT` | `/api/agents/<agent>/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 | | `PUT` | `/api/agents/<agent>/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/<agent>/inference` | —; answers 204 | | `DELETE` | `/api/agents/<agent>/inference` | —; answers 204 |
An agent's own queue credential grants it An agent's own queue credential grants it
`$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.<agent>`, the `$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.<agent>`, the

View file

@ -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. cap never stops it. Lowering the cap stops nothing already running.
The daemon reads the cap with the model list, on every `start` and 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 `continue`, so a change applies to the next call. A cap of 0 refuses every
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. `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 ## Runtime

View file

@ -21,6 +21,7 @@ import { SelectField } from "../../ui/select-field/SelectField.js";
import { TextField } from "../../ui/text-field/TextField.js"; import { TextField } from "../../ui/text-field/TextField.js";
import { Button } from "../../ui/button/Button.js"; import { Button } from "../../ui/button/Button.js";
import "./AgentInference.css"; import "./AgentInference.css";
import { MAX_SUBAGENTS, maxSubagentsValue } from "./max-subagents.js";
type Source = "swarm" | "agent_nix" | "local_file" | "default"; type Source = "swarm" | "agent_nix" | "local_file" | "default";
@ -67,18 +68,6 @@ function modelNameProblem(name: string): string | null {
return null; return null;
} }
// The highest `max_subagents` the swarm accepts.
const MAX_SUBAGENTS = 32;
// `max_subagents` as typed: empty is no cap, else a whole number in range.
function maxSubagentsValue(text: string): number | null | undefined {
const t = text.trim();
if (t === "") return undefined;
if (!/^\d+$/.test(t)) return null;
const n = Number(t);
return n >= 1 && n <= MAX_SUBAGENTS ? n : null;
}
// Subagent models are typed as one list, split on commas and whitespace. // Subagent models are typed as one list, split on commas and whitespace.
function parseModels(text: string): string[] { function parseModels(text: string): string[] {
return text.split(/[\s,]+/).filter((m) => m !== ""); return text.split(/[\s,]+/).filter((m) => m !== "");
@ -112,7 +101,7 @@ export function draftProblem(d: InferenceDraft): string | null {
.map(modelNameProblem) .map(modelNameProblem)
.find((p) => p !== null) ?? .find((p) => p !== null) ??
(maxSubagentsValue(d.maxSubagents) === 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) : null)
); );
} }
@ -273,7 +262,11 @@ export function AgentInference({ agent }: { agent: string }) {
<li> <li>
<Badge <Badge
label="max subagents" label="max subagents"
value={String(configured?.max_subagents ?? "no cap")} value={
configured?.max_subagents === 0
? "0 (no subagents)"
: String(configured?.max_subagents ?? "no cap")
}
/> />
</li> </li>
</ul> </ul>

View file

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

View file

@ -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);
}
});

View file

@ -2590,6 +2590,10 @@ pub fn interrupt(state: &State, name: &str) -> anyhow::Result<String> {
fn refused(state: &State, name: &str, refusal: &Refusal, hint: &str) -> anyhow::Error { fn refused(state: &State, name: &str, refusal: &Refusal, hint: &str) -> anyhow::Error {
match refusal { match refusal {
Refusal::Taken => name_taken(state, name, hint), 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!( Refusal::AtCap { running, cap } => anyhow::anyhow!(
"{running} {} running, cap is {cap} (set in swarm-ui → this agent → \ "{running} {} running, cap is {cap} (set in swarm-ui → this agent → \
inference) — wait for one to finish, or `interrupt` one", 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. /// A run between turns and one winding down both still count.
#[test] #[test]
fn every_tracked_slot_counts_toward_the_cap() { fn every_tracked_slot_counts_toward_the_cap() {

View file

@ -53,8 +53,8 @@ pub struct InferenceSettings {
/// The models the agent may start its subagents on. At least one. /// The models the agent may start its subagents on. At least one.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
subagent_models: Option<Vec<String>>, subagent_models: Option<Vec<String>>,
/// How many subagents the agent may have running at once, from 1 to /// How many subagents the agent may have running at once, from 0 (no
/// [`MAX_SUBAGENTS`]. Absent is no cap. /// subagents) to [`MAX_SUBAGENTS`]. Absent is no cap.
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
max_subagents: Option<u32>, max_subagents: Option<u32>,
} }
@ -107,10 +107,10 @@ impl InferenceSettings {
} }
} }
if let Some(max) = self.max_subagents if let Some(max) = self.max_subagents
&& !(1..=MAX_SUBAGENTS).contains(&max) && max > MAX_SUBAGENTS
{ {
return Err(format!( 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 { Ok(AgentInference {
@ -286,7 +286,7 @@ pub(crate) async fn get_agent_inference(
ones whole", body = InferenceSettings), ones whole", body = InferenceSettings),
(status = 400, description = "not an identifier, not declared on any hive, or a \ (status = 400, description = "not an identifier, not declared on any hive, or a \
model name is empty, has whitespace, starts with `-`, or is listed \ 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), body = String),
(status = 422, description = "an unknown field, or an effort that is not one of \ (status = 422, description = "an unknown field, or an effort that is not one of \
the five levels"), the five levels"),
@ -558,15 +558,18 @@ mod tests {
#[test] #[test]
fn max_subagents_outside_its_range_is_refused() { fn max_subagents_outside_its_range_is_refused() {
for json in [r#"{"max_subagents":0}"#, r#"{"max_subagents":33}"#] { assert!(
assert!(settings(json).unwrap().into_stored().is_err(), "{json}"); settings(r#"{"max_subagents":33}"#)
} .unwrap()
.into_stored()
.is_err()
);
assert!(settings(r#"{"max_subagents":-1}"#).is_err()); assert!(settings(r#"{"max_subagents":-1}"#).is_err());
} }
#[test] #[test]
fn max_subagents_in_range_is_stored() { 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}}}"#)) let stored = settings(&format!(r#"{{"max_subagents":{max}}}"#))
.unwrap() .unwrap()
.into_stored() .into_stored()