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:
parent
6d7fc0dd99
commit
9a71d1e094
10 changed files with 393 additions and 94 deletions
|
|
@ -1,5 +1,6 @@
|
|||
//! `/api/agents/{name}/inference` — the model, effort and subagent models the
|
||||
//! swarm sets for one agent, and what the agent reports running on.
|
||||
//! `/api/agents/{name}/inference` — the model, effort, subagent models and
|
||||
//! subagent cap the swarm sets for one agent, and what the agent reports
|
||||
//! running on.
|
||||
//!
|
||||
//! Each agent's settings are one value under its own key of `agent-inference`
|
||||
//! (`swarm_queue_client::agent_inference`), which this daemon alone writes.
|
||||
|
|
@ -32,6 +33,11 @@ use crate::{AppState, error_problem, wanted_error_status};
|
|||
/// `wanted::MAX_ATTEMPTS`.
|
||||
const MAX_ATTEMPTS: usize = 5;
|
||||
|
||||
/// The highest `max_subagents` a `PUT` may set. Far more concurrent claude
|
||||
/// sessions than one agent container has memory for, so a value above it is a
|
||||
/// typo rather than a cap.
|
||||
const MAX_SUBAGENTS: u32 = 32;
|
||||
|
||||
/// One agent's inference settings. An absent field is not set at swarm level,
|
||||
/// and the agent keeps its own value for it.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, ToSchema)]
|
||||
|
|
@ -47,6 +53,10 @@ 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<Vec<String>>,
|
||||
/// 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<u32>,
|
||||
}
|
||||
|
||||
impl From<AgentInference> for InferenceSettings {
|
||||
|
|
@ -55,6 +65,7 @@ impl From<AgentInference> 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() {
|
||||
|
|
|
|||
Loading…
Reference in a new issue