Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-10 00:00:25 +02:00 • committed by mara
commit d723fac0be
20 changed files with 655 additions and 113 deletions

View file

@ -214,7 +214,9 @@ The second thing an agent publishes is its **turn-state header**, on
`$SWARM.agent-state.<agent>` — same shape of subject, same grant mechanics, `$SWARM.agent-state.<agent>` — same shape of subject, same grant mechanics,
same lack of retention. It carries what a header bar wants: what the turn loop 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), 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 context budget and the last turn's context and cost token blocks, and
`agent_state`. `agent_state`.
@ -238,7 +240,9 @@ opens with the bucket's snapshot and lets this stream refine it.
Swarm-side, `GET /api/agents/<name>/state/stream` relays that subject as SSE, Swarm-side, `GET /api/agents/<name>/state/stream` relays that subject as SSE,
resolving the agent's hive at request time exactly as the terminal stream does. 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/<name>/inference` ([`inference.md`](inference.md)).
The third thing an agent publishes is its **icon**, the same SVG its own 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 `GET /icon` serves. It goes into the `agent-icons` KV bucket under the key

View file

@ -21,11 +21,41 @@ swarmctl agent inference clear iris
`continue` calls may name, read on each call `continue` calls may name, read on each call
([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)). ([`subagent.md`](../tools/subagent.md#which-models-a-subagent-may-run-on)).
The web UI's model picker doesn't use it. 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. - `clear` removes every field; the agent goes back to its own values.
The full flag reference is [`swarmctl-cli.md`](../tools/swarmctl-cli.md). 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 ## When an agent picks a change up
Each agent reads its settings every 10 seconds and switches on the next 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 | | method | path | body |
| -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ | | -------- | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------ |
| `GET` | `/api/agents/<agent>/inference` | —; answers the stored value, `{}` when none | | `GET` | `/api/agents/<agent>/inference` | —; answers `{"configured": <the stored value, {} when none>, "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 | | `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 |
| `DELETE` | `/api/agents/<agent>/inference` | —; answers 204 | | `DELETE` | `/api/agents/<agent>/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 `subagent_models` with the same direct get, under the same credential, on
each `start` or `continue` that names a model. each `start` or `continue` that names a model.
`effective` comes from the agent's turn-state header on
`$SWARM.agent-state.<agent>` ([`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`.
</details> </details>

View file

@ -152,7 +152,7 @@ A field the swarm sets wins over the agent's own value (from its `/model` or `/e
###### **Subcommands:** ###### **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 * `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 * `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` ## `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] <AGENT>` **Usage:** `swarmctl agent inference get [OPTIONS] <AGENT>`

View file

@ -25,6 +25,9 @@ export interface TokenUsage {
cache_creation_input_tokens?: number; 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 // Mirrors `hive_agent::swarm_agent_state::AgentStateMsg` field-for-field
// — see that struct's own doc comment for why `turn_state_since` (an // — 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 // ISO 8601 string here, unlike the per-agent page's unix-seconds
@ -35,6 +38,9 @@ export interface AgentStateHeader {
turn_state_since: string; turn_state_since: string;
agent_state: "up" | "offline" | "paused" | "destroyed"; agent_state: "up" | "offline" | "paused" | "destroyed";
model: string; model: string;
effort: string;
model_source: InferenceSource;
effort_source: InferenceSource;
resolved_model: string | null; resolved_model: string | null;
context_window_tokens: number; context_window_tokens: number;
ctx_usage: TokenUsage | null; ctx_usage: TokenUsage | null;

View file

@ -16,6 +16,7 @@ use chrono::Utc;
use hive_claude::TokenUsage; use hive_claude::TokenUsage;
use rusqlite::{Connection, params}; use rusqlite::{Connection, params};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use swarm_queue_client::agent_inference::Source;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use crate::harness_state::{ use crate::harness_state::{
@ -285,6 +286,10 @@ pub struct Bus {
/// operator can override at runtime via `POST /api/model`. /// operator can override at runtime via `POST /api/model`.
model: Arc<Mutex<String>>, model: Arc<Mutex<String>>,
effort: Arc<Mutex<String>>, effort: Arc<Mutex<String>>,
/// Where `model` and `effort` come from, as the swarm is told in this
/// agent's turn-state header. Set with them.
model_source: Arc<Mutex<Source>>,
effort_source: Arc<Mutex<Source>>,
/// Last-inference token usage from the most recent turn's final /// Last-inference token usage from the most recent turn's final
/// `assistant` event. Represents the actual context window size at /// `assistant` event. Represents the actual context window size at
/// turn-end — the number the operator watches to decide whether to /// turn-end — the number the operator watches to decide whether to
@ -393,8 +398,8 @@ impl Bus {
} }
}; };
let (tx, _) = broadcast::channel(CHANNEL_CAPACITY); let (tx, _) = broadcast::channel(CHANNEL_CAPACITY);
let initial_model = local_model(configured_model()); let (initial_model, model_source) = local_model(configured_model());
let initial_effort = local_effort(configured_effort()); let (initial_effort, effort_source) = local_effort(configured_effort());
// Restore rate_limited (and needs_login) from the consolidated // Restore rate_limited (and needs_login) from the consolidated
// harness state file so the dashboard shows the correct status // harness state file so the dashboard shows the correct status
// on cold load if the harness crashed while parked. // 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()))), state: Arc::new(Mutex::new((TurnState::Idle, Utc::now().timestamp()))),
model: Arc::new(Mutex::new(initial_model)), model: Arc::new(Mutex::new(initial_model)),
effort: Arc::new(Mutex::new(initial_effort)), 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_ctx_usage: Arc::new(Mutex::new(None)),
last_cost_usage: Arc::new(Mutex::new(None)), last_cost_usage: Arc::new(Mutex::new(None)),
rate_limited: Arc::new(AtomicBool::new(was_rate_limited)), rate_limited: Arc::new(AtomicBool::new(was_rate_limited)),
@ -579,18 +586,20 @@ impl Bus {
if let Err(e) = persist_model(&value) { if let Err(e) = persist_model(&value) {
tracing::warn!(error = ?e, "model: persist failed"); 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 /// Switch the model for future turns without persisting it, so
/// `hyperhive-model` keeps the agent's local value. For a value the /// `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
/// ///
/// Panics if the internal lock is poisoned. /// 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.lock().unwrap().clone_from(&value);
*self.model_source.lock().unwrap() = source;
// Mirror the resolved model into hyperhive-harness.json so // Mirror the resolved model into hyperhive-harness.json so
// hive-c0re can surface it on the dashboard badge without reading // hive-c0re can surface it on the dashboard badge without reading
// the hyperhive-model override file directly. // the hyperhive-model override file directly.
@ -624,21 +633,43 @@ impl Bus {
if let Err(e) = persist_effort(&value) { if let Err(e) = persist_effort(&value) {
tracing::warn!(error = ?e, "effort: persist failed"); 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` /// Switch the effort level without persisting it, so `hyperhive-effort`
/// keeps the agent's local value. For a value the swarm sets /// 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
/// ///
/// Panics if the internal lock is poisoned. /// 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.lock().unwrap().clone_from(&value);
*self.effort_source.lock().unwrap() = source;
self.emit(LiveEvent::EffortChanged { effort: value }); 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 /// Seed `last_ctx_usage` + `last_cost_usage` at startup without
/// emitting a SSE event. Used by the bin entrypoints to backfill /// 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 /// from the most recent `turn_stats` row so the per-agent web UI's

View file

@ -6,6 +6,8 @@
use std::path::PathBuf; use std::path::PathBuf;
use swarm_queue_client::agent_inference::Source;
/// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE` /// Path to the persisted model file. Overridable via `HYPERHIVE_MODEL_FILE`
/// for dev / tests; otherwise derived from the agent's harness dir. /// for dev / tests; otherwise derived from the agent's harness dir.
fn model_file_path() -> PathBuf { fn model_file_path() -> PathBuf {
@ -53,25 +55,37 @@ pub(crate) fn load_effort() -> Option<String> {
} }
} }
/// The model this agent runs when the swarm sets none. Priority: /// The model this agent runs when the swarm sets none, and where it comes
/// `configured` (`HIVE_DEFAULT_MODEL`, from `services.hyperhive.agent.model` /// from. Priority: `configured` (`HIVE_DEFAULT_MODEL`, from
/// in agent.nix) > the persisted runtime override > [`DEFAULT_MODEL`]. /// `services.hyperhive.agent.model` in agent.nix) > the persisted runtime
pub(crate) fn local_model(configured: Option<&str>) -> String { /// override > [`DEFAULT_MODEL`].
configured.map_or_else( pub(crate) fn local_model(configured: Option<&str>) -> (String, Source) {
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()), model_from(configured, load_model())
str::to_owned,
)
} }
/// The effort this agent runs at when the swarm sets none. The inverse of fn model_from(configured: Option<&str>, file: Option<String>) -> (String, Source) {
/// [`local_model`]: a persisted operator pick wins over `configured` match (configured, file) {
/// (`HIVE_DEFAULT_EFFORT`), so the runtime choice survives a harness restart (Some(nix), _) => (nix.to_owned(), Source::AgentNix),
/// (it only resets on `--purge`); nix is the default for a never-touched (None, Some(file)) => (file, Source::LocalFile),
/// agent. (None, None) => (DEFAULT_MODEL.to_owned(), Source::Default),
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()) /// 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>) -> (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<()> { pub(crate) fn persist_effort(level: &str) -> std::io::Result<()> {
@ -291,7 +305,10 @@ pub fn context_window_tokens(model: &str) -> u64 {
#[cfg(test)] #[cfg(test)]
mod tests { 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] #[test]
fn effort_validation_accepts_only_known_levels() { fn effort_validation_accepts_only_known_levels() {
@ -312,4 +329,38 @@ mod tests {
assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT)); assert!(EFFORT_LEVELS.contains(&DEFAULT_EFFORT));
assert_eq!(DEFAULT_EFFORT, "medium"); 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)
);
}
} }

View file

@ -31,6 +31,7 @@
use serde::Serialize; use serde::Serialize;
use tokio::sync::broadcast; use tokio::sync::broadcast;
use swarm_queue_client::agent_inference::Effective;
use swarm_queue_client::wanted::AgentState; use swarm_queue_client::wanted::AgentState;
use crate::events::{Bus, BusEvent, LiveEvent, TurnState}; 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 /// which documents why only two of the four variants can ever appear
/// here. /// here.
agent_state: AgentState, agent_state: AgentState,
/// The alias `claude --model` was invoked with (e.g. `"sonnet"`). /// `model`, `effort`, `model_source` and `effort_source`: what the next
model: String, /// 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 /// The concrete model id the most recent completed turn actually ran
/// on, resolved from that turn's assistant events. `None` before any /// on, resolved from that turn's assistant events. `None` before any
/// turn has completed. /// turn has completed.
@ -126,7 +130,12 @@ fn snapshot(bus: &Bus) -> AgentStateMsg {
turn_state_since: iso8601_utc(since_unix), turn_state_since: iso8601_utc(since_unix),
agent_state: observed_state(), agent_state: observed_state(),
context_window_tokens: bus.effective_context_window(&model), 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(), resolved_model: bus.last_resolved_model(),
ctx_usage: bus.last_ctx_usage(), ctx_usage: bus.last_ctx_usage(),
cost_usage: bus.last_cost_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. /// Watch the bus and publish whenever the header actually changed.
/// ///
/// **Why the change test is on the serialized bytes rather than on which /// **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 /// `Bus` state, only some of which announce themselves with an event of
/// their own — `set_resolved_model`, for one, emits nothing and lands /// their own — `set_resolved_model`, for one, emits nothing and lands
/// alongside the `TokenUsageChanged` of the same turn. An allow-list of /// alongside the `TokenUsageChanged` of the same turn. An allow-list of
@ -251,6 +260,7 @@ async fn publish(
mod tests { mod tests {
use super::AgentStateMsg; use super::AgentStateMsg;
use crate::events::TurnState; use crate::events::TurnState;
use swarm_queue_client::agent_inference::{Effective, Source};
use swarm_queue_client::wanted::AgentState; use swarm_queue_client::wanted::AgentState;
/// `TokenUsage` is `#[non_exhaustive]`, so a downstream crate cannot /// `TokenUsage` is `#[non_exhaustive]`, so a downstream crate cannot
@ -274,7 +284,12 @@ mod tests {
// As `iso8601_utc` renders a transition time. // As `iso8601_utc` renders a transition time.
turn_state_since: "2026-09-13T12:35:03Z".to_owned(), turn_state_since: "2026-09-13T12:35:03Z".to_owned(),
agent_state: AgentState::Paused, 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()), resolved_model: Some("claude-sonnet-4-5-20260805".to_owned()),
context_window_tokens: 200_000, context_window_tokens: 200_000,
ctx_usage: Some(usage(&serde_json::json!({ ctx_usage: Some(usage(&serde_json::json!({
@ -314,7 +329,10 @@ mod tests {
"context_window_tokens", "context_window_tokens",
"cost_usage", "cost_usage",
"ctx_usage", "ctx_usage",
"effort",
"effort_source",
"model", "model",
"model_source",
"resolved_model", "resolved_model",
"turn_state", "turn_state",
"turn_state_since", "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` /// Both enums cross the wire as the snake-case strings their `serde`
/// attributes promise, not as Rust variant names — and the two stay /// attributes promise, not as Rust variant names — and the two stay
/// separate fields, since neither vocabulary contains the other's values. /// separate fields, since neither vocabulary contains the other's values.

View file

@ -14,6 +14,9 @@
//! [`crate::harness_state::local_effort`]). A field that was set and then //! [`crate::harness_state::local_effort`]). A field that was set and then
//! cleared goes back to that local value, read again at that moment. //! 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 //! A failed read changes nothing. Until the first successful read after a
//! start, the agent runs on its local values. //! start, the agent runs on its local values.
//! //!
@ -21,7 +24,7 @@
use std::time::Duration; 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::events::Bus;
use crate::harness_state::{configured_effort, configured_model, local_effort, local_model}; 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. /// Where the settings are applied, and where the local values come from.
trait Target { trait Target {
fn model(&self) -> String; fn model(&self) -> (String, Source);
fn effort(&self) -> String; fn effort(&self) -> (String, Source);
fn apply_model(&self, value: String); fn apply_model(&self, value: String, source: Source);
fn apply_effort(&self, value: String); fn apply_effort(&self, value: String, source: Source);
fn local_model(&self) -> String; fn local_model(&self) -> (String, Source);
fn local_effort(&self) -> String; fn local_effort(&self) -> (String, Source);
} }
/// The harness's Bus, with the nix values read once at start. /// The harness's Bus, with the nix values read once at start.
@ -48,22 +51,22 @@ struct Harness {
} }
impl Target for Harness { impl Target for Harness {
fn model(&self) -> String { fn model(&self) -> (String, Source) {
self.bus.model() (self.bus.model(), self.bus.model_source())
} }
fn effort(&self) -> String { fn effort(&self) -> (String, Source) {
self.bus.effort() (self.bus.effort(), self.bus.effort_source())
} }
fn apply_model(&self, value: String) { fn apply_model(&self, value: String, source: Source) {
self.bus.apply_model(value); self.bus.apply_model(value, source);
} }
fn apply_effort(&self, value: String) { fn apply_effort(&self, value: String, source: Source) {
self.bus.apply_effort(value); self.bus.apply_effort(value, source);
} }
fn local_model(&self) -> String { fn local_model(&self) -> (String, Source) {
local_model(self.nix_model) local_model(self.nix_model)
} }
fn local_effort(&self) -> String { fn local_effort(&self) -> (String, Source) {
local_effort(self.nix_effort) local_effort(self.nix_effort)
} }
} }
@ -75,46 +78,47 @@ struct Overridden {
effort: bool, effort: bool,
} }
/// What one field should become: the swarm's value while it sets one, the /// What one field and its source should become: the swarm's value while it
/// local value on the read after it stops, and otherwise `None` (left alone). /// sets one, the local value on the read after it stops, and otherwise
/// Also `None` when the value is already what it should be. /// `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( fn next(
swarm: Option<&str>, swarm: Option<&str>,
overridden: &mut bool, overridden: &mut bool,
current: &str, current: &(String, Source),
local: impl FnOnce() -> String, local: impl FnOnce() -> (String, Source),
) -> Option<String> { ) -> Option<(String, Source)> {
let wanted = match swarm { let wanted = match swarm {
Some(value) => { Some(value) => {
*overridden = true; *overridden = true;
value.to_owned() (value.to_owned(), Source::Swarm)
} }
None if std::mem::take(overridden) => local(), None if std::mem::take(overridden) => local(),
None => return None, None => return None,
}; };
(wanted != current).then_some(wanted) (wanted != *current).then_some(wanted)
} }
impl Overridden { impl Overridden {
/// Apply one read of the key to `target`. /// Apply one read of the key to `target`.
fn apply(&mut self, read: &AgentInference, target: &impl 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(), read.model.as_deref(),
&mut self.model, &mut self.model,
&target.model(), &target.model(),
|| target.local_model(), || target.local_model(),
) { ) {
tracing::info!(%model, swarm = self.model, "inference: switching model"); tracing::info!(%model, ?source, "inference: switching model");
target.apply_model(model); target.apply_model(model, source);
} }
if let Some(effort) = next( if let Some((effort, source)) = next(
read.effort.map(Effort::as_str), read.effort.map(Effort::as_str),
&mut self.effort, &mut self.effort,
&target.effort(), &target.effort(),
|| target.local_effort(), || target.local_effort(),
) { ) {
tracing::info!(%effort, swarm = self.effort, "inference: switching effort"); tracing::info!(%effort, ?source, "inference: switching effort");
target.apply_effort(effort); target.apply_effort(effort, source);
} }
} }
} }
@ -166,10 +170,12 @@ mod tests {
use super::*; 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 { struct Fake {
model: RefCell<String>, model: RefCell<String>,
effort: RefCell<String>, effort: RefCell<String>,
sources: RefCell<(Source, Source)>,
local_model: RefCell<String>, local_model: RefCell<String>,
local_effort: String, local_effort: String,
applies: RefCell<usize>, applies: RefCell<usize>,
@ -180,6 +186,7 @@ mod tests {
Self { Self {
model: RefCell::new("sonnet".to_owned()), model: RefCell::new("sonnet".to_owned()),
effort: RefCell::new("medium".to_owned()), effort: RefCell::new("medium".to_owned()),
sources: RefCell::new((Source::AgentNix, Source::LocalFile)),
local_model: RefCell::new("sonnet".to_owned()), local_model: RefCell::new("sonnet".to_owned()),
local_effort: "medium".to_owned(), local_effort: "medium".to_owned(),
applies: RefCell::new(0), applies: RefCell::new(0),
@ -191,25 +198,27 @@ mod tests {
} }
impl Target for Fake { impl Target for Fake {
fn model(&self) -> String { fn model(&self) -> (String, Source) {
self.model.borrow().clone() (self.model.borrow().clone(), self.sources.borrow().0)
} }
fn effort(&self) -> String { fn effort(&self) -> (String, Source) {
self.effort.borrow().clone() (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.applies.borrow_mut() += 1;
*self.model.borrow_mut() = value; *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.applies.borrow_mut() += 1;
*self.effort.borrow_mut() = value; *self.effort.borrow_mut() = value;
self.sources.borrow_mut().1 = source;
} }
fn local_model(&self) -> String { fn local_model(&self) -> (String, Source) {
self.local_model.borrow().clone() (self.local_model.borrow().clone(), Source::AgentNix)
} }
fn local_effort(&self) -> String { fn local_effort(&self) -> (String, Source) {
self.local_effort.clone() (self.local_effort.clone(), Source::LocalFile)
} }
} }
@ -279,6 +288,34 @@ mod tests {
assert_eq!(fake.now(), pair("sonnet", "medium")); 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 /// 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. /// persisted files, so clearing it, or a restart, finds the local value.
#[test] #[test]
@ -298,12 +335,14 @@ mod tests {
let mut o = Overridden::default(); let mut o = Overridden::default();
o.apply(&set(Some("opus"), Some(Effort::Max)), &target); o.apply(&set(Some("opus"), Some(Effort::Max)), &target);
let swarm = (bus.model(), bus.effort()); let swarm = (bus.model(), bus.effort());
let swarm_sources = (bus.model_source(), bus.effort_source());
let files = ( let files = (
crate::harness_state::load_model(), crate::harness_state::load_model(),
crate::harness_state::load_effort(), crate::harness_state::load_effort(),
); );
o.apply(&AgentInference::default(), &target); o.apply(&AgentInference::default(), &target);
let cleared = (bus.model(), bus.effort()); let cleared = (bus.model(), bus.effort());
let cleared_sources = (bus.model_source(), bus.effort_source());
match prev { match prev {
// SAFETY: as above. // SAFETY: as above.
Some(v) => unsafe { std::env::set_var("HYPERHIVE_HARNESS_DIR", v) }, 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!(swarm, pair("opus", "max"));
assert_eq!(files, (Some("sonnet".to_owned()), Some("low".to_owned()))); assert_eq!(files, (Some("sonnet".to_owned()), Some("low".to_owned())));
assert_eq!(cleared, pair("sonnet", "low")); assert_eq!(cleared, pair("sonnet", "low"));
assert_eq!(swarm_sources, (Source::Swarm, Source::Swarm));
assert_eq!(cleared_sources, (Source::LocalFile, Source::LocalFile));
} }
} }

View file

@ -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, - **Inference settings** — each agent's model, effort and subagent models,
one key per agent in the `agent-inference` KV bucket, which this daemon one key per agent in the `agent-inference` KV bucket, which this daemon
alone writes (`GET`/`PUT`/`DELETE /api/agents/{name}/inference`); each 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 - **Relays** — each agent's terminal and turn-state header as SSE, agent
icons, the cross-repo issue report, and the UI's quick links. icons, the cross-repo issue report, and the UI's quick links.

View file

@ -17,9 +17,10 @@
//! is the write rate this feature exists to avoid. //! is the write rate this feature exists to avoid.
//! //!
//! **The payload is passed through opaquely**, same as the terminal relay: //! **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`'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::convert::Infallible;
use std::time::SystemTime; use std::time::SystemTime;
@ -36,9 +37,10 @@ use crate::AppState;
params(("name" = String, Path, description = "agent whose turn state to stream")), params(("name" = String, Path, description = "agent whose turn state to stream")),
responses( responses(
(status = 200, description = "server-sent event stream; each event's `data` is one \ (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 header, JSON as hive-agent published it (opaque to this relay) — \
`turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, \ `turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, `effort`, \
`resolved_model`, `context_window_tokens`, `ctx_usage`, `cost_usage`. Sent on \ `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", transition only, live, no replay: an idle agent emits nothing until it changes",
body = String, content_type = "text/event-stream"), body = String, content_type = "text/event-stream"),
(status = 400, description = "the agent name is not shaped like an identifier \ (status = 400, description = "the agent name is not shaped like an identifier \

View file

@ -152,6 +152,7 @@ mod tests {
wanted: None, wanted: None,
agent_status: None, agent_status: None,
agent_icons: None, agent_icons: None,
reported_inference: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(), hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(), hive_jobq::resources::ResourceTable::new(),

View file

@ -122,6 +122,7 @@ mod tests {
wanted: None, wanted: None,
agent_status: None, agent_status: None,
agent_icons: None, agent_icons: None,
reported_inference: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(), hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(), hive_jobq::resources::ResourceTable::new(),

View file

@ -1,19 +1,27 @@
//! `/api/agents/{name}/inference` — the model, effort and subagent models the //! `/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` //! Each agent's settings are one value under its own key of `agent-inference`
//! (`swarm_queue_client::agent_inference`), which this daemon alone writes. //! (`swarm_queue_client::agent_inference`), which this daemon alone writes.
//! The agent reads its key and applies each set field over its local value; //! The agent reads its key and applies each set field over its local value;
//! an unset field leaves the local value in force. //! an unset field leaves the local value in force.
//!
//! What the agent runs on comes from its turn-state header
//! (`$SWARM.agent-state.<agent>`), 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::Json;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::StatusCode; use axum::http::StatusCode;
use futures_util::StreamExt as _;
use problem_details::ProblemDetails; use problem_details::ProblemDetails;
use serde::{Deserialize, Serialize}; 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 utoipa::ToSchema;
use crate::schedules::{live, queue, read, refused, write}; 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<EffectiveInference>,
}
/// 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<Effective> 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<HashMap<String, Effective>>,
}
impl Reported {
/// Start recording every agent's turn-state headers from `client`.
pub(crate) fn spawn(client: async_nats::Client) -> Arc<Self> {
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::<Effective>(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<Effective> {
self.latest
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(agent)
.cloned()
}
}
fn agent_name(name: &str) -> Result<String, (StatusCode, String)> { fn agent_name(name: &str) -> Result<String, (StatusCode, String)> {
hive_types::Ident::parse(name) hive_types::Ident::parse(name)
.map(hive_types::Ident::into_string) .map(hive_types::Ident::into_string)
@ -114,8 +219,8 @@ async fn open(
path = "/api/agents/{name}/inference", path = "/api/agents/{name}/inference",
params(("name" = String, Path, description = "agent name")), params(("name" = String, Path, description = "agent name")),
responses( responses(
(status = 200, description = "the agent's settings; `{}` when none is set", (status = 200, description = "the settings the swarm sets for the agent, and what \
body = InferenceSettings), it reported running on", body = AgentInferenceView),
(status = 400, description = "not an identifier (problem+json)", body = String), (status = 400, description = "not an identifier (problem+json)", body = String),
(status = 503, description = "no swarm queue is configured on this host, or it is \ (status = 503, description = "no swarm queue is configured on this host, or it is \
not connected (problem+json)", body = String), not connected (problem+json)", body = String),
@ -127,14 +232,21 @@ async fn open(
pub(crate) async fn get_agent_inference( pub(crate) async fn get_agent_inference(
State(state): State<AppState>, State(state): State<AppState>,
Path(name): Path<String>, Path(name): Path<String>,
) -> Result<Json<InferenceSettings>, ProblemDetails> { ) -> Result<Json<AgentInferenceView>, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?; let agent = agent_name(&name).map_err(refused)?;
let client = queue(&state).map_err(refused)?; let client = queue(&state).map_err(refused)?;
let store = open(&client).await?; let store = open(&client).await?;
let (current, _) = read::<AgentInference>(&store, &agent) let (current, _) = read::<AgentInference>(&store, &agent)
.await .await
.map_err(|e| queue_problem(&e))?; .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( #[utoipa::path(
@ -280,6 +392,70 @@ mod tests {
assert!(settings(r#"{"model":"opus","effort":"xhigh"}"#).is_ok()); 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<Effective>| {
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] #[test]
fn bad_model_names_are_refused() { fn bad_model_names_are_refused() {
for json in [ for json in [

View file

@ -957,6 +957,7 @@ pub(crate) mod tests {
wanted: None, wanted: None,
agent_status: None, agent_status: None,
agent_icons: None, agent_icons: None,
reported_inference: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(), hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(), hive_jobq::resources::ResourceTable::new(),

View file

@ -787,6 +787,10 @@ struct AppState {
/// connection, several consumers" rationale as `agent_status` above, /// connection, several consumers" rationale as `agent_status` above,
/// and `None` in exactly the state `status` is. /// and `None` in exactly the state `status` is.
agent_icons: Option<Arc<agent_icon::AgentIconReader>>, agent_icons: Option<Arc<agent_icon::AgentIconReader>>,
/// 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<Arc<inference::Reported>>,
/// The swarm-level job graph, wrapped in its /// The swarm-level job graph, wrapped in its
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it /// [`hive_jobq::scheduler::Scheduler`] now that something drives it
/// (`spawn_jobq_worker`) — the graph alone was enough for the /// (`spawn_jobq_worker`) — the graph alone was enough for the
@ -1138,6 +1142,32 @@ fn agent_icon_reader(
Some(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<status::StatusReader>>,
) -> Option<Arc<inference::Reported>> {
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<Arc<str>> {
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. /// 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 /// Reports the status and the detail rather than a rendered
@ -3119,20 +3149,7 @@ async fn main() -> Result<()> {
let _jobq_metrics_provider = let _jobq_metrics_provider =
hive_jobq_metrics::spawn_exporter(Arc::clone(&jobq), "swarm-controller", jobq_http_client); 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. let webhook_secret = load_webhook_secret();
// 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
}
};
// Diagnostic only — `put_matrix_account` does not call this yet (see its // Diagnostic only — `put_matrix_account` does not call this yet (see its
// own doc comment), so this cannot change any request's outcome. Logged // 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()), wanted: wanted_writer(status.as_ref()),
agent_status: agent_status_reader(status.as_ref()), agent_status: agent_status_reader(status.as_ref()),
agent_icons: agent_icon_reader(status.as_ref()), agent_icons: agent_icon_reader(status.as_ref()),
reported_inference: reported_inference(status.as_ref()),
status, status,
jobq, jobq,
webhook_secret, webhook_secret,
@ -3359,6 +3377,7 @@ mod tests {
wanted: None, wanted: None,
agent_status: None, agent_status: None,
agent_icons: None, agent_icons: None,
reported_inference: None,
jobq: std::sync::Arc::clone(&sched), jobq: std::sync::Arc::clone(&sched),
webhook_secret: None, webhook_secret: None,
config_prs: std::sync::Arc::default(), config_prs: std::sync::Arc::default(),

View file

@ -566,6 +566,7 @@ mod tests {
wanted: None, wanted: None,
agent_status: None, agent_status: None,
agent_icons: None, agent_icons: None,
reported_inference: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(), hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(), hive_jobq::resources::ResourceTable::new(),

View file

@ -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. /// Open the inference bucket for writing, creating it if nothing has yet.
/// ///
/// **Controller-side only.** `history: 1`: an agent wants its current value. /// **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(); serde_json::from_str(r#"{"model":"opus","temperature":1}"#).unwrap();
assert_eq!(decoded.model.as_deref(), Some("opus")); 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,
}
);
}
} }

View file

@ -327,6 +327,16 @@ pub fn agent_state_subject_wildcard() -> String {
agent_state_subject("*") 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. /// Shared by [`agent_state_subject`] and its wildcard.
const AGENT_STATE_SUBJECT_PREFIX: &str = "$SWARM.agent-state"; const AGENT_STATE_SUBJECT_PREFIX: &str = "$SWARM.agent-state";
@ -933,6 +943,22 @@ mod tests {
assert_eq!(subagent_term::subject_wildcard(), "$SWARM.term.*.sub.*"); 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] #[test]
fn a_tls_client_config_builds_once_the_provider_is_installed() { fn a_tls_client_config_builds_once_the_provider_is_installed() {
install_crypto_provider(); install_crypto_provider();

View file

@ -6,8 +6,8 @@
//! seconds, and its next turn runs on it. A field the swarm does not set //! seconds, and its next turn runs on it. A field the swarm does not set
//! leaves the agent on its own value. //! leaves the agent on its own value.
//! //!
//! The shape below mirrors the controller's `InferenceSettings`, for the //! The shapes below mirror the controller's `InferenceSettings` and
//! reason [`crate::agent`]'s module doc gives. //! `AgentInferenceView`, for the reason [`crate::agent`]'s module doc gives.
use std::path::Path; use std::path::Path;
@ -27,6 +27,22 @@ struct Settings {
subagent_models: Option<Vec<String>>, subagent_models: Option<Vec<String>>,
} }
/// What `GET` answers: the settings, and what the agent reported running on.
#[derive(Debug, Deserialize)]
struct View {
configured: Settings,
effective: Option<Effective>,
}
/// 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. /// What `agent inference set` was asked to change.
pub(crate) struct SetFlags { pub(crate) struct SetFlags {
pub model: Option<String>, pub model: Option<String>,
@ -65,14 +81,18 @@ fn member(agent: &str) -> Result<String> {
/// Run `swarmctl agent inference get`. /// Run `swarmctl agent inference get`.
pub(crate) fn get(socket: &Path, agent: &str) -> Result<()> { pub(crate) fn get(socket: &Path, agent: &str) -> Result<()> {
let uri = member(agent)?; let uri = member(agent)?;
let current: Settings = runtime()?.block_on(send( let view: View = runtime()?.block_on(send(
socket, socket,
hyper::Method::GET, hyper::Method::GET,
&uri, &uri,
None::<&()>, None::<&()>,
"inference-get", "inference-get",
))?; ))?;
print!("{}", render(&current)); print!(
"{}{}",
render(&view.configured),
render_effective(view.effective.as_ref())
);
Ok(()) 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<()> { pub(crate) fn set(socket: &Path, agent: &str, flags: SetFlags) -> Result<()> {
let uri = member(agent)?; let uri = member(agent)?;
let rt = runtime()?; let rt = runtime()?;
let current: Settings = rt.block_on(send( let current: View = rt.block_on(send(
socket, socket,
hyper::Method::GET, hyper::Method::GET,
&uri, &uri,
None::<&()>, None::<&()>,
"inference-get", "inference-get",
))?; ))?;
let wanted = flags.merge(current)?; let wanted = flags.merge(current.configured)?;
let stored: Settings = rt.block_on(send( let stored: Settings = rt.block_on(send(
socket, socket,
hyper::Method::PUT, 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)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@ -169,6 +206,22 @@ mod tests {
assert!(flags().merge(Settings::default()).is_err()); 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] #[test]
fn an_empty_value_renders_every_field_as_unset() { fn an_empty_value_renders_every_field_as_unset() {
let row = render(&serde_json::from_str("{}").unwrap()); let row = render(&serde_json::from_str("{}").unwrap());

View file

@ -227,7 +227,8 @@ enum AgentVerb {
#[derive(Subcommand)] #[derive(Subcommand)]
enum InferenceVerb { enum InferenceVerb {
/// Show what the swarm sets for an agent, naming the fields it doesn't /// 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), Get(InferenceAgentArgs),
/// Set fields for an agent. Only the flags given change; the others keep /// Set fields for an agent. Only the flags given change; the others keep
/// what's stored. /// what's stored.