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

@ -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<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
/// `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

View file

@ -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<String> {
}
}
/// 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>) -> (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>) -> (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)
);
}
}

View file

@ -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.

View file

@ -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<String> {
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<String>,
effort: RefCell<String>,
sources: RefCell<(Source, Source)>,
local_model: RefCell<String>,
local_effort: String,
applies: RefCell<usize>,
@ -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));
}
}