diff --git a/docs/swarm/inference.md b/docs/swarm/inference.md index c4fd4505..00c5b6ab 100644 --- a/docs/swarm/inference.md +++ b/docs/swarm/inference.md @@ -1,4 +1,4 @@ -or a model name with whitespace or a leading `-`, or a `max_subagents` outside 1 to 32 |# Per-agent inference settings +# Per-agent inference settings The swarm can set the model and reasoning effort each agent's turns run on, plus the models it may start subagents on and how many subagents it may run @@ -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 warning in its log, until the next `PUT` replaces it. -| method | path | body | -| -------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/agents//inference` | —; answers `{"configured": , "effective": }` | -| `PUT` | `/api/agents//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 `-` | -| `DELETE` | `/api/agents//inference` | —; answers 204 | +| method | path | body | +| -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `GET` | `/api/agents//inference` | —; answers `{"configured": , "effective": }` | +| `PUT` | `/api/agents//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 | +| `DELETE` | `/api/agents//inference` | —; answers 204 | An agent's own queue credential grants it `$JS.API.DIRECT.GET.KV_agent-inference.$KV.agent-inference.`, the diff --git a/docs/tools/subagent.md b/docs/tools/subagent.md index 9c1ad469..287bff8d 100644 --- a/docs/tools/subagent.md +++ b/docs/tools/subagent.md @@ -355,7 +355,7 @@ cap never stops it. Lowering the cap stops nothing already running. 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 -when the read fails, no cap applies. +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 diff --git a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx index 92c5fd74..b7ebca60 100644 --- a/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx +++ b/frontend/packages/swarm-ui/src/agent/detail/AgentInference.tsx @@ -310,6 +310,7 @@ function InferenceForm({ label="max subagents" value={maxSubagents} placeholder="empty for no cap" + pattern="[0-9]*" onInput={setMaxSubagents} /> {formProblem ? ( diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index f6377784..202be430 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -560,7 +560,11 @@ impl State { Some(reader) => Some(reader.read().await), None => None, }; - let cap = subagent_cap(read.as_ref()); + let agent = self + .models + .as_ref() + .map_or("", crate::swarm_models::Reader::agent); + let cap = subagent_cap(read.as_ref(), agent); if model.is_some() { check_model(model, &model_list(read, (self.local_models)()))?; } @@ -1089,13 +1093,14 @@ fn model_list(read: Option, local: Option) -> /// The swarm's `max_subagents` for this agent, or `None`, no cap: when it /// sets none, when `read` is `None` because this daemon has no swarm queue, -/// or when the read failed, which is logged. -fn subagent_cap(read: Option<&crate::swarm_models::Read>) -> Option { +/// or when the read failed, which is logged as a warning naming the agent and the error. +fn subagent_cap(read: Option<&crate::swarm_models::Read>, agent: &str) -> Option { use crate::swarm_models::Read; match read? { Read::Current(settings) => settings.max_subagents, Read::Failed(error) => { tracing::warn!( + agent, %error, "reading this agent's subagent settings from the swarm failed; applying no \ subagent cap" @@ -4697,11 +4702,29 @@ mod tests { #[test] fn the_cap_is_the_swarm_value_and_none_otherwise() { - assert_eq!(subagent_cap(Some(&capped(3))), Some(3)); - assert_eq!(subagent_cap(Some(&unset())), None); - assert_eq!(subagent_cap(None), None); + assert_eq!(subagent_cap(Some(&capped(3)), "a"), Some(3)); + assert_eq!(subagent_cap(Some(&unset()), "a"), None); + assert_eq!(subagent_cap(None, "a"), None); let failed = crate::swarm_models::Read::Failed("no answer".to_owned()); - assert_eq!(subagent_cap(Some(&failed)), None); + assert_eq!(subagent_cap(Some(&failed), "a"), None); + } + + #[test] + fn a_failed_cap_read_warns_naming_the_agent_and_the_error() { + let logs = CapturedLogs::default(); + let subscriber = tracing_subscriber::fmt() + .with_writer(logs.clone()) + .with_ansi(false) + .finish(); + let failed = crate::swarm_models::Read::Failed("no answer".to_owned()); + let cap = + tracing::subscriber::with_default(subscriber, || subagent_cap(Some(&failed), "iris")); + assert_eq!(cap, None); + let logged = logs.text(); + assert!( + logged.contains("WARN") && logged.contains("iris") && logged.contains("no answer"), + "{logged}" + ); } /// `start` and `continue` read the cap live: the swarm's cap of 1 refuses diff --git a/hive-subagent-mcp/src/swarm_models.rs b/hive-subagent-mcp/src/swarm_models.rs index 9cf482a8..e6be9a4b 100644 --- a/hive-subagent-mcp/src/swarm_models.rs +++ b/hive-subagent-mcp/src/swarm_models.rs @@ -105,6 +105,16 @@ impl Reader { (Self { source }, reads) } + /// The agent whose key is read; `"test"` for a fixed reader. + #[must_use] + pub fn agent(&self) -> &str { + match &self.source { + Source::Queue { agent, .. } => agent, + #[cfg(test)] + Source::Fixed { .. } => "test", + } + } + /// This agent's settings as the swarm holds them now. pub async fn read(&self) -> Read { match &self.source {