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
|
|
@ -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 [
|
||||
|
|
|
|||
Loading…
Reference in a new issue