Watch
0
0
Fork
You've already forked hyperhive
0

subagents publish their model and effort as headers on every row, and the swarm shows them

The subagent daemon stamps Swarm-Model and Swarm-Effort on each row it
publishes. The list endpoint reads them off the newest row it already fetches,
so a long-running subagent keeps them past the stream's 24 hour window, and the
terminal page's menu and the agent panel's list show them beside the name.
This commit is contained in:
iris 2026-10-28 20:54:16 +01:00 • committed by mara
commit da5fcbfd9b

View file

@ -11,6 +11,8 @@
//! 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.
@ -120,6 +122,11 @@ pub(crate) struct SubagentEntry {
/// 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<i64>,
/// 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<String>,
/// Its reasoning effort, from the `Swarm-Effort` header, with the same `None`.
pub effort: Option<String>,
}
#[utoipa::path(
@ -165,8 +172,8 @@ pub(crate) async fn list_subagents(
// item type trips a "`FnOnce` is not general enough" inference error where
// `routes!` checks this fn as a handler.
let (stream_ref, agent_ref) = (&stream, &agent);
let lookups: Vec<Result<i64, String>> = futures_util::stream::iter(names.clone())
.map(|name| async move { last_row_unix(stream_ref, agent_ref, &name).await })
let lookups: Vec<Result<NewestRow, String>> = futures_util::stream::iter(names.clone())
.map(|name| async move { newest_row(stream_ref, agent_ref, &name).await })
.buffered(LOOKUP_CONCURRENCY)
.collect()
.await;
@ -183,26 +190,52 @@ pub(crate) async fn list_subagents(
let entries = names
.into_iter()
.zip(lookups)
.map(|(name, lookup)| SubagentEntry {
name,
last_row_unix: lookup.ok(),
.map(|(name, lookup)| {
let row = lookup.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),
}
})
.collect();
Ok(Json(entries))
}
/// The publish time of 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 last_row_unix(
/// 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<String>,
effort: Option<String>,
}
/// One header's value, or `None` when the row has none or an empty one.
fn header(headers: &async_nats::HeaderMap, name: &str) -> Option<String> {
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<i64, String> {
) -> Result<NewestRow, String> {
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(row.time.unix_timestamp()),
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:?}")),
}
@ -291,7 +324,8 @@ fn read_failed(agent: &str, e: &dyn std::error::Error) -> problem_details::Probl
#[cfg(test)]
mod tests {
use swarm_wire::subagent_term::subagent_names;
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
@ -307,4 +341,16 @@ mod tests {
["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);
}
}