From 7364ccdc98bc0ba6f62150a810f127c892492a72 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 10 Oct 2026 00:32:36 +0200 Subject: [PATCH] swarm-controller: GET inference answers configured: null without a key `GET /api/agents/{name}/inference` answered `"configured": {}` both for an agent with no key in `agent-inference` and for a stored value with no fields, so a reader could not tell "the swarm sets nothing" from "a setting with every field unset". It now reads the key as `Option`: an absent or deleted key is `null`, a stored `{}` stays `{}`. The shared `schedules::read` helper is unchanged. `swarmctl agent inference get` prints "no swarm setting; the agent runs on its own model and effort" for `null`; `set` merges into an empty value in that case, as before. The agent itself still treats no key and `{}` the same. Refs #5034 --- docs/swarm/inference.md | 11 +++++++---- swarm-controller/src/inference.rs | 32 ++++++++++++++++++++++++++----- swarmctl/src/inference.rs | 29 +++++++++++++++++++++++----- 3 files changed, 58 insertions(+), 14 deletions(-) diff --git a/docs/swarm/inference.md b/docs/swarm/inference.md index 631f1e71..1b71df78 100644 --- a/docs/swarm/inference.md +++ b/docs/swarm/inference.md @@ -21,8 +21,9 @@ swarmctl agent inference clear iris `continue` calls may name, read on each call ([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)). The web UI's model picker doesn't use it. -- `get` shows each field, naming the unset ones, and then the model and - effort the agent reports running on, each with where it comes from. +- `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 + running on, each with where it comes from. - `clear` removes every field; the agent goes back to its own values. The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). @@ -80,11 +81,13 @@ which swarm-controller alone writes: { "model": "opus", "effort": "high", "subagent_models": ["haiku", "sonnet"] } ``` -Every field is optional, and `{}` reads the same as no key. +Every field is optional. The agent runs the same with `{}` as with no key; +`GET` answers `"configured": null` for no key (or a cleared one) and `{}` for +a stored value with no fields. | method | path | body | | -------- | ------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- | -| `GET` | `/api/agents//inference` | —; answers `{"configured": , "effective": }` | +| `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 | diff --git a/swarm-controller/src/inference.rs b/swarm-controller/src/inference.rs index 1f747d12..7b9a89b5 100644 --- a/swarm-controller/src/inference.rs +++ b/swarm-controller/src/inference.rs @@ -106,8 +106,9 @@ impl InferenceSettings { /// What `GET` answers for one agent. #[derive(Debug, Serialize, ToSchema)] pub struct AgentInferenceView { - /// The settings the swarm sets; `{}` when none is set. - configured: InferenceSettings, + /// The settings the swarm sets; `null` when it has none for the agent (no + /// key, or a cleared one), `{}` for a stored setting with no fields. + configured: Option, /// What the agent reported running on in its latest turn-state header; /// `null` when this daemon has received none with these fields since it /// started. @@ -241,11 +242,13 @@ pub(crate) async fn get_agent_inference( let agent = agent_name(&name).map_err(refused)?; let client = queue(&state).map_err(refused)?; let store = open(&client).await?; - let (current, _) = read::(&store, &agent) + // `Option`'s default is the `None` an absent or deleted key reads as; a + // stored `{}` decodes to `Some`. + let (current, _) = read::>(&store, &agent) .await .map_err(|e| queue_problem(&e))?; Ok(Json(AgentInferenceView { - configured: current.into(), + configured: current.map(Into::into), effective: state .reported_inference .as_ref() @@ -444,7 +447,7 @@ mod tests { fn the_view_answers_both_halves() { let view = |effective: Option| { serde_json::to_value(AgentInferenceView { - configured: InferenceSettings::default(), + configured: Some(InferenceSettings::default()), effective: effective.map(Into::into), }) .unwrap() @@ -453,6 +456,14 @@ mod tests { view(None), serde_json::json!({"configured": {}, "effective": null}) ); + let unset = AgentInferenceView { + configured: None, + effective: None, + }; + assert_eq!( + serde_json::to_value(unset).unwrap(), + serde_json::json!({"configured": null, "effective": null}) + ); let reported = Reported::default(); reported.record(&iris(), NEW_HEADER); assert_eq!( @@ -484,4 +495,15 @@ mod tests { let json = r#"{"model":"claude-opus-4-8","subagent_models":["claude-haiku-4-5"]}"#; assert!(settings(json).unwrap().into_stored().is_ok()); } + + /// What `get_agent_inference` reads with: an absent or deleted key is + /// `Option`'s default, `None`; a stored `{}` is a setting with no fields. + #[test] + fn no_key_and_an_empty_setting_read_apart() { + assert_eq!(Option::::default(), None); + assert_eq!( + serde_json::from_slice::>(b"{}").unwrap(), + Some(AgentInference::default()) + ); + } } diff --git a/swarmctl/src/inference.rs b/swarmctl/src/inference.rs index 6cdb8db5..8e3f866b 100644 --- a/swarmctl/src/inference.rs +++ b/swarmctl/src/inference.rs @@ -30,7 +30,7 @@ struct Settings { /// What `GET` answers: the settings, and what the agent reported running on. #[derive(Debug, Deserialize)] struct View { - configured: Settings, + configured: Option, effective: Option, } @@ -90,7 +90,7 @@ pub(crate) fn get(socket: &Path, agent: &str) -> Result<()> { ))?; print!( "{}{}", - render(&view.configured), + render_configured(view.configured.as_ref()), render_effective(view.effective.as_ref()) ); Ok(()) @@ -108,7 +108,7 @@ pub(crate) fn set(socket: &Path, agent: &str, flags: SetFlags) -> Result<()> { None::<&()>, "inference-get", ))?; - let wanted = flags.merge(current.configured)?; + let wanted = flags.merge(current.configured.unwrap_or_default())?; let stored: Settings = rt.block_on(send( socket, hyper::Method::PUT, @@ -134,6 +134,14 @@ pub(crate) fn clear(socket: &Path, agent: &str) -> Result<()> { Ok(()) } +/// The swarm's setting, or one line saying it has none. +fn render_configured(s: Option<&Settings>) -> String { + s.map_or_else( + || "no swarm setting; the agent runs on its own model and effort\n".to_owned(), + render, + ) +} + /// One line per field, naming the unset ones. fn render(s: &Settings) -> String { const UNSET: &str = "(not set; the agent's own)"; @@ -213,13 +221,24 @@ mod tests { "model_source":"swarm","effort_source":"local_file"}}"#, ) .unwrap(); - assert_eq!(view.configured.model.as_deref(), Some("opus")); + assert_eq!( + view.configured.as_ref().and_then(|s| s.model.as_deref()), + Some("opus") + ); assert_eq!( render_effective(view.effective.as_ref()), "running model: opus (swarm)\nrunning effort: medium (local_file)\n" ); - let unknown: View = serde_json::from_str(r#"{"configured":{},"effective":null}"#).unwrap(); + let unknown: View = + serde_json::from_str(r#"{"configured":null,"effective":null}"#).unwrap(); + assert!(render_configured(unknown.configured.as_ref()).starts_with("no swarm setting")); assert!(render_effective(unknown.effective.as_ref()).starts_with("running: not reported")); + // A stored setting with no fields still lists every field as unset. + let empty: View = serde_json::from_str(r#"{"configured":{},"effective":null}"#).unwrap(); + assert_eq!( + render_configured(empty.configured.as_ref()).lines().count(), + 3 + ); } #[test]