//! An agent's subagents: the stream that keeps their rows, which ones the //! swarm has rows for, and an SSE relay of one subagent's terminal. //! //! The agent's subagent daemon publishes each subagent's classified rows on //! `$SWARM.term.{agent}.sub.{subagent}` under the agent's own queue credential, //! which may publish there and nothing else of these streams. [`spawn`] //! creates the stream `term-sub-{agent}` for every live agent, with the config //! `swarm_queue_client::subagent_term` spells, and in the same pass the //! agent's own terminal stream (`swarm_queue_client::agent_term`). Subagents are spawned on demand //! under caller-chosen names, so the list is the set of subjects that stream //! holds rows for, read with `STREAM.INFO`. A missing stream lists nothing. //! Each subagent carries the stream's own publish time of its newest row, so //! a reader can tell a subagent that is running from one that has gone quiet. //! The same row's `Swarm-Model` and `Swarm-Effort` headers //! (`swarm_wire::subagent_term`) give the model and effort it runs with. //! //! **No agent-status lookup.** Unlike [`crate::term_stream`], an agent that //! has never reported gets an empty stream, not a 404. //! //! **Replay, then the live tail, output only.** The relay is //! [`crate::term_stream::replay_or_live`], the agent's own terminal route's, //! on the subagent's subject in `term-sub-{agent}`: the subagent's newest //! `agent_term::REPLAY_ROWS` rows, then each new one, with the same //! `Last-Event-ID` resume and the same live-only fallback while the stream //! cannot be read. Nothing is sent toward a subagent: subagents take no //! input from the swarm. use std::collections::BTreeSet; use std::convert::Infallible; use std::sync::Arc; use std::time::Duration; use axum::Json; use axum::extract::{Path, State}; use axum::http::{HeaderMap, StatusCode}; use axum::response::sse::{Event, KeepAlive, Sse}; use futures_util::{Stream, StreamExt as _, TryStreamExt as _}; use serde::Serialize; use swarm_wire::subagent_term; use utoipa::ToSchema; use crate::routes::AppState; use crate::wanted::WantedWriter; /// How often [`spawn`] checks every live agent's stream. Also how long a newly /// declared agent's subagents can go unlisted. const ENSURE_INTERVAL: Duration = Duration::from_mins(1); /// How long [`list_subagents`] waits for one subagent's newest row before /// listing it without a time. The lookups run concurrently, so a stream that /// does not answer costs the list this long per [`LOOKUP_CONCURRENCY`] /// subagents. const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2); /// How many newest-row lookups [`list_subagents`] has in flight at once. const LOOKUP_CONCURRENCY: usize = 16; /// Create the subagent stream and the terminal stream (`agent_term`) of every /// live agent that lacks one, now and every [`ENSURE_INTERVAL`] after. An /// existing stream is left as it is. /// /// The live agents are the ones some hive's wanted-state declaration names in /// a state other than `Destroyed` (`agent_renewal::live_agents`). A pass skips /// a hive whose declaration cannot be read and an agent whose stream cannot be /// created, logging each; a pass while the queue is down does nothing. It /// never stops the daemon. pub(crate) fn spawn(client: async_nats::Client, wanted: Arc, hives: Vec) { tokio::spawn(async move { let mut ticker = tokio::time::interval(ENSURE_INTERVAL); loop { ticker.tick().await; if swarm_queue_client::ensure_connected(&client).is_err() { tracing::debug!("terminal streams: queue not connected; next pass"); continue; } if let Err(e) = swarm_queue_client::subagent_status::open_or_create(&client).await { tracing::warn!( error = %swarm_queue_client::chain(&e), "subagent status: opening the bucket failed; next pass" ); } for agent in live_agents(&wanted, &hives).await { if let Err(e) = swarm_queue_client::subagent_term::open_or_create(&client, &agent).await { tracing::warn!( %agent, error = %swarm_queue_client::chain(&e), "subagent streams: creating the agent's stream failed; next pass" ); } if let Err(e) = swarm_queue_client::agent_term::open_or_create(&client, &agent).await { tracing::warn!( %agent, error = %swarm_queue_client::chain(&e), "terminal streams: creating the agent's stream failed; next pass" ); } } } }); } /// Every agent the readable declarations of `hives` keep alive. pub(crate) async fn live_agents(wanted: &WantedWriter, hives: &[String]) -> BTreeSet { let mut declarations = Vec::with_capacity(hives.len()); for hive in hives { match wanted.view(hive).await { Ok(Some(d)) => declarations.push(d), Ok(None) => {} Err(e) => tracing::warn!( %hive, error = %format!("{e:#}"), "subagent streams: reading the hive's wanted-state declaration failed" ), } } crate::agent_renewal::live_agents(&declarations) } /// One subagent of an agent, as the list endpoint reports it. #[derive(Debug, PartialEq, Eq, Serialize, ToSchema)] pub(crate) struct SubagentEntry { /// The subagent's name, as it appears in its stream subject. pub name: String, /// When its newest row in the stream was published, unix seconds, by the /// stream's own clock. `None` when that row could not be read. pub last_row_unix: Option, /// The model it runs, as its newest row's `Swarm-Model` header says. `None` /// when the row has none (the harness's default model) or could not be read. pub model: Option, /// Its reasoning effort, from the `Swarm-Effort` header, with the same `None`. pub effort: Option, /// Whether it is running, from the status its daemon published. `None` /// for a subagent that has published none (an older daemon), which a /// reader has to judge from `last_row_unix`. pub state: Option, /// When the subagent entered its published state, unix seconds. `None` /// with `state`. pub since_unix: Option, /// The process's exit status once it finished, when it exited by itself. pub exit: Option, } /// What the status a subagent's daemon published says about it, as of the /// request. #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, ToSchema)] #[serde(rename_all = "snake_case")] pub(crate) enum SubagentState { /// A run is in progress and the daemon reported in recently. Running, /// The last run ended. Finished, /// The status says running, but the daemon has not reported in for three /// heartbeats: it died or lost the queue. Stale, } impl SubagentState { fn of(status: &swarm_wire::subagent_status::Status, now_s: i64) -> Self { use swarm_wire::subagent_status::State; match status.state { State::Finished => Self::Finished, State::Running if status.is_running(now_s) => Self::Running, State::Running => Self::Stale, } } } #[utoipa::path( get, path = "/api/agents/{name}/subagents", params(("name" = String, Path, description = "agent whose subagents to list")), responses( (status = 200, description = "the agent's subagents with terminal rows in the \ swarm queue, sorted by name, each with the publish time of its newest row; \ empty when the agent has published none", body = Vec), (status = 400, description = "the agent name is not shaped like an identifier \ (problem+json)", body = String), (status = 503, description = "no swarm queue is configured on this host, or the \ queue could not be reached (problem+json)", body = String), (status = 500, description = "reading the agent's subagent stream failed \ (problem+json)", body = String), ), tag = "agents" )] pub(crate) async fn list_subagents( State(state): State, Path(agent): Path, ) -> Result>, problem_details::ProblemDetails> { let agent = ident(&agent).map_err(bad_request)?; let client = connected_client(&state).map_err(|e| unavailable(&e))?; let js = async_nats::jetstream::new(client.clone()); let stream_name = subagent_term::stream_name(&agent); let stream = match js.get_stream(&stream_name).await { Ok(stream) => stream, Err(e) if is_stream_not_found(&e) => return Ok(Json(Vec::new())), Err(e) => return Err(read_failed(&agent, &e)), }; let subjects: Vec<(String, usize)> = stream .info_with_subjects(subagent_term::stream_subjects(&agent)) .await .map_err(|e| read_failed(&agent, &e))? .try_collect() .await .map_err(|e| read_failed(&agent, &e))?; let names = subagent_term::subagent_names(&agent, subjects.iter().map(|(s, _)| s.as_str())); // Owned names and `async move`, not a closure over `&names`: a borrowed // item type trips a "`FnOnce` is not general enough" inference error where // `routes!` checks this fn as a handler. let statuses = read_statuses(&client, &agent, &names).await; let (stream_ref, agent_ref) = (&stream, &agent); // A subagent with a published status needs no newest-row lookup: the // status carries what the row's time and headers were read for. let lookups: Vec>> = futures_util::stream::iter( names .iter() .cloned() .zip(statuses.iter().map(Option::is_some)), ) .map(|(name, has_status)| async move { if has_status { None } else { Some(newest_row(stream_ref, agent_ref, &name).await) } }) .buffered(LOOKUP_CONCURRENCY) .collect() .await; let failed = lookups.iter().flatten().filter(|l| l.is_err()).count(); if let Some(Err(error)) = lookups.iter().flatten().find(|l| l.is_err()) { tracing::warn!( %agent, failed, subagents = names.len(), %error, "listing subagents: reading the newest row failed (first error shown)" ); } let now_s = unix_now(); let entries = names .into_iter() .zip(statuses) .zip(lookups) .map(|((name, status), lookup)| entry_of(name, status, lookup, now_s)) .collect(); Ok(Json(entries)) } /// One list entry. A published status gives the state, when it began and the /// model and effort; without one, the newest row gives the time and the /// headers give the model and effort. fn entry_of( name: String, status: Option, lookup: Option>, now_s: i64, ) -> SubagentEntry { if let Some(status) = status { return SubagentEntry { name, last_row_unix: None, state: Some(SubagentState::of(&status, now_s)), since_unix: Some(status.since), exit: status.exit, model: status.model, effort: status.effort, }; } let row = lookup.and_then(Result::ok); SubagentEntry { name, last_row_unix: row.as_ref().map(|r| r.unix), model: row.as_ref().and_then(|r| r.model.clone()), effort: row.and_then(|r| r.effort), state: None, since_unix: None, exit: None, } } /// Seconds since the unix epoch, by this host's clock. fn unix_now() -> i64 { std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)) } /// The status each of `names` last published, in the same order: `None` for a /// subagent with none, and for every subagent when the bucket cannot be read /// (an older swarm, or the queue failing), so the list falls back to the rows. async fn read_statuses( client: &async_nats::Client, agent: &str, names: &[String], ) -> Vec> { let js = async_nats::jetstream::new(client.clone()); let store = match js.get_key_value(swarm_wire::subagent_status::BUCKET).await { Ok(store) => store, Err(e) => { tracing::debug!( %agent, error = %swarm_queue_client::chain(&e), "listing subagents: the status bucket is not readable; using the rows" ); return vec![None; names.len()]; } }; // Owned items and `async move`, not a closure over borrows: a borrowed item // type trips the same "`FnOnce` is not general enough" error as above. let items: Vec<(async_nats::jetstream::kv::Store, String, String)> = names .iter() .map(|name| (store.clone(), agent.to_owned(), name.clone())) .collect(); futures_util::stream::iter(items) .map(|(store, agent, name)| async move { read_status(&store, &agent, &name).await }) .buffered(LOOKUP_CONCURRENCY) .collect() .await } /// One subagent's published status, or `None` when it has none or it could not /// be read (each failure but a missing key is a log line). async fn read_status( store: &async_nats::jetstream::kv::Store, agent: &str, name: &str, ) -> Option { let key = swarm_wire::subagent_status::key(agent, name); match tokio::time::timeout(LOOKUP_TIMEOUT, store.get(&key)).await { Ok(Ok(Some(value))) => match serde_json::from_slice(&value) { Ok(status) => Some(status), Err(e) => { tracing::warn!(%agent, subagent = %name, error = %e, "listing subagents: the status is not readable"); None } }, Ok(Ok(None)) => None, Ok(Err(e)) => { tracing::warn!(%agent, subagent = %name, error = %swarm_queue_client::chain(&e), "listing subagents: reading the status failed"); None } Err(_) => { tracing::warn!(%agent, subagent = %name, "listing subagents: no answer reading the status"); None } } } /// What a subagent's newest row says: when it was published, and the model and /// effort its headers name. #[derive(Debug, PartialEq, Eq)] struct NewestRow { unix: i64, model: Option, effort: Option, } /// One header's value, or `None` when the row has none or an empty one. fn header(headers: &async_nats::HeaderMap, name: &str) -> Option { headers .get(name) .map(ToString::to_string) .filter(|v| !v.is_empty()) } /// A subagent's newest row, or why it could not be read. A row that cannot be /// read, aged out between the listing and this lookup say, is a missing time /// and not a failed list. async fn newest_row( stream: &async_nats::jetstream::stream::Stream, agent: &str, subagent: &str, ) -> Result { let subject = subagent_term::subject(agent, subagent); let lookup = stream.get_last_raw_message_by_subject(&subject); match tokio::time::timeout(LOOKUP_TIMEOUT, lookup).await { Ok(Ok(row)) => Ok(NewestRow { unix: row.time.unix_timestamp(), model: header(&row.headers, subagent_term::HEADER_MODEL), effort: header(&row.headers, subagent_term::HEADER_EFFORT), }), Ok(Err(e)) => Err(format!("{subagent}: {}", swarm_queue_client::chain(&e))), Err(_) => Err(format!("{subagent}: no answer in {LOOKUP_TIMEOUT:?}")), } } #[utoipa::path( get, path = "/api/agents/{name}/subagents/{subagent}/term/stream", params( ("name" = String, Path, description = "agent the subagent belongs to"), ("subagent" = String, Path, description = "subagent whose terminal to stream"), ), responses( (status = 200, description = "server-sent event stream; each event's `data` is \ one already-classified TermMsg row, JSON as the agent's subagent daemon \ published it (opaque to this daemon); the subagent's newest retained rows \ first, then live rows. Each event's `id` is the row's stream sequence, and a \ `Last-Event-ID` request header resumes right after that row", body = String, content_type = "text/event-stream"), (status = 400, description = "the agent or subagent name is not shaped like an \ identifier (problem+json)", body = String), (status = 503, description = "no swarm queue is configured on this host, or the \ queue could not be reached (problem+json)", body = String), (status = 500, description = "the subscribe itself failed (problem+json)", body = String), ), tag = "agents" )] pub(crate) async fn stream_subagent_term( State(state): State, Path((agent, subagent)): Path<(String, String)>, headers: HeaderMap, ) -> Result>>, problem_details::ProblemDetails> { let agent = ident(&agent).map_err(bad_request)?; let subagent = ident(&subagent).map_err(bad_request)?; let client = connected_client(&state).map_err(|e| unavailable(&e))?; let stream = crate::term_stream::replay_or_live( &client, &subagent_term::stream_name(&agent), subagent_term::subject(&agent, &subagent), crate::term_stream::last_event_id(&headers), "subagent term", ) .await?; Ok(Sse::new(stream).keep_alive(KeepAlive::default())) } /// The queue client, or why there is none: the 503 every queue-backed route /// answers. fn connected_client(state: &AppState) -> Result { let status = state .status .as_ref() .ok_or_else(|| "no swarm queue is configured on this host".to_owned())?; let client = status.queue_client(); swarm_queue_client::ensure_connected(&client).map_err(|e| swarm_queue_client::chain(&e))?; Ok(client) } /// A path segment as a single subject token, or why it is not one. fn ident(name: &str) -> Result { hive_types::Ident::parse(name).map(hive_types::Ident::into_string) } fn bad_request(reason: &str) -> problem_details::ProblemDetails { crate::routes::error_problem(StatusCode::BAD_REQUEST, reason) } fn unavailable(detail: &str) -> problem_details::ProblemDetails { crate::routes::error_problem(StatusCode::SERVICE_UNAVAILABLE, detail) } fn is_stream_not_found(e: &async_nats::jetstream::context::GetStreamError) -> bool { matches!( e.kind(), async_nats::jetstream::context::GetStreamErrorKind::JetStream(js) if js.error_code() == async_nats::jetstream::ErrorCode::STREAM_NOT_FOUND ) } fn read_failed(agent: &str, e: &dyn std::error::Error) -> problem_details::ProblemDetails { let detail = swarm_queue_client::chain(e); tracing::warn!(%agent, error = %detail, "listing subagents: reading the stream failed"); crate::routes::error_problem(StatusCode::INTERNAL_SERVER_ERROR, &detail) } #[cfg(test)] mod tests { use super::header; use swarm_wire::subagent_term::{HEADER_EFFORT, HEADER_MODEL, subagent_names}; /// The list is what `STREAM.INFO` reports for the stream's subjects, by /// name: a subagent that published once and one that published many rows /// list the same way, and the order is the name's, not the stream's. #[test] fn the_list_is_the_streams_subjects_by_name() { let reported = [ ("$SWARM.term.atlas.sub.scout".to_owned(), 12), ("$SWARM.term.atlas.sub.builder".to_owned(), 1), ]; assert_eq!( subagent_names("atlas", reported.iter().map(|(s, _)| s.as_str())), ["builder", "scout"] ); } /// The model and effort are read off the newest row's headers; a row /// without them, or with an empty one, names nothing. #[test] fn the_model_and_effort_come_from_the_rows_headers() { let mut headers = async_nats::HeaderMap::new(); headers.insert(HEADER_MODEL, "opus"); headers.insert(HEADER_EFFORT, ""); assert_eq!(header(&headers, HEADER_MODEL), Some("opus".to_owned())); assert_eq!(header(&headers, HEADER_EFFORT), None); assert_eq!(header(&async_nats::HeaderMap::new(), HEADER_MODEL), None); } }