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:
parent
47bd83054f
commit
7364ccdc98
3 changed files with 58 additions and 14 deletions
|
|
@ -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 |
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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]
|
||||
|
|
|
|||
Loading…
Reference in a new issue