Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-13 19:24:13 +02:00 • committed by mara
commit 9a71d1e094
10 changed files with 393 additions and 94 deletions

View file

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