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

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

View file

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

View file

@ -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(),

View file

@ -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(),

View file

@ -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.<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::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<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)> {
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<AppState>,
Path(name): Path<String>,
) -> Result<Json<InferenceSettings>, ProblemDetails> {
) -> Result<Json<AgentInferenceView>, ProblemDetails> {
let agent = agent_name(&name).map_err(refused)?;
let client = queue(&state).map_err(refused)?;
let store = open(&client).await?;
let (current, _) = read::<AgentInference>(&store, &agent)
.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<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]
fn bad_model_names_are_refused() {
for json in [

View file

@ -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(),

View file

@ -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<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
/// [`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<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.
///
/// 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(),

View file

@ -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(),