diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 934a8153..270bb054 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -63,6 +63,9 @@ does for the agent. The list refreshes every 10 seconds, so a subagent the agent spawns shows up once it writes output. A subagent that drops out of the list closes its terminal, and a later subagent with the same name starts unpicked. +Each subagent in the list shows the model and effort it runs, like +`opus · high`, beside its name; one on the default model shows only the effort. +The `/agents//terminal` page's subagent list shows them too. The preview carries only the connection badge and **expand**, so it stays clear of the text. What the agent is doing shows in the details table above it, in @@ -433,7 +436,12 @@ into a stream named `term-sub-` that swarm-controller creates for every declared agent. The stream keeps rows for 24 hours. swarm-controller serves the list as `GET /api/agents//subagents`, the subagents named by the subjects in that stream, each with `last_row_unix`, the stream's publish time of its newest row, -or `null` when that row isn't read within two seconds. The lookups run +or `null` when that row isn't read within two seconds. Each row also carries +the `Swarm-Model` and `Swarm-Effort` message headers, the model and effort the +subagent runs with, and the list returns them from the newest row as +`model` and `effort`. A subagent on the default model has no `Swarm-Model` +header and lists `model: null`; every row has the headers, so a subagent that +runs past the 24-hour window still shows them. The lookups run concurrently, sixteen at a time, so the list waits at most two seconds for each sixteen subagents. swarm-controller also relays one subagent's rows as SSE on diff --git a/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx b/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx index 718a005a..c4938050 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/SubagentTerms.tsx @@ -12,7 +12,7 @@ import { useEffect, useState } from "preact/hooks"; import { ApiErrorPanel } from "@hive/shared/api-error-panel.js"; import { Badge } from "@hive/shared/badge.js"; import { AgentTermPreview } from "./AgentTermPreview.js"; -import { STALE_S, stillListed } from "./subagent-list.js"; +import { STALE_S, runLabel, stillListed } from "./subagent-list.js"; import { useSubagentList } from "./useSubagentList.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import "./SubagentTerms.css"; @@ -41,7 +41,9 @@ export function SubagentTerms({ agentName }: { agentName: string }) {
subagents - {subagents?.map(({ name, last_row_unix }) => { + {subagents?.map((sub) => { + const { name, last_row_unix } = sub; + const runs = runLabel(sub); const stale = last_row_unix !== null && nowS - last_row_unix > STALE_S; return ( @@ -60,6 +62,7 @@ export function SubagentTerms({ agentName }: { agentName: string }) { } onClick={() => setPicked(name === selected ? null : name)} /> + {runs && {runs}} {last_row_unix !== null && ( diff --git a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css index 413a055c..afa19f76 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css +++ b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.css @@ -45,3 +45,9 @@ .ui-subagents-menu-back { color: var(--blue); } + +/* What it runs sits after the name, not centred between name and age. */ +.ui-subagents-menu-run { + margin-right: auto; + margin-left: 0.6em; +} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx index 3fc2778d..07e1cb4f 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx +++ b/frontend/packages/swarm-ui/src/agent/terminal/SubagentsMenu.tsx @@ -17,7 +17,12 @@ import { Panel } from "../../ui/panel/Panel.js"; import { Popover } from "../../ui/popover/Popover.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { termPaths } from "./AgentTermPreview.js"; -import { runningLabel, splitRunning, type Subagent } from "./subagent-list.js"; +import { + runLabel, + runningLabel, + splitRunning, + type Subagent, +} from "./subagent-list.js"; import { useSubagentList } from "./useSubagentList.js"; import "./SubagentsMenu.css"; @@ -41,6 +46,11 @@ function Entry({ aria-current={current ? "page" : undefined} > {sub.name} + {runLabel(sub) && ( + + {runLabel(sub)} + + )} {sub.last_row_unix !== null && ( diff --git a/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts b/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts index 6904b51c..0df1bf6c 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/subagent-list.ts @@ -8,6 +8,19 @@ export interface Subagent { name: string; /** Unix seconds of its newest terminal row, `null` when not readable. */ last_row_unix: number | null; + /** The model it runs; `null` on the harness's default or when not readable. */ + model: string | null; + /** Its reasoning effort; `null` when not readable. */ + effort: string | null; +} + +/** + * What a subagent runs, as one short label: `opus · high`. Whichever of the + * two the controller could not read is left out, and an empty string means + * there is nothing to show. + */ +export function runLabel(sub: Pick): string { + return [sub.model, sub.effort].filter((v) => v).join(" · "); } /** A subagent quiet for longer than this is no longer counted as running. */ diff --git a/frontend/packages/swarm-ui/test/subagent-list.test.mjs b/frontend/packages/swarm-ui/test/subagent-list.test.mjs index dcf1f282..da1207cc 100644 --- a/frontend/packages/swarm-ui/test/subagent-list.test.mjs +++ b/frontend/packages/swarm-ui/test/subagent-list.test.mjs @@ -7,6 +7,7 @@ import { STALE_S, byRecency, isRunning, + runLabel, runningLabel, splitRunning, stillListed, @@ -16,6 +17,8 @@ const NOW = 1_000_000; const sub = (name, ago) => ({ name, last_row_unix: ago === null ? null : NOW - ago, + model: null, + effort: null, }); test("a subagent with a recent row is running, a quiet one is not", () => { @@ -69,3 +72,11 @@ test("a pick holds only while the refreshed list still has it", () => { assert.equal(stillListed("a", null), null); assert.equal(stillListed(null, subs), null); }); + +test("the run label names the model and effort, whichever are known", () => { + assert.equal(runLabel({ model: "opus", effort: "high" }), "opus · high"); + assert.equal(runLabel({ model: "opus", effort: null }), "opus"); + assert.equal(runLabel({ model: null, effort: "medium" }), "medium"); + assert.equal(runLabel({ model: null, effort: null }), ""); + assert.equal(runLabel({ model: "", effort: "" }), ""); +}); diff --git a/hive-subagent-mcp/src/session.rs b/hive-subagent-mcp/src/session.rs index b923e53d..5ee37f9a 100644 --- a/hive-subagent-mcp/src/session.rs +++ b/hive-subagent-mcp/src/session.rs @@ -1814,6 +1814,9 @@ fn continue_on_runtime( struct LivenessSink { state: Arc, name: String, + /// The model and effort this run was started with, stamped on every row + /// it publishes. + info: Arc, /// Where to report the first non-terminal event, on a `resume` whose /// caller is still waiting to hear whether it landed. `None` on a /// `start`, which has nobody waiting. @@ -1845,11 +1848,20 @@ impl LivenessSink { /// one to take it. fn publish(&self, line: impl FnOnce() -> Output) { if let Some(term) = &self.state.term { - term.offer(&self.name, line()); + term.offer(&self.name, &self.info, line()); } } } +/// What a run's rows say about how it was started: its `config`'s model and +/// effort, which the swarm shows beside the subagent's name. +fn run_info(config: &Config) -> Arc { + Arc::new(crate::swarm_term::RunInfo { + model: config.model.clone(), + effort: config.effort.clone(), + }) +} + /// Spawn the child (synchronous — `Claude::spawn` returns with a real pid /// the instant the process exists), track it in `running`, and hand the /// actual turn off to a background task so nobody blocks on the whole turn. @@ -1911,6 +1923,7 @@ fn spawn_and_track( None }; + let info = run_info(config); let config = config.clone(); let state = Arc::clone(state); let task_name = name.to_owned(); @@ -1922,6 +1935,7 @@ fn spawn_and_track( let sink = LivenessSink { state: Arc::clone(&state), name: task_name.clone(), + info: Arc::clone(&info), verdict: verdict.clone(), }; let end = classify_end(running.wait(&prompt, &sink).await, searched.as_deref()); @@ -2025,6 +2039,7 @@ fn spawn_and_track_acp( state.clear_kill(name); state.note_event(name); + let info = run_info(&config); let state = Arc::clone(state); let task_name = name.to_owned(); tokio::spawn(async move { @@ -2033,6 +2048,7 @@ fn spawn_and_track_acp( let sink = LivenessSink { state: Arc::clone(&state), name: task_name.clone(), + info: Arc::clone(&info), verdict: verdict.clone(), }; let end = match runtime.run(&config, &prompt, &sink).await { @@ -3455,6 +3471,7 @@ mod tests { let sink = LivenessSink { state: Arc::clone(&state), name: "n".to_owned(), + info: Arc::default(), verdict: None, }; (state, sink) @@ -3745,6 +3762,7 @@ mod tests { let sink = LivenessSink { state: Arc::new(State::new(PathBuf::from("/dev/null"), signal_url())), name: "n".to_owned(), + info: Arc::default(), verdict: Some(Arc::new(Mutex::new(Some(tx)))), }; (rx, sink) diff --git a/hive-subagent-mcp/src/swarm_term.rs b/hive-subagent-mcp/src/swarm_term.rs index a4ecd14b..667d7d13 100644 --- a/hive-subagent-mcp/src/swarm_term.rs +++ b/hive-subagent-mcp/src/swarm_term.rs @@ -74,8 +74,32 @@ pub enum Output { Stderr(String), } +/// The model and effort a subagent was started with, published as headers on +/// each of its rows (`swarm_wire::subagent_term`). `None` is the harness's +/// default, which has no name to publish. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RunInfo { + pub model: Option, + pub effort: Option, +} + +impl RunInfo { + /// The headers for one row: only the values there are. + fn headers(&self) -> async_nats::HeaderMap { + let mut headers = async_nats::HeaderMap::new(); + if let Some(model) = self.model.as_deref().filter(|v| !v.is_empty()) { + headers.insert(subagent_term::HEADER_MODEL, model); + } + if let Some(effort) = self.effort.as_deref().filter(|v| !v.is_empty()) { + headers.insert(subagent_term::HEADER_EFFORT, effort); + } + headers + } +} + struct Line { subagent: String, + info: Arc, output: Output, /// Unix seconds when the sink saw the line. ts: i64, @@ -131,14 +155,16 @@ impl Publisher { (publisher, rx) } - /// Queue `output` from `subagent` for publishing. Never blocks: a full - /// queue drops the line and counts it. - pub fn offer(&self, subagent: &str, output: Output) { + /// Queue `output` from `subagent` for publishing, with the `info` its rows + /// carry as headers. Never blocks: a full queue drops the line and counts + /// it. + pub fn offer(&self, subagent: &str, info: &Arc, output: Output) { let ts = SystemTime::now() .duration_since(UNIX_EPOCH) .map_or(0, |d| i64::try_from(d.as_secs()).unwrap_or(i64::MAX)); let line = Line { subagent: subagent.to_owned(), + info: Arc::clone(info), output, ts, }; @@ -296,8 +322,9 @@ async fn run(mut rx: mpsc::Receiver, target: Target, dropped: Arc Vec { } /// Offer one row. Every failure is terminal for that row and for nothing else. -async fn publish(client: &async_nats::Client, subject: &str, msg: TermMsg) { +async fn publish( + client: &async_nats::Client, + subject: &str, + headers: &async_nats::HeaderMap, + msg: TermMsg, +) { // Unconnected, the client buffers rather than fails, and reports the // library's default payload limit rather than the server's. if let Err(e) = swarm_queue_client::ensure_connected(client) { @@ -338,7 +370,10 @@ async fn publish(client: &async_nats::Client, subject: &str, msg: TermMsg) { return; } }; - if let Err(e) = client.publish(subject.to_owned(), payload.into()).await { + let sent = client + .publish_with_headers(subject.to_owned(), headers.clone(), payload.into()) + .await; + if let Err(e) = sent { tracing::warn!(error = %e, "subagent terminal: publish failed, row dropped"); } } @@ -412,11 +447,45 @@ mod tests { #[test] fn a_full_queue_drops_and_counts_rather_than_blocks() { let (publisher, _rx) = Publisher::channel(1); - publisher.offer("scout", Output::Stdout("one".to_owned())); - publisher.offer("scout", Output::Stdout("two".to_owned())); + let info = Arc::new(RunInfo::default()); + publisher.offer("scout", &info, Output::Stdout("one".to_owned())); + publisher.offer("scout", &info, Output::Stdout("two".to_owned())); assert_eq!(publisher.dropped.load(Ordering::Relaxed), 1); } + /// The list endpoint reads these two headers off a subagent's newest row. + #[test] + fn a_row_carries_the_model_and_effort_the_subagent_runs() { + let info = RunInfo { + model: Some("opus".to_owned()), + effort: Some("high".to_owned()), + }; + let headers = info.headers(); + assert_eq!( + headers + .get(subagent_term::HEADER_MODEL) + .map(ToString::to_string), + Some("opus".to_owned()) + ); + assert_eq!( + headers + .get(subagent_term::HEADER_EFFORT) + .map(ToString::to_string), + Some("high".to_owned()) + ); + } + + /// A subagent on the default model names none, and an empty header would + /// read as a model called "". + #[test] + fn a_value_that_is_not_set_is_not_published() { + let info = RunInfo { + model: None, + effort: Some(String::new()), + }; + assert!(info.headers().is_empty()); + } + #[test] fn every_kind_of_line_renders_as_stamped_rows() { let mut ctx = ClassifyCtx::default(); diff --git a/swarm-controller/src/subagent_term.rs b/swarm-controller/src/subagent_term.rs index 5392de5c..57de40bd 100644 --- a/swarm-controller/src/subagent_term.rs +++ b/swarm-controller/src/subagent_term.rs @@ -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, + /// 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, } #[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> = futures_util::stream::iter(names.clone()) - .map(|name| async move { last_row_unix(stream_ref, agent_ref, &name).await }) + let lookups: Vec> = 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, + 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 { +) -> 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(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); + } } diff --git a/swarm-wire/src/subagent_term.rs b/swarm-wire/src/subagent_term.rs index 8204d90e..5ce31541 100644 --- a/swarm-wire/src/subagent_term.rs +++ b/swarm-wire/src/subagent_term.rs @@ -32,6 +32,16 @@ const SUBAGENT_TOKEN: &str = "sub"; /// its last output. pub const MAX_AGE: std::time::Duration = std::time::Duration::from_hours(24); +/// The message header carrying the model a subagent runs, on every row it +/// publishes. Absent when the subagent runs the harness's default model, which +/// is no value to name. The list endpoint reads it off the newest row, so a +/// subagent that runs for longer than [`MAX_AGE`] still shows it. +pub const HEADER_MODEL: &str = "Swarm-Model"; + +/// The message header carrying a subagent's reasoning effort, published with +/// [`HEADER_MODEL`]. +pub const HEADER_EFFORT: &str = "Swarm-Effort"; + /// The stream holding `agent`'s subagent rows: `term-sub-`. #[must_use] pub fn stream_name(agent: &str) -> String {