swarm: show subagent terminals in the swarm UI
An agent's subagent daemon publishes each subagent's output as terminal
rows on `$SWARM.term.<agent>.sub.<subagent>`, as the agent, into a
per-agent stream it creates itself; swarm-controller lists an agent's
subagents from that stream's subjects and relays one subagent's rows as
SSE; the swarm UI lists them under the agent's terminal preview and
reuses AgentTermPreview, full-screen tab included, with no input.
- swarm-nats.nix: the agent token may also publish
`$SWARM.term.{agent}.sub.>` and `$JS.API.STREAM.CREATE|INFO` on
`term-sub-{agent}`, and nothing else of JetStream. A module-eval arm
pins the agent-token grant as an exact list.
- mcp.nix: hive-subagent-daemon loads the agent's store identity
(`hive-agent-bao-cert/-key/-server-ca`, the ones hive-agent loads)
whenever the agent has a store, not only on the opencode preset. The
agent's own queue secret lives in the store, so this is the credential
the harness connects with.
- hive-subagent-mcp: `swarm_term` reads the agent's queue secret under
that identity, connects with the agent token, opens or creates
`term-sub-<agent>` (max_age 24h), and publishes classified rows from
the sink every subagent line already passes through. The sink only
queues (bounded, drop-and-count); a missing store, refused credential,
failed stream create or failed publish is a log line.
- The stream-json classifier (`stream_enrich`) and the `TermMsg` row
types plus `fit` move from the hive-agent binary into hive-sh4re, so
the subagent daemon publishes the rows AgentTermPreview already
renders. hive-agent keeps its LiveEvent classifier on top.
- swarm-controller: `GET /api/agents/{name}/subagents` and
`GET /api/agents/{name}/subagents/{subagent}/term/stream`.
- docs/swarm: what the UI shows and what the queue carries.
Closes #4827
This commit is contained in:
parent
b90be9e65e
commit
d6f94e5247
35 changed files with 1529 additions and 340 deletions
|
|
@ -1,5 +1,5 @@
|
|||
//! Terminal-message wire shape: what the per-agent web UI's live/history
|
||||
//! endpoints actually serve for the "terminal" event stream, as opposed to
|
||||
//! The harness's events as terminal rows: what the per-agent web UI's
|
||||
//! live/history endpoints serve for the "terminal" event stream, as opposed to
|
||||
//! agent-state changes (`StatusChanged`/`ModelChanged`/`EffortChanged`/
|
||||
//! `TokenUsageChanged`/`TurnStateChanged`), which have never rendered as
|
||||
//! terminal rows (the header/badges poll `/api/state`, not this stream) and
|
||||
|
|
@ -9,167 +9,17 @@
|
|||
//! map to exactly one, but `LiveEvent::Stream` (one raw claude
|
||||
//! `stream-json` line) can expand to several: an `assistant` message with
|
||||
//! both a text block and a `tool_use` block produces two rows.
|
||||
//!
|
||||
//! Seven fields, and classification belongs **here**, not in the client: the
|
||||
//! web UI renders what it is handed and owns no per-tool dispatch table, so
|
||||
//! a new tool needs no frontend change. `level` carries styling, so a row
|
||||
//! never names a CSS class. `kind`, `unread`, `from` and `expanded_default`
|
||||
//! are deliberately absent — adding one back is a design change, not an
|
||||
//! oversight.
|
||||
//!
|
||||
//! The seventh field is `ts`, and it is the row's own: [`classify`] stamps
|
||||
//! every row it returns with the time of the event it classified, not the
|
||||
//! time it ran. The swarm queue publishes the bare row with no envelope
|
||||
//! around it, so a subscriber that reads a row has nowhere else to learn
|
||||
//! when the thing happened; carrying the source event's time means a row
|
||||
//! replayed out of sqlite months later still says when it happened rather
|
||||
//! than when it was read.
|
||||
|
||||
use chrono::{DateTime, SecondsFormat};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
pub use hive_sh4re::term_msg::{ClassifyCtx, Level, TermMsg, iso8601_utc};
|
||||
|
||||
use crate::events::LiveEvent;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Level {
|
||||
/// Low-signal / ambient chatter — thinking, progress ticks, harness
|
||||
/// housekeeping. The client's default rendering can dim/de-emphasize
|
||||
/// these without hiding them outright.
|
||||
Debug,
|
||||
/// Routine substantive content — turn boundaries, assistant text, tool
|
||||
/// calls/results, message bodies.
|
||||
Info,
|
||||
/// Heads-up, not necessarily broken — stderr lines, an unclassified
|
||||
/// event shape landing (the old `.sys` catch-all), API retries.
|
||||
Warn,
|
||||
/// Something actually failed — a turn ending non-ok, a tool result with
|
||||
/// `is_error: true`.
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BodyFormat {
|
||||
Markdown,
|
||||
Diff,
|
||||
}
|
||||
|
||||
/// One terminal row. `body_format: None` with `body: Some(_)` means plain
|
||||
/// text (the common case — no explicit tag on the wire for it, same logic
|
||||
/// as `body` itself being absent meaning "nothing to expand").
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TermMsg {
|
||||
/// When the classified event happened, ISO 8601 / RFC 3339 UTC.
|
||||
///
|
||||
/// Empty on a freshly built row and filled by [`classify`], which is
|
||||
/// the only path a row reaches either wire by — a builder that had to
|
||||
/// be handed the time would repeat the same value across the several
|
||||
/// rows one `stream-json` line expands into.
|
||||
pub ts: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
pub level: Level,
|
||||
pub summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body_format: Option<BodyFormat>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub coalesce_key: Option<String>,
|
||||
}
|
||||
|
||||
impl TermMsg {
|
||||
pub fn new(level: Level, summary: impl Into<String>) -> Self {
|
||||
Self {
|
||||
ts: String::new(),
|
||||
icon: None,
|
||||
level,
|
||||
summary: summary.into(),
|
||||
body: None,
|
||||
body_format: None,
|
||||
coalesce_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn icon(mut self, icon: impl Into<String>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn body(mut self, body: impl Into<String>, format: Option<BodyFormat>) -> Self {
|
||||
self.body = Some(body.into());
|
||||
self.body_format = format;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn coalesce(mut self, key: impl Into<String>) -> Self {
|
||||
self.coalesce_key = Some(key.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Stamp the row with the time of the event it came from.
|
||||
#[must_use]
|
||||
pub fn at(mut self, ts: impl Into<String>) -> Self {
|
||||
self.ts = ts.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Render an event's unix-seconds stamp as ISO 8601 / RFC 3339 UTC.
|
||||
///
|
||||
/// Seconds rather than milliseconds because that is the resolution the
|
||||
/// event bus and the sqlite `events.ts` column actually carry
|
||||
/// (`crate::events`) — a `.000` on every row would be precision the source
|
||||
/// does not have. UTC rather than local: the reader of a swarm-published
|
||||
/// row is not on the machine that wrote it, and an offset-carrying stamp
|
||||
/// would make two agents' rows sort by string differently than by time.
|
||||
///
|
||||
/// A stamp outside the representable range renders as the epoch: a row
|
||||
/// whose only defect is an absurd clock is still worth reading.
|
||||
#[must_use]
|
||||
pub fn iso8601_utc(unix_seconds: i64) -> String {
|
||||
DateTime::from_timestamp(unix_seconds, 0)
|
||||
.unwrap_or_default()
|
||||
.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
/// Per-connection/per-request classification state. A live SSE stream keeps
|
||||
/// one of these alive for the connection's lifetime — `tool_use` id → name
|
||||
/// correlation, so a `tool_result` can tell it's answering a `recv` call and
|
||||
/// render its body as markdown instead of plain text. The history endpoint
|
||||
/// uses a fresh one per page: correlation only works within the page
|
||||
/// actually returned, not across the live/history boundary. Accepted
|
||||
/// degradation — the only user-visible effect is a `recv` result whose
|
||||
/// `tool_use` fell on the other side of a page/reconnect boundary rendering
|
||||
/// its body as plain text instead of markdown.
|
||||
#[derive(Default)]
|
||||
pub struct ClassifyCtx {
|
||||
tool_name_by_id: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ClassifyCtx {
|
||||
pub fn record_tool_use(&mut self, id: &str, name: &str) {
|
||||
self.tool_name_by_id.insert(id.to_owned(), name.to_owned());
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tool_name(&self, id: &str) -> Option<&str> {
|
||||
self.tool_name_by_id.get(id).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify one [`LiveEvent`] into zero or more terminal rows, each
|
||||
/// stamped with `ts` — the event's own unix-seconds time, from the bus on
|
||||
/// the live path and from the `events` table on replay.
|
||||
///
|
||||
/// The stamp is applied here, over the rows the classifiers return, so that
|
||||
/// every row reaching a wire carries one: a row built anywhere else has an
|
||||
/// empty `ts` and cannot get out without passing through this function.
|
||||
/// every row the harness puts on a wire carries one.
|
||||
pub fn classify(ev: &LiveEvent, ts: i64, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
|
||||
let ts = iso8601_utc(ts);
|
||||
classify_rows(ev, ctx)
|
||||
|
|
@ -202,7 +52,7 @@ fn classify_rows(ev: &LiveEvent, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
|
|||
vec![msg]
|
||||
}
|
||||
LiveEvent::Note { text } => vec![classify_note(text)],
|
||||
LiveEvent::Stream(v) => crate::stream_enrich::classify_stream_value(v, ctx),
|
||||
LiveEvent::Stream(v) => hive_sh4re::stream_enrich::classify_stream_value(v, ctx),
|
||||
// Agent-state transitions never render as terminal rows — the
|
||||
// header/badges read `/api/state`, not this stream (see module doc).
|
||||
LiveEvent::StatusChanged { .. }
|
||||
|
|
|
|||
Loading…
Reference in a new issue