swarm: show each agent's effective model and effort, and where they came from
Each agent now reports the model and effort it runs on, and where each
comes from (`swarm`, `agent_nix`, `local_file` or `default`), in its
turn-state header on `$SWARM.agent-state.<agent>`: `effort`,
`model_source` and `effort_source` join `model`. The shared
`swarm_queue_client::agent_inference::{Effective, Source}` types spell
those fields for both ends.
The Bus keeps a source next to the model and effort. `Bus::new` takes it
from the local fallback chain (`harness_state::local_model` /
`local_effort`, which now return the source with the value), a `/model`
or `/effort` pick sets `local_file`, and the swarm poller sets `swarm`
or, on a clear, the local chain's source. A swarm value equal to the one
the agent already runs is now applied once, so the source moves to
`swarm` and the header is republished; before, `next()` skipped it as
unchanged.
swarm-controller subscribes to every agent's header with the reader
grant it already holds for the state relay, and keeps the latest
inference fields per agent in memory (`inference::Reported`).
`GET /api/agents/{name}/inference` now answers
`{configured, effective}`, `effective` being `null` until the agent's
first header with these fields since the controller started.
`swarmctl agent inference get` prints both; `set` reads `configured`.
Refs #5034
This commit is contained in:
parent
6982b2ec14
commit
d723fac0be
20 changed files with 655 additions and 113 deletions
|
|
@ -31,6 +31,7 @@
|
|||
use serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
||||
use swarm_queue_client::agent_inference::Effective;
|
||||
use swarm_queue_client::wanted::AgentState;
|
||||
|
||||
use crate::events::{Bus, BusEvent, LiveEvent, TurnState};
|
||||
|
|
@ -72,8 +73,11 @@ struct AgentStateMsg {
|
|||
/// which documents why only two of the four variants can ever appear
|
||||
/// here.
|
||||
agent_state: AgentState,
|
||||
/// The alias `claude --model` was invoked with (e.g. `"sonnet"`).
|
||||
model: String,
|
||||
/// `model`, `effort`, `model_source` and `effort_source`: what the next
|
||||
/// turn runs on and where each comes from. swarm-controller decodes
|
||||
/// these four out of the header with the same type.
|
||||
#[serde(flatten)]
|
||||
inference: Effective,
|
||||
/// The concrete model id the most recent completed turn actually ran
|
||||
/// on, resolved from that turn's assistant events. `None` before any
|
||||
/// turn has completed.
|
||||
|
|
@ -126,7 +130,12 @@ fn snapshot(bus: &Bus) -> AgentStateMsg {
|
|||
turn_state_since: iso8601_utc(since_unix),
|
||||
agent_state: observed_state(),
|
||||
context_window_tokens: bus.effective_context_window(&model),
|
||||
model,
|
||||
inference: Effective {
|
||||
model,
|
||||
effort: bus.effort(),
|
||||
model_source: bus.model_source(),
|
||||
effort_source: bus.effort_source(),
|
||||
},
|
||||
resolved_model: bus.last_resolved_model(),
|
||||
ctx_usage: bus.last_ctx_usage(),
|
||||
cost_usage: bus.last_cost_usage(),
|
||||
|
|
@ -146,7 +155,7 @@ pub fn spawn(bus: &Bus) {
|
|||
/// Watch the bus and publish whenever the header actually changed.
|
||||
///
|
||||
/// **Why the change test is on the serialized bytes rather than on which
|
||||
/// event arrived.** The header is assembled from six independent pieces of
|
||||
/// event arrived.** The header is assembled from independent pieces of
|
||||
/// `Bus` state, only some of which announce themselves with an event of
|
||||
/// their own — `set_resolved_model`, for one, emits nothing and lands
|
||||
/// alongside the `TokenUsageChanged` of the same turn. An allow-list of
|
||||
|
|
@ -251,6 +260,7 @@ async fn publish(
|
|||
mod tests {
|
||||
use super::AgentStateMsg;
|
||||
use crate::events::TurnState;
|
||||
use swarm_queue_client::agent_inference::{Effective, Source};
|
||||
use swarm_queue_client::wanted::AgentState;
|
||||
|
||||
/// `TokenUsage` is `#[non_exhaustive]`, so a downstream crate cannot
|
||||
|
|
@ -274,7 +284,12 @@ mod tests {
|
|||
// As `iso8601_utc` renders a transition time.
|
||||
turn_state_since: "2026-09-13T12:35:03Z".to_owned(),
|
||||
agent_state: AgentState::Paused,
|
||||
model: "sonnet".to_owned(),
|
||||
inference: Effective {
|
||||
model: "sonnet".to_owned(),
|
||||
effort: "high".to_owned(),
|
||||
model_source: Source::Swarm,
|
||||
effort_source: Source::AgentNix,
|
||||
},
|
||||
resolved_model: Some("claude-sonnet-4-5-20260805".to_owned()),
|
||||
context_window_tokens: 200_000,
|
||||
ctx_usage: Some(usage(&serde_json::json!({
|
||||
|
|
@ -314,7 +329,10 @@ mod tests {
|
|||
"context_window_tokens",
|
||||
"cost_usage",
|
||||
"ctx_usage",
|
||||
"effort",
|
||||
"effort_source",
|
||||
"model",
|
||||
"model_source",
|
||||
"resolved_model",
|
||||
"turn_state",
|
||||
"turn_state_since",
|
||||
|
|
@ -322,6 +340,14 @@ mod tests {
|
|||
);
|
||||
}
|
||||
|
||||
/// swarm-controller reads the inference fields back out of a header.
|
||||
#[test]
|
||||
fn the_header_decodes_as_its_inference_settings() {
|
||||
let bytes = serde_json::to_vec(&msg()).expect("serialises");
|
||||
let decoded: Effective = serde_json::from_slice(&bytes).expect("decodes");
|
||||
assert_eq!(decoded, msg().inference);
|
||||
}
|
||||
|
||||
/// Both enums cross the wire as the snake-case strings their `serde`
|
||||
/// attributes promise, not as Rust variant names — and the two stay
|
||||
/// separate fields, since neither vocabulary contains the other's values.
|
||||
|
|
|
|||
Loading…
Reference in a new issue