diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 23c9b8b0..57ef34b7 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -214,7 +214,9 @@ The second thing an agent publishes is its **turn-state header**, on `$SWARM.agent-state.` — same shape of subject, same grant mechanics, same lack of retention. It carries what a header bar wants: what the turn loop is doing (`turn_state`, plus `turn_state_since` as an ISO 8601 UTC stamp), -which model (`model` and the resolved id the last turn actually ran on), the +which model and effort (`model`, `effort`, where each comes from as +`model_source` and `effort_source`, and the resolved id the last turn +actually ran on), the context budget and the last turn's context and cost token blocks, and `agent_state`. @@ -238,7 +240,9 @@ opens with the bucket's snapshot and lets this stream refine it. Swarm-side, `GET /api/agents//state/stream` relays that subject as SSE, resolving the agent's hive at request time exactly as the terminal stream does. -The payload passes through opaquely — the controller never parses a header. +The payload passes through opaquely. Separately, the controller keeps each +agent's latest `model`, `effort` and their sources for +`GET /api/agents//inference` ([`inference.md`](inference.md)). The third thing an agent publishes is its **icon**, the same SVG its own `GET /icon` serves. It goes into the `agent-icons` KV bucket under the key diff --git a/docs/swarm/inference.md b/docs/swarm/inference.md index 20d44ba0..5de2d546 100644 --- a/docs/swarm/inference.md +++ b/docs/swarm/inference.md @@ -21,11 +21,41 @@ 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. +- `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. - `clear` removes every field; the agent goes back to its own values. The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). +## What an agent runs on + +`get` shows what the agent runs on next to what the swarm sets: + +```text +model: opus +effort: (not set; the agent's own) +subagent_models: (not set; the agent's own) +running model: opus (swarm) +running effort: medium (local_file) +``` + +Each running value names where it comes from: + +| source | meaning | +| ------------ | -------------------------------------------------------------------------- | +| `swarm` | the value set here | +| `agent_nix` | `services.hyperhive.agent.model` or `effortLevel` in the agent's agent.nix | +| `local_file` | a `/model` or `/effort` pick in the agent's web UI | +| `default` | the harness's built-in default (`haiku`, `medium`) | + +When the agent starts without a swarm value, its agent.nix model wins over +its `/model` pick, and its `/effort` pick wins over its agent.nix effort. + +The agent reports these whenever they change. swarm-controller keeps only +what it has received since it started, so after a controller restart `get` +shows `running: not reported…` for an agent until its next change (its next +turn, or a setting changing). + ## When an agent picks a change up Each agent reads its settings every 10 seconds and switches on the next @@ -55,7 +85,7 @@ Every field is optional, and `{}` reads the same as no key. | method | path | body | | -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | -| `GET` | `/api/agents//inference` | —; answers the stored value, `{}` when none | +| `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 | | `DELETE` | `/api/agents//inference` | —; answers 204 | @@ -72,4 +102,12 @@ The subagent daemon (`hive-subagent-mcp`'s `swarm_models`) reads `subagent_models` with the same direct get, under the same credential, on each `start` or `continue` that names a model. +`effective` comes from the agent's turn-state header on +`$SWARM.agent-state.` ([`README.md`](README.md)), which carries +`model`, `effort`, `model_source` and `effort_source`. swarm-controller +subscribes to every agent's header with the reader grant it already holds for +the state relay and keeps the latest per agent in memory. A header without +those fields, from an agent that doesn't report them, leaves `effective` +`null`. + diff --git a/docs/tools/swarmctl-cli.md b/docs/tools/swarmctl-cli.md index 45ac1a86..f9928ad1 100644 --- a/docs/tools/swarmctl-cli.md +++ b/docs/tools/swarmctl-cli.md @@ -152,7 +152,7 @@ A field the swarm sets wins over the agent's own value (from its `/model` or `/e ###### **Subcommands:** -* `get` — Show what the swarm sets for an agent, naming the fields it doesn't set +* `get` — Show what the swarm sets for an agent, naming the fields it doesn't set, and the model and effort the agent runs on, each with where it comes from * `set` — Set fields for an agent. Only the flags given change; the others keep what's stored * `clear` — Clear every field for an agent, so it goes back to its own values @@ -160,7 +160,7 @@ A field the swarm sets wins over the agent's own value (from its `/model` or `/e ## `swarmctl agent inference get` -Show what the swarm sets for an agent, naming the fields it doesn't set +Show what the swarm sets for an agent, naming the fields it doesn't set, and the model and effort the agent runs on, each with where it comes from **Usage:** `swarmctl agent inference get [OPTIONS] ` diff --git a/frontend/packages/swarm-ui/src/pages/agents/useSwarmAgentStateStream.ts b/frontend/packages/swarm-ui/src/pages/agents/useSwarmAgentStateStream.ts index 8fbd31f0..c4ebb6ca 100644 --- a/frontend/packages/swarm-ui/src/pages/agents/useSwarmAgentStateStream.ts +++ b/frontend/packages/swarm-ui/src/pages/agents/useSwarmAgentStateStream.ts @@ -25,6 +25,9 @@ export interface TokenUsage { cache_creation_input_tokens?: number; } +// `swarm_queue_client::agent_inference::Source`. +export type InferenceSource = "swarm" | "agent_nix" | "local_file" | "default"; + // Mirrors `hive_agent::swarm_agent_state::AgentStateMsg` field-for-field // — see that struct's own doc comment for why `turn_state_since` (an // ISO 8601 string here, unlike the per-agent page's unix-seconds @@ -35,6 +38,9 @@ export interface AgentStateHeader { turn_state_since: string; agent_state: "up" | "offline" | "paused" | "destroyed"; model: string; + effort: string; + model_source: InferenceSource; + effort_source: InferenceSource; resolved_model: string | null; context_window_tokens: number; ctx_usage: TokenUsage | null; diff --git a/hive-agent/src/events.rs b/hive-agent/src/events.rs index 135b48a7..7974dfd9 100644 --- a/hive-agent/src/events.rs +++ b/hive-agent/src/events.rs @@ -16,6 +16,7 @@ use chrono::Utc; use hive_claude::TokenUsage; use rusqlite::{Connection, params}; use serde::{Deserialize, Serialize}; +use swarm_queue_client::agent_inference::Source; use tokio::sync::broadcast; use crate::harness_state::{ @@ -285,6 +286,10 @@ pub struct Bus { /// operator can override at runtime via `POST /api/model`. model: Arc>, effort: Arc>, + /// Where `model` and `effort` come from, as the swarm is told in this + /// agent's turn-state header. Set with them. + model_source: Arc>, + effort_source: Arc>, /// Last-inference token usage from the most recent turn's final /// `assistant` event. Represents the actual context window size at /// turn-end — the number the operator watches to decide whether to @@ -393,8 +398,8 @@ impl Bus { } }; let (tx, _) = broadcast::channel(CHANNEL_CAPACITY); - let initial_model = local_model(configured_model()); - let initial_effort = local_effort(configured_effort()); + let (initial_model, model_source) = local_model(configured_model()); + let (initial_effort, effort_source) = local_effort(configured_effort()); // Restore rate_limited (and needs_login) from the consolidated // harness state file so the dashboard shows the correct status // on cold load if the harness crashed while parked. @@ -411,6 +416,8 @@ impl Bus { state: Arc::new(Mutex::new((TurnState::Idle, Utc::now().timestamp()))), model: Arc::new(Mutex::new(initial_model)), effort: Arc::new(Mutex::new(initial_effort)), + model_source: Arc::new(Mutex::new(model_source)), + effort_source: Arc::new(Mutex::new(effort_source)), last_ctx_usage: Arc::new(Mutex::new(None)), last_cost_usage: Arc::new(Mutex::new(None)), rate_limited: Arc::new(AtomicBool::new(was_rate_limited)), @@ -579,18 +586,20 @@ impl Bus { if let Err(e) = persist_model(&value) { tracing::warn!(error = ?e, "model: persist failed"); } - self.apply_model(value); + self.apply_model(value, Source::LocalFile); } /// Switch the model for future turns without persisting it, so /// `hyperhive-model` keeps the agent's local value. For a value the - /// swarm sets (`crate::swarm_inference`). + /// swarm sets (`crate::swarm_inference`). Emits `ModelChanged` even when + /// only `source` changed. /// /// # Panics /// /// Panics if the internal lock is poisoned. - pub fn apply_model(&self, value: String) { + pub fn apply_model(&self, value: String, source: Source) { self.model.lock().unwrap().clone_from(&value); + *self.model_source.lock().unwrap() = source; // Mirror the resolved model into hyperhive-harness.json so // hive-c0re can surface it on the dashboard badge without reading // the hyperhive-model override file directly. @@ -624,21 +633,43 @@ impl Bus { if let Err(e) = persist_effort(&value) { tracing::warn!(error = ?e, "effort: persist failed"); } - self.apply_effort(value); + self.apply_effort(value, Source::LocalFile); } /// Switch the effort level without persisting it, so `hyperhive-effort` /// keeps the agent's local value. For a value the swarm sets - /// (`crate::swarm_inference`). + /// (`crate::swarm_inference`). Emits `EffortChanged` even when only + /// `source` changed. /// /// # Panics /// /// Panics if the internal lock is poisoned. - pub fn apply_effort(&self, value: String) { + pub fn apply_effort(&self, value: String, source: Source) { self.effort.lock().unwrap().clone_from(&value); + *self.effort_source.lock().unwrap() = source; self.emit(LiveEvent::EffortChanged { effort: value }); } + /// Where [`Self::model`] comes from. + /// + /// # Panics + /// + /// Panics if the internal lock is poisoned. + #[must_use] + pub fn model_source(&self) -> Source { + *self.model_source.lock().unwrap() + } + + /// Where [`Self::effort`] comes from. + /// + /// # Panics + /// + /// Panics if the internal lock is poisoned. + #[must_use] + pub fn effort_source(&self) -> Source { + *self.effort_source.lock().unwrap() + } + /// Seed `last_ctx_usage` + `last_cost_usage` at startup without /// emitting a SSE event. Used by the bin entrypoints to backfill /// from the most recent `turn_stats` row so the per-agent web UI's diff --git a/hive-agent/src/harness_state.rs b/hive-agent/src/harness_state.rs index 492680f7..9ebd1eff 100644 --- a/hive-agent/src/harness_state.rs +++ b/hive-agent/src/harness_state.rs @@ -6,6 +6,8 @@ use std::path::PathBuf; +use swarm_queue_client::agent_inference::Source; + /// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE` /// for dev / tests; otherwise derived from the agent's harness dir. fn model_file_path() -> PathBuf { @@ -53,25 +55,37 @@ pub(crate) fn load_effort() -> Option { } } -/// The model this agent runs when the swarm sets none. Priority: -/// `configured` (`HIVE_DEFAULT_MODEL`, from `services.hyperhive.agent.model` -/// in agent.nix) > the persisted runtime override > [`DEFAULT_MODEL`]. -pub(crate) fn local_model(configured: Option<&str>) -> String { - configured.map_or_else( - || load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()), - str::to_owned, - ) +/// The model this agent runs when the swarm sets none, and where it comes +/// from. Priority: `configured` (`HIVE_DEFAULT_MODEL`, from +/// `services.hyperhive.agent.model` in agent.nix) > the persisted runtime +/// override > [`DEFAULT_MODEL`]. +pub(crate) fn local_model(configured: Option<&str>) -> (String, Source) { + model_from(configured, load_model()) } -/// The effort this agent runs at when the swarm sets none. The inverse of -/// [`local_model`]: a persisted operator pick wins over `configured` -/// (`HIVE_DEFAULT_EFFORT`), so the runtime choice survives a harness restart -/// (it only resets on `--purge`); nix is the default for a never-touched -/// agent. -pub(crate) fn local_effort(configured: Option<&str>) -> String { - load_effort() - .or_else(|| configured.map(str::to_owned)) - .unwrap_or_else(|| DEFAULT_EFFORT.to_owned()) +fn model_from(configured: Option<&str>, file: Option) -> (String, Source) { + match (configured, file) { + (Some(nix), _) => (nix.to_owned(), Source::AgentNix), + (None, Some(file)) => (file, Source::LocalFile), + (None, None) => (DEFAULT_MODEL.to_owned(), Source::Default), + } +} + +/// The effort this agent runs at when the swarm sets none, and where it +/// comes from. The inverse of [`local_model`]: a persisted operator pick +/// wins over `configured` (`HIVE_DEFAULT_EFFORT`), so the runtime choice +/// survives a harness restart (it only resets on `--purge`); nix is the +/// default for a never-touched agent. +pub(crate) fn local_effort(configured: Option<&str>) -> (String, Source) { + effort_from(configured, load_effort()) +} + +fn effort_from(configured: Option<&str>, file: Option) -> (String, Source) { + match (file, configured) { + (Some(file), _) => (file, Source::LocalFile), + (None, Some(nix)) => (nix.to_owned(), Source::AgentNix), + (None, None) => (DEFAULT_EFFORT.to_owned(), Source::Default), + } } pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> { @@ -291,7 +305,10 @@ pub fn context_window_tokens(model: &str) -> u64 { #[cfg(test)] mod tests { - use super::{DEFAULT_EFFORT, EFFORT_LEVELS, is_valid_effort}; + use super::{ + DEFAULT_EFFORT, DEFAULT_MODEL, EFFORT_LEVELS, Source, effort_from, is_valid_effort, + model_from, + }; #[test] fn effort_validation_accepts_only_known_levels() { @@ -312,4 +329,38 @@ mod tests { assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT)); assert_eq!(DEFAULT_EFFORT, "medium"); } + + fn pick(value: &str, source: Source) -> (String, Source) { + (value.to_owned(), source) + } + + /// agent.nix wins over the file for the model. + #[test] + fn each_model_source() { + let file = || Some("sonnet".to_owned()); + assert_eq!( + model_from(Some("opus"), file()), + pick("opus", Source::AgentNix) + ); + assert_eq!(model_from(None, file()), pick("sonnet", Source::LocalFile)); + assert_eq!(model_from(None, None), pick(DEFAULT_MODEL, Source::Default)); + } + + /// The file wins over agent.nix for the effort. + #[test] + fn each_effort_source() { + let file = || Some("low".to_owned()); + assert_eq!( + effort_from(Some("high"), file()), + pick("low", Source::LocalFile) + ); + assert_eq!( + effort_from(Some("high"), None), + pick("high", Source::AgentNix) + ); + assert_eq!( + effort_from(None, None), + pick(DEFAULT_EFFORT, Source::Default) + ); + } } diff --git a/hive-agent/src/swarm_agent_state.rs b/hive-agent/src/swarm_agent_state.rs index cdfa3e69..5c498ea3 100644 --- a/hive-agent/src/swarm_agent_state.rs +++ b/hive-agent/src/swarm_agent_state.rs @@ -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. diff --git a/hive-agent/src/swarm_inference.rs b/hive-agent/src/swarm_inference.rs index 30e32c70..3be47c2d 100644 --- a/hive-agent/src/swarm_inference.rs +++ b/hive-agent/src/swarm_inference.rs @@ -14,6 +14,9 @@ //! [`crate::harness_state::local_effort`]). A field that was set and then //! cleared goes back to that local value, read again at that moment. //! +//! Each value is applied with its [`Source`], which the agent's turn-state +//! header (`crate::swarm_agent_state`) carries to the swarm. +//! //! A failed read changes nothing. Until the first successful read after a //! start, the agent runs on its local values. //! @@ -21,7 +24,7 @@ use std::time::Duration; -use swarm_queue_client::agent_inference::{AgentInference, Effort, Read, read_own}; +use swarm_queue_client::agent_inference::{AgentInference, Effort, Read, Source, read_own}; use crate::events::Bus; use crate::harness_state::{configured_effort, configured_model, local_effort, local_model}; @@ -32,12 +35,12 @@ const POLL_INTERVAL: Duration = Duration::from_secs(10); /// Where the settings are applied, and where the local values come from. trait Target { - fn model(&self) -> String; - fn effort(&self) -> String; - fn apply_model(&self, value: String); - fn apply_effort(&self, value: String); - fn local_model(&self) -> String; - fn local_effort(&self) -> String; + fn model(&self) -> (String, Source); + fn effort(&self) -> (String, Source); + fn apply_model(&self, value: String, source: Source); + fn apply_effort(&self, value: String, source: Source); + fn local_model(&self) -> (String, Source); + fn local_effort(&self) -> (String, Source); } /// The harness's Bus, with the nix values read once at start. @@ -48,22 +51,22 @@ struct Harness { } impl Target for Harness { - fn model(&self) -> String { - self.bus.model() + fn model(&self) -> (String, Source) { + (self.bus.model(), self.bus.model_source()) } - fn effort(&self) -> String { - self.bus.effort() + fn effort(&self) -> (String, Source) { + (self.bus.effort(), self.bus.effort_source()) } - fn apply_model(&self, value: String) { - self.bus.apply_model(value); + fn apply_model(&self, value: String, source: Source) { + self.bus.apply_model(value, source); } - fn apply_effort(&self, value: String) { - self.bus.apply_effort(value); + fn apply_effort(&self, value: String, source: Source) { + self.bus.apply_effort(value, source); } - fn local_model(&self) -> String { + fn local_model(&self) -> (String, Source) { local_model(self.nix_model) } - fn local_effort(&self) -> String { + fn local_effort(&self) -> (String, Source) { local_effort(self.nix_effort) } } @@ -75,46 +78,47 @@ struct Overridden { effort: bool, } -/// What one field should become: the swarm's value while it sets one, the -/// local value on the read after it stops, and otherwise `None` (left alone). -/// Also `None` when the value is already what it should be. +/// What one field and its source should become: the swarm's value while it +/// sets one, the local value on the read after it stops, and otherwise +/// `None` (left alone). Also `None` when both are already what they should +/// be; a swarm value equal to the current one still changes the source. fn next( swarm: Option<&str>, overridden: &mut bool, - current: &str, - local: impl FnOnce() -> String, -) -> Option { + current: &(String, Source), + local: impl FnOnce() -> (String, Source), +) -> Option<(String, Source)> { let wanted = match swarm { Some(value) => { *overridden = true; - value.to_owned() + (value.to_owned(), Source::Swarm) } None if std::mem::take(overridden) => local(), None => return None, }; - (wanted != current).then_some(wanted) + (wanted != *current).then_some(wanted) } impl Overridden { /// Apply one read of the key to `target`. fn apply(&mut self, read: &AgentInference, target: &impl Target) { - if let Some(model) = next( + if let Some((model, source)) = next( read.model.as_deref(), &mut self.model, &target.model(), || target.local_model(), ) { - tracing::info!(%model, swarm = self.model, "inference: switching model"); - target.apply_model(model); + tracing::info!(%model, ?source, "inference: switching model"); + target.apply_model(model, source); } - if let Some(effort) = next( + if let Some((effort, source)) = next( read.effort.map(Effort::as_str), &mut self.effort, &target.effort(), || target.local_effort(), ) { - tracing::info!(%effort, swarm = self.effort, "inference: switching effort"); - target.apply_effort(effort); + tracing::info!(%effort, ?source, "inference: switching effort"); + target.apply_effort(effort, source); } } } @@ -166,10 +170,12 @@ mod tests { use super::*; - /// A Bus stand-in recording every apply. + /// A Bus stand-in recording every apply. Starts on its local values: + /// the model from agent.nix, the effort from the file. struct Fake { model: RefCell, effort: RefCell, + sources: RefCell<(Source, Source)>, local_model: RefCell, local_effort: String, applies: RefCell, @@ -180,6 +186,7 @@ mod tests { Self { model: RefCell::new("sonnet".to_owned()), effort: RefCell::new("medium".to_owned()), + sources: RefCell::new((Source::AgentNix, Source::LocalFile)), local_model: RefCell::new("sonnet".to_owned()), local_effort: "medium".to_owned(), applies: RefCell::new(0), @@ -191,25 +198,27 @@ mod tests { } impl Target for Fake { - fn model(&self) -> String { - self.model.borrow().clone() + fn model(&self) -> (String, Source) { + (self.model.borrow().clone(), self.sources.borrow().0) } - fn effort(&self) -> String { - self.effort.borrow().clone() + fn effort(&self) -> (String, Source) { + (self.effort.borrow().clone(), self.sources.borrow().1) } - fn apply_model(&self, value: String) { + fn apply_model(&self, value: String, source: Source) { *self.applies.borrow_mut() += 1; *self.model.borrow_mut() = value; + self.sources.borrow_mut().0 = source; } - fn apply_effort(&self, value: String) { + fn apply_effort(&self, value: String, source: Source) { *self.applies.borrow_mut() += 1; *self.effort.borrow_mut() = value; + self.sources.borrow_mut().1 = source; } - fn local_model(&self) -> String { - self.local_model.borrow().clone() + fn local_model(&self) -> (String, Source) { + (self.local_model.borrow().clone(), Source::AgentNix) } - fn local_effort(&self) -> String { - self.local_effort.clone() + fn local_effort(&self) -> (String, Source) { + (self.local_effort.clone(), Source::LocalFile) } } @@ -279,6 +288,34 @@ mod tests { assert_eq!(fake.now(), pair("sonnet", "medium")); } + /// A swarm value equal to what the agent runs is still applied once, so + /// the source moves to the swarm and the header is republished. + #[test] + fn a_swarm_value_equal_to_the_current_one_is_applied_once() { + let fake = Fake::new(); + let mut o = Overridden::default(); + o.apply(&set(Some("sonnet"), Some(Effort::Medium)), &fake); + assert_eq!(fake.now(), pair("sonnet", "medium")); + assert_eq!(*fake.applies.borrow(), 2); + o.apply(&set(Some("sonnet"), Some(Effort::Medium)), &fake); + assert_eq!(*fake.applies.borrow(), 2); + } + + #[test] + fn the_source_follows_the_swarm_value() { + let fake = Fake::new(); + let mut o = Overridden::default(); + o.apply(&set(Some("opus"), Some(Effort::High)), &fake); + assert_eq!(*fake.sources.borrow(), (Source::Swarm, Source::Swarm)); + o.apply(&set(None, Some(Effort::High)), &fake); + assert_eq!(*fake.sources.borrow(), (Source::AgentNix, Source::Swarm)); + o.apply(&AgentInference::default(), &fake); + assert_eq!( + *fake.sources.borrow(), + (Source::AgentNix, Source::LocalFile) + ); + } + /// On the real Bus: the swarm's value reaches the Bus but not the /// persisted files, so clearing it, or a restart, finds the local value. #[test] @@ -298,12 +335,14 @@ mod tests { let mut o = Overridden::default(); o.apply(&set(Some("opus"), Some(Effort::Max)), &target); let swarm = (bus.model(), bus.effort()); + let swarm_sources = (bus.model_source(), bus.effort_source()); let files = ( crate::harness_state::load_model(), crate::harness_state::load_effort(), ); o.apply(&AgentInference::default(), &target); let cleared = (bus.model(), bus.effort()); + let cleared_sources = (bus.model_source(), bus.effort_source()); match prev { // SAFETY: as above. Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) }, @@ -312,5 +351,7 @@ mod tests { assert_eq!(swarm, pair("opus", "max")); assert_eq!(files, (Some("sonnet".to_owned()), Some("low".to_owned()))); assert_eq!(cleared, pair("sonnet", "low")); + assert_eq!(swarm_sources, (Source::Swarm, Source::Swarm)); + assert_eq!(cleared_sources, (Source::LocalFile, Source::LocalFile)); } } diff --git a/swarm-controller/README.md b/swarm-controller/README.md index f98852be..036ff283 100644 --- a/swarm-controller/README.md +++ b/swarm-controller/README.md @@ -49,7 +49,9 @@ The swarm's control plane. The swarm UI and `swarmctl` are its clients. - **Inference settings** — each agent's model, effort and subagent models, one key per agent in the `agent-inference` KV bucket, which this daemon alone writes (`GET`/`PUT`/`DELETE /api/agents/{name}/inference`); each - agent applies them itself → [`docs/swarm/inference.md`](../docs/swarm/inference.md). + agent applies them itself. `GET` also answers the model and effort the + agent last reported in its turn-state header, kept in memory → + [`docs/swarm/inference.md`](../docs/swarm/inference.md). - **Relays** — each agent's terminal and turn-state header as SSE, agent icons, the cross-repo issue report, and the UI's quick links. diff --git a/swarm-controller/src/agent_state_stream.rs b/swarm-controller/src/agent_state_stream.rs index e00d8bcb..c1482f1a 100644 --- a/swarm-controller/src/agent_state_stream.rs +++ b/swarm-controller/src/agent_state_stream.rs @@ -17,9 +17,10 @@ //! is the write rate this feature exists to avoid. //! //! **The payload is passed through opaquely**, same as the terminal relay: -//! this daemon never inspects a header, so it does not depend on +//! this relay never inspects a header, so it does not depend on //! `hive-agent`'s type to re-serialise one. The bytes on the wire are what -//! `hive-agent::swarm_agent_state::publish` sent. +//! `hive-agent::swarm_agent_state::publish` sent. [`crate::inference`] +//! reads its own fields out of the same subject separately. use std::convert::Infallible; use std::time::SystemTime; @@ -36,9 +37,10 @@ use crate::AppState; params(("name" = String, Path, description = "agent whose turn state to stream")), responses( (status = 200, description = "server-sent event stream; each event's `data` is one \ - turn-state header, JSON as hive-agent published it (opaque to this daemon) — \ - `turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, \ - `resolved_model`, `context_window_tokens`, `ctx_usage`, `cost_usage`. Sent on \ + turn-state header, JSON as hive-agent published it (opaque to this relay) — \ + `turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, `effort`, \ + `model_source`, `effort_source`, `resolved_model`, `context_window_tokens`, \ + `ctx_usage`, `cost_usage`. Sent on \ transition only, live, no replay: an idle agent emits nothing until it changes", body = String, content_type = "text/event-stream"), (status = 400, description = "the agent name is not shaped like an identifier \ diff --git a/swarm-controller/src/forge_account.rs b/swarm-controller/src/forge_account.rs index 8986cd39..f9059351 100644 --- a/swarm-controller/src/forge_account.rs +++ b/swarm-controller/src/forge_account.rs @@ -152,6 +152,7 @@ mod tests { wanted: None, agent_status: None, agent_icons: None, + reported_inference: None, jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), diff --git a/swarm-controller/src/github_account.rs b/swarm-controller/src/github_account.rs index 7b5ff1c7..3a1634a1 100644 --- a/swarm-controller/src/github_account.rs +++ b/swarm-controller/src/github_account.rs @@ -122,6 +122,7 @@ mod tests { wanted: None, agent_status: None, agent_icons: None, + reported_inference: None, jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), diff --git a/swarm-controller/src/inference.rs b/swarm-controller/src/inference.rs index 6d92d97e..364f1386 100644 --- a/swarm-controller/src/inference.rs +++ b/swarm-controller/src/inference.rs @@ -1,19 +1,27 @@ //! `/api/agents/{name}/inference` — the model, effort and subagent models the -//! swarm sets for one agent. +//! swarm sets for one agent, and what the agent reports running on. //! //! Each agent's settings are one value under its own key of `agent-inference` //! (`swarm_queue_client::agent_inference`), which this daemon alone writes. //! The agent reads its key and applies each set field over its local value; //! an unset field leaves the local value in force. +//! +//! What the agent runs on comes from its turn-state header +//! (`$SWARM.agent-state.`), which [`Reported`] keeps the latest of. +//! Agents publish a header only when it changes and the queue keeps none, so +//! an agent that has not changed since this daemon started has no entry. -use std::collections::BTreeSet; +use std::collections::{BTreeSet, HashMap}; +use std::sync::{Arc, Mutex, PoisonError}; +use std::time::Duration; use axum::Json; use axum::extract::{Path, State}; use axum::http::StatusCode; +use futures_util::StreamExt as _; use problem_details::ProblemDetails; use serde::{Deserialize, Serialize}; -use swarm_queue_client::agent_inference::{self, AgentInference, Effort}; +use swarm_queue_client::agent_inference::{self, AgentInference, Effective, Effort, Source}; use utoipa::ToSchema; use crate::schedules::{live, queue, read, refused, write}; @@ -90,6 +98,103 @@ 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, + /// 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. + effective: Option, +} + +/// The model and effort an agent runs on, and where each comes from. +#[derive(Debug, PartialEq, Eq, Serialize, ToSchema)] +pub struct EffectiveInference { + model: String, + effort: String, + /// `swarm`, `agent_nix`, `local_file` or `default`. + #[schema(value_type = String)] + model_source: Source, + /// As `model_source`. + #[schema(value_type = String)] + effort_source: Source, +} + +impl From for EffectiveInference { + fn from(v: Effective) -> Self { + Self { + model: v.model, + effort: v.effort, + model_source: v.model_source, + effort_source: v.effort_source, + } + } +} + +/// The latest [`Effective`] each agent reported, by agent. +#[derive(Debug, Default)] +pub(crate) struct Reported { + latest: Mutex>, +} + +impl Reported { + /// Start recording every agent's turn-state headers from `client`. + pub(crate) fn spawn(client: async_nats::Client) -> Arc { + let reported = Arc::new(Self::default()); + let recording = Arc::clone(&reported); + tokio::spawn(async move { recording.follow(&client).await }); + reported + } + + /// Subscribe to every agent's header, and subscribe again if the + /// subscription ends. + async fn follow(&self, client: &async_nats::Client) { + const RETRY: Duration = Duration::from_secs(5); + let subject = swarm_queue_client::agent_state_subject_wildcard(); + loop { + match client.subscribe(subject.clone()).await { + Ok(mut headers) => { + tracing::info!(%subject, "inference: following agent turn-state headers"); + while let Some(msg) = headers.next().await { + self.record(&msg.subject, &msg.payload); + } + tracing::warn!(%subject, "inference: header subscription ended"); + } + Err(e) => tracing::warn!(%subject, error = %e, "inference: subscribe failed"), + } + tokio::time::sleep(RETRY).await; + } + } + + /// Keep one header's inference fields. A header without them, from an + /// agent that does not report them, removes what was kept. + fn record(&self, subject: &str, payload: &[u8]) { + let Some(agent) = swarm_queue_client::agent_from_state_subject(subject) else { + return; + }; + let mut latest = self.latest.lock().unwrap_or_else(PoisonError::into_inner); + match serde_json::from_slice::(payload) { + Ok(effective) => { + latest.insert(agent.to_owned(), effective); + } + Err(e) => { + tracing::debug!(%agent, error = %e, "inference: header has no inference fields"); + latest.remove(agent); + } + } + } + + fn get(&self, agent: &str) -> Option { + self.latest + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(agent) + .cloned() + } +} + fn agent_name(name: &str) -> Result { hive_types::Ident::parse(name) .map(hive_types::Ident::into_string) @@ -114,8 +219,8 @@ async fn open( path = "/api/agents/{name}/inference", params(("name" = String, Path, description = "agent name")), responses( - (status = 200, description = "the agent's settings; `{}` when none is set", - body = InferenceSettings), + (status = 200, description = "the settings the swarm sets for the agent, and what \ + it reported running on", body = AgentInferenceView), (status = 400, description = "not an identifier (problem+json)", body = String), (status = 503, description = "no swarm queue is configured on this host, or it is \ not connected (problem+json)", body = String), @@ -127,14 +232,21 @@ async fn open( pub(crate) async fn get_agent_inference( State(state): State, Path(name): Path, -) -> Result, ProblemDetails> { +) -> Result, ProblemDetails> { 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) .await .map_err(|e| queue_problem(&e))?; - Ok(Json(current.into())) + Ok(Json(AgentInferenceView { + configured: current.into(), + effective: state + .reported_inference + .as_ref() + .and_then(|r| r.get(&agent)) + .map(Into::into), + })) } #[utoipa::path( @@ -280,6 +392,70 @@ mod tests { assert!(settings(r#"{"model":"opus","effort":"xhigh"}"#).is_ok()); } + const NEW_HEADER: &[u8] = br#"{"turn_state":"idle","model":"opus","effort":"high", + "model_source":"swarm","effort_source":"local_file"}"#; + + /// A header from an agent that does not report effort or sources. + const OLD_HEADER: &[u8] = br#"{"turn_state":"idle","model":"sonnet"}"#; + + fn iris() -> String { + swarm_queue_client::agent_state_subject("iris") + } + + #[test] + fn a_header_is_kept_under_its_agent() { + let reported = Reported::default(); + reported.record(&iris(), NEW_HEADER); + assert_eq!( + reported.get("iris"), + Some(Effective { + model: "opus".to_owned(), + effort: "high".to_owned(), + model_source: Source::Swarm, + effort_source: Source::LocalFile, + }) + ); + assert_eq!(reported.get("atlas"), None); + } + + #[test] + fn a_header_without_the_inference_fields_leaves_no_entry() { + let reported = Reported::default(); + reported.record(&iris(), NEW_HEADER); + reported.record(&iris(), OLD_HEADER); + assert_eq!(reported.get("iris"), None); + } + + #[test] + fn a_subject_naming_no_agent_is_skipped() { + let reported = Reported::default(); + reported.record("$SWARM.agent-state.iris.sub.scout", NEW_HEADER); + reported.record("$SWARM.term.iris", NEW_HEADER); + assert!(reported.latest.lock().unwrap().is_empty()); + } + + #[test] + fn the_view_answers_both_halves() { + let view = |effective: Option| { + serde_json::to_value(AgentInferenceView { + configured: InferenceSettings::default(), + effective: effective.map(Into::into), + }) + .unwrap() + }; + assert_eq!( + view(None), + serde_json::json!({"configured": {}, "effective": null}) + ); + let reported = Reported::default(); + reported.record(&iris(), NEW_HEADER); + assert_eq!( + view(reported.get("iris"))["effective"], + serde_json::json!({"model": "opus", "effort": "high", + "model_source": "swarm", "effort_source": "local_file"}) + ); + } + #[test] fn bad_model_names_are_refused() { for json in [ diff --git a/swarm-controller/src/linked_accounts.rs b/swarm-controller/src/linked_accounts.rs index 4f3686e6..4b31adf3 100644 --- a/swarm-controller/src/linked_accounts.rs +++ b/swarm-controller/src/linked_accounts.rs @@ -957,6 +957,7 @@ pub(crate) mod tests { wanted: None, agent_status: None, agent_icons: None, + reported_inference: None, jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index bee0b7ee..975a5ffa 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -787,6 +787,10 @@ struct AppState { /// connection, several consumers" rationale as `agent_status` above, /// and `None` in exactly the state `status` is. agent_icons: Option>, + /// What each agent last reported running on, from its turn-state + /// headers on `status`'s connection. `None` in exactly the state + /// `status` is. + reported_inference: Option>, /// The swarm-level job graph, wrapped in its /// [`hive_jobq::scheduler::Scheduler`] now that something drives it /// (`spawn_jobq_worker`) — the graph alone was enough for the @@ -1138,6 +1142,32 @@ fn agent_icon_reader( Some(reader) } +/// What each agent last reported running on, read on the status reader's +/// connection — same rationale as [`wanted_writer`]. +fn reported_inference( + status: Option<&Arc>, +) -> Option> { + Some(inference::Reported::spawn(status?.queue_client())) +} + +/// Same "log and carry on" shape as the queue/bridge/forge wiring in `main`. +/// A controller that cannot hold a webhook secret still serves every other +/// route; the webhook endpoint answers 503, which is the honest answer +/// rather than a silent accept. Lifted out of `main` for +/// `clippy::too_many_lines`. +fn load_webhook_secret() -> Option> { + match webhook::load_or_generate_secret() { + Ok(secret) => Some(Arc::from(secret)), + Err(e) => { + tracing::warn!( + error = %format!("{e:#}"), + "webhook secret unavailable; swarm-wide forge webhooks are off" + ); + None + } + } +} + /// A hive name that is shaped like one and names a hive this swarm has. /// /// Reports the status and the detail rather than a rendered @@ -3119,20 +3149,7 @@ async fn main() -> Result<()> { let _jobq_metrics_provider = hive_jobq_metrics::spawn_exporter(Arc::clone(&jobq), "swarm-controller", jobq_http_client); - // Same "log and carry on" shape as the queue/bridge/forge wiring above. - // A controller that cannot hold a webhook secret still serves every - // other route; the webhook endpoint answers 503, which is the honest - // answer rather than a silent accept. - let webhook_secret = match webhook::load_or_generate_secret() { - Ok(secret) => Some(Arc::from(secret)), - Err(e) => { - tracing::warn!( - error = %format!("{e:#}"), - "webhook secret unavailable; swarm-wide forge webhooks are off" - ); - None - } - }; + let webhook_secret = load_webhook_secret(); // Diagnostic only — `put_matrix_account` does not call this yet (see its // own doc comment), so this cannot change any request's outcome. Logged @@ -3178,6 +3195,7 @@ async fn main() -> Result<()> { wanted: wanted_writer(status.as_ref()), agent_status: agent_status_reader(status.as_ref()), agent_icons: agent_icon_reader(status.as_ref()), + reported_inference: reported_inference(status.as_ref()), status, jobq, webhook_secret, @@ -3359,6 +3377,7 @@ mod tests { wanted: None, agent_status: None, agent_icons: None, + reported_inference: None, jobq: std::sync::Arc::clone(&sched), webhook_secret: None, config_prs: std::sync::Arc::default(), diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 81c603b3..82b60847 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -566,6 +566,7 @@ mod tests { wanted: None, agent_status: None, agent_icons: None, + reported_inference: None, jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( hive_jobq::Graph::new(), hive_jobq::resources::ResourceTable::new(), diff --git a/swarm-queue-client/src/agent_inference.rs b/swarm-queue-client/src/agent_inference.rs index 3130745c..1f64827f 100644 --- a/swarm-queue-client/src/agent_inference.rs +++ b/swarm-queue-client/src/agent_inference.rs @@ -73,6 +73,36 @@ impl Effort { } } +/// Where the model or effort an agent runs on comes from. +#[derive(Debug, Clone, Copy, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Source { + /// The agent's key in [`BUCKET`]. + Swarm, + /// `services.hyperhive.agent.model` or `effortLevel` in its agent.nix. + AgentNix, + /// `hyperhive-model` or `hyperhive-effort`, written by a `/model` or + /// `/effort` pick in its web UI. + LocalFile, + /// The harness's built-in default. + Default, +} + +/// The model and effort an agent runs on now, and where each comes from. +/// +/// Part of the turn-state header the agent publishes on +/// [`crate::agent_state_subject`]; swarm-controller decodes these fields +/// out of that header. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Effective { + /// The alias `claude --model` is invoked with (e.g. `"sonnet"`). + pub model: String, + /// The level `claude --effort` is invoked with. + pub effort: String, + pub model_source: Source, + pub effort_source: Source, +} + /// Open the inference bucket for writing, creating it if nothing has yet. /// /// **Controller-side only.** `history: 1`: an agent wants its current value. @@ -232,4 +262,36 @@ mod tests { serde_json::from_str(r#"{"model":"opus","temperature":1}"#).unwrap(); assert_eq!(decoded.model.as_deref(), Some("opus")); } + + #[test] + fn every_source_is_sent_snake_case() { + for (source, wire) in [ + (Source::Swarm, "swarm"), + (Source::AgentNix, "agent_nix"), + (Source::LocalFile, "local_file"), + (Source::Default, "default"), + ] { + assert_eq!(serde_json::to_value(source).unwrap(), wire); + } + } + + /// The fields are read out of a whole turn-state header; the rest of it + /// is skipped. + #[test] + fn effective_decodes_out_of_a_header() { + let decoded: Effective = serde_json::from_str( + r#"{"turn_state":"idle","model":"opus","effort":"high", + "model_source":"swarm","effort_source":"agent_nix"}"#, + ) + .unwrap(); + assert_eq!( + decoded, + Effective { + model: "opus".to_owned(), + effort: "high".to_owned(), + model_source: Source::Swarm, + effort_source: Source::AgentNix, + } + ); + } } diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 2bc3df95..a76a901e 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -327,6 +327,16 @@ pub fn agent_state_subject_wildcard() -> String { agent_state_subject("*") } +/// The agent an [`agent_state_subject`] names, or `None` for any other +/// subject. +#[must_use] +pub fn agent_from_state_subject(subject: &str) -> Option<&str> { + let agent = subject + .strip_prefix(AGENT_STATE_SUBJECT_PREFIX)? + .strip_prefix('.')?; + (!agent.is_empty() && !agent.contains('.')).then_some(agent) +} + /// Shared by [`agent_state_subject`] and its wildcard. const AGENT_STATE_SUBJECT_PREFIX: &str = "$SWARM.agent-state"; @@ -933,6 +943,22 @@ mod tests { assert_eq!(subagent_term::subject_wildcard(), "$SWARM.term.*.sub.*"); } + #[test] + fn the_agent_is_read_back_out_of_its_state_subject() { + assert_eq!( + agent_from_state_subject(&agent_state_subject("iris")), + Some("iris") + ); + for other in [ + "$SWARM.agent-state.", + "$SWARM.agent-state.iris.sub.scout", + "$SWARM.agent-stateiris", + "$SWARM.term.iris", + ] { + assert_eq!(agent_from_state_subject(other), None, "{other}"); + } + } + #[test] fn a_tls_client_config_builds_once_the_provider_is_installed() { install_crypto_provider(); diff --git a/swarmctl/src/inference.rs b/swarmctl/src/inference.rs index 7b814976..9eae3b3c 100644 --- a/swarmctl/src/inference.rs +++ b/swarmctl/src/inference.rs @@ -6,8 +6,8 @@ //! seconds, and its next turn runs on it. A field the swarm does not set //! leaves the agent on its own value. //! -//! The shape below mirrors the controller's `InferenceSettings`, for the -//! reason [`crate::agent`]'s module doc gives. +//! The shapes below mirror the controller's `InferenceSettings` and +//! `AgentInferenceView`, for the reason [`crate::agent`]'s module doc gives. use std::path::Path; @@ -27,6 +27,22 @@ struct Settings { subagent_models: Option>, } +/// What `GET` answers: the settings, and what the agent reported running on. +#[derive(Debug, Deserialize)] +struct View { + configured: Settings, + effective: Option, +} + +/// The model and effort an agent runs on, and where each comes from. +#[derive(Debug, Deserialize)] +struct Effective { + model: String, + effort: String, + model_source: String, + effort_source: String, +} + /// What `agent inference set` was asked to change. pub(crate) struct SetFlags { pub model: Option, @@ -65,14 +81,18 @@ fn member(agent: &str) -> Result { /// Run `swarmctl agent inference get`. pub(crate) fn get(socket: &Path, agent: &str) -> Result<()> { let uri = member(agent)?; - let current: Settings = runtime()?.block_on(send( + let view: View = runtime()?.block_on(send( socket, hyper::Method::GET, &uri, None::<&()>, "inference-get", ))?; - print!("{}", render(¤t)); + print!( + "{}{}", + render(&view.configured), + render_effective(view.effective.as_ref()) + ); Ok(()) } @@ -81,14 +101,14 @@ pub(crate) fn get(socket: &Path, agent: &str) -> Result<()> { pub(crate) fn set(socket: &Path, agent: &str, flags: SetFlags) -> Result<()> { let uri = member(agent)?; let rt = runtime()?; - let current: Settings = rt.block_on(send( + let current: View = rt.block_on(send( socket, hyper::Method::GET, &uri, None::<&()>, "inference-get", ))?; - let wanted = flags.merge(current)?; + let wanted = flags.merge(current.configured)?; let stored: Settings = rt.block_on(send( socket, hyper::Method::PUT, @@ -127,6 +147,23 @@ fn render(s: &Settings) -> String { ) } +/// What the agent runs on, each value with where it comes from. +fn render_effective(e: Option<&Effective>) -> String { + e.map_or_else( + || { + "running: not reported since swarm-controller started; the agent reports on its \ + next change\n" + .to_owned() + }, + |e| { + format!( + "running model: {} ({})\nrunning effort: {} ({})\n", + e.model, e.model_source, e.effort, e.effort_source + ) + }, + ) +} + #[cfg(test)] mod tests { use super::*; @@ -169,6 +206,22 @@ mod tests { assert!(flags().merge(Settings::default()).is_err()); } + #[test] + fn get_shows_the_running_values_with_their_sources() { + let view: View = serde_json::from_str( + r#"{"configured":{"model":"opus"},"effective":{"model":"opus","effort":"medium", + "model_source":"swarm","effort_source":"local_file"}}"#, + ) + .unwrap(); + assert_eq!(view.configured.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(); + assert!(render_effective(unknown.effective.as_ref()).starts_with("running: not reported")); + } + #[test] fn an_empty_value_renders_every_field_as_unset() { let row = render(&serde_json::from_str("{}").unwrap()); diff --git a/swarmctl/src/main.rs b/swarmctl/src/main.rs index bdcc8d90..357bcccc 100644 --- a/swarmctl/src/main.rs +++ b/swarmctl/src/main.rs @@ -227,7 +227,8 @@ enum AgentVerb { #[derive(Subcommand)] enum InferenceVerb { /// Show what the swarm sets for an agent, naming the fields it doesn't - /// set. + /// set, and the model and effort the agent runs on, each with where it + /// comes from. Get(InferenceAgentArgs), /// Set fields for an agent. Only the flags given change; the others keep /// what's stored.