Watch
0
0
Fork
You've already forked hyperhive
0

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<AgentInference>`: 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
This commit is contained in:
atlas 2026-10-10 00:32:36 +02:00 • committed by mara
commit 7364ccdc98
3 changed files with 58 additions and 14 deletions

View file

@ -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/<agent>/inference` | —; answers `{"configured": <the stored value, {} when none>, "effective": <what the agent reported, or null>}` |
| `GET` | `/api/agents/<agent>/inference` | —; answers `{"configured": <the stored value, or null>, "effective": <what the agent reported, or null>}` |
| `PUT` | `/api/agents/<agent>/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/<agent>/inference` | —; answers 204 |

View file

@ -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<InferenceSettings>,
/// 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::<AgentInference>(&store, &agent)
// `Option`'s default is the `None` an absent or deleted key reads as; a
// stored `{}` decodes to `Some`.
let (current, _) = read::<Option<AgentInference>>(&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<Effective>| {
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::<AgentInference>::default(), None);
assert_eq!(
serde_json::from_slice::<Option<AgentInference>>(b"{}").unwrap(),
Some(AgentInference::default())
);
}
}

View file

@ -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<Settings>,
effective: Option<Effective>,
}
@ -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]