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

@ -1,11 +1,13 @@
//! Reads the subagent model list the swarm sets for this agent.
//! Reads the subagent settings the swarm sets for this agent.
//!
//! swarm-controller keeps the agent's settings under its key in the
//! `agent-inference` KV bucket. Its `subagent_models`, when set, is the list
//! a `start` or `continue` naming a `model` is checked against, in place of
//! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`).
//! `HIVE_AVAILABLE_MODELS` (`crate::session::check_model`). Its
//! `max_subagents`, when set, caps how many subagents run at once
//! (`crate::session::State::reserve_under`).
//!
//! **Read on every such call**, never cached, with the one subject the
//! **Read on every `start` and `continue`**, never cached, with the one subject the
//! agent's own queue credential is granted in that bucket
//! (`swarm_queue_client::agent_inference::read_own`). The connection is made
//! the way [`crate::swarm_term`] makes its own: as the agent, never as the
@ -13,14 +15,15 @@
//!
//! **A subagent start never waits on the queue.** Before the first connect
//! succeeds, and when a read fails or outlasts `READ_TIMEOUT`, the read is
//! [`Read::Failed`] and the caller checks against `HIVE_AVAILABLE_MODELS`.
//! [`Read::Failed`]: the caller checks against `HIVE_AVAILABLE_MODELS` and
//! applies no cap.
#[cfg(test)]
use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use swarm_queue_client::agent_inference::{self, read_own};
use swarm_queue_client::agent_inference::{self, AgentInference, read_own};
use crate::swarm_term::{Target, connect};
@ -32,13 +35,12 @@ const RETRY_AFTER: Duration = Duration::from_mins(1);
/// misgranted agent's `start` waits.
const READ_TIMEOUT: Duration = Duration::from_secs(2);
/// What one read of this agent's key said about `subagent_models`.
/// What one read of this agent's key found.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Read {
/// The swarm sets the list.
Set(Vec<String>),
/// The key, the field, or the bucket is absent.
Unset,
/// The settings the swarm holds; an absent key or bucket has every field
/// unset.
Current(AgentInference),
/// Nothing could be read, and why.
Failed(String),
}
@ -70,7 +72,7 @@ impl Reader {
Ok(None) => {
tracing::info!(
"no swarm queue address or no secret store for this agent; subagent models \
are checked against HIVE_AVAILABLE_MODELS only"
are checked against HIVE_AVAILABLE_MODELS only, and no subagent cap applies"
);
return None;
}
@ -78,7 +80,7 @@ impl Reader {
tracing::warn!(
error = format!("{e:#}"),
"this agent's secret store coordinates are incomplete; subagent models are \
checked against HIVE_AVAILABLE_MODELS only"
checked against HIVE_AVAILABLE_MODELS only, and no subagent cap applies"
);
return None;
}
@ -103,7 +105,7 @@ impl Reader {
(Self { source }, reads)
}
/// This agent's `subagent_models` as the swarm holds it now.
/// This agent's settings as the swarm holds them now.
pub async fn read(&self) -> Read {
match &self.source {
Source::Queue { agent, client } => read_queue(agent, client).await,
@ -128,12 +130,10 @@ async fn read_queue(agent: &str, client: &OnceLock<async_nats::Client>) -> Read
Err(_) => Read::Failed(format!("no answer within {READ_TIMEOUT:?}")),
Ok(Err(e)) => Read::Failed(e.to_string()),
Ok(Ok(agent_inference::Read::NoBucket)) => {
tracing::debug!("subagent models: the agent-inference bucket does not answer");
Read::Unset
}
Ok(Ok(agent_inference::Read::Current(current))) => {
current.subagent_models.map_or(Read::Unset, Read::Set)
tracing::debug!("subagent settings: the agent-inference bucket does not answer");
Read::Current(AgentInference::default())
}
Ok(Ok(agent_inference::Read::Current(current))) => Read::Current(current),
}
}
@ -142,7 +142,7 @@ async fn connect_until_up(target: Arc<Target>, client: Arc<OnceLock<async_nats::
loop {
match connect(&target).await {
Ok(connected) => {
tracing::info!(url = %target.url, "subagent models: connected as this agent");
tracing::info!(url = %target.url, "subagent settings: connected as this agent");
let _already_set = client.set(connected);
return;
}
@ -150,8 +150,8 @@ async fn connect_until_up(target: Arc<Target>, client: Arc<OnceLock<async_nats::
tracing::warn!(
error = format!("{e:#}"),
retry_in = ?RETRY_AFTER,
"subagent models: connecting with this agent's own queue credential failed; \
HIVE_AVAILABLE_MODELS applies until a connect succeeds"
"subagent settings: connecting with this agent's own queue credential failed; \
HIVE_AVAILABLE_MODELS and no subagent cap apply until a connect succeeds"
);
tokio::time::sleep(RETRY_AFTER).await;
}