allow a max_subagents of 0: no subagents for the agent
This commit is contained in:
parent
7539ef82f7
commit
a1f99c77f8
7 changed files with 85 additions and 31 deletions
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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>
|
||||||
|
|
|
||||||
17
frontend/packages/swarm-ui/src/agent/detail/max-subagents.ts
Normal file
17
frontend/packages/swarm-ui/src/agent/detail/max-subagents.ts
Normal 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;
|
||||||
|
}
|
||||||
26
frontend/packages/swarm-ui/test/max-subagents.test.mjs
Normal file
26
frontend/packages/swarm-ui/test/max-subagents.test.mjs
Normal 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);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
@ -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() {
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue