swarm: show each agent's effective model and effort, and where they came from
Each agent now reports the model and effort it runs on, and where each
comes from (`swarm`, `agent_nix`, `local_file` or `default`), in its
turn-state header on `$SWARM.agent-state.<agent>`: `effort`,
`model_source` and `effort_source` join `model`. The shared
`swarm_queue_client::agent_inference::{Effective, Source}` types spell
those fields for both ends.
The Bus keeps a source next to the model and effort. `Bus::new` takes it
from the local fallback chain (`harness_state::local_model` /
`local_effort`, which now return the source with the value), a `/model`
or `/effort` pick sets `local_file`, and the swarm poller sets `swarm`
or, on a clear, the local chain's source. A swarm value equal to the one
the agent already runs is now applied once, so the source moves to
`swarm` and the header is republished; before, `next()` skipped it as
unchanged.
swarm-controller subscribes to every agent's header with the reader
grant it already holds for the state relay, and keeps the latest
inference fields per agent in memory (`inference::Reported`).
`GET /api/agents/{name}/inference` now answers
`{configured, effective}`, `effective` being `null` until the agent's
first header with these fields since the controller started.
`swarmctl agent inference get` prints both; `set` reads `configured`.
Refs #5034
This commit is contained in:
parent
6982b2ec14
commit
d723fac0be
20 changed files with 655 additions and 113 deletions
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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 [
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue