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:
parent
ae9c3444bc
commit
da5fcbfd9b
1 changed files with 219 additions and 25 deletions
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue