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
|
|
@ -13,11 +13,10 @@ hive-priv-sock.workspace = true
|
|||
hive-types.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
# `stream_enrich` walks raw claude `stream-json` values into `term_msg` rows.
|
||||
serde_json.workspace = true
|
||||
strum.workspace = true
|
||||
# Facade only, for the one warn in `permissions::ToolGroup::parse_list`: an
|
||||
# unknown tool-group name is skipped rather than fatal, so the log line is the
|
||||
# only trace it leaves.
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -9,4 +9,6 @@ pub mod manager;
|
|||
pub mod paths;
|
||||
pub mod permissions;
|
||||
pub mod schedule;
|
||||
pub mod stream_enrich;
|
||||
pub mod term_msg;
|
||||
pub mod wire_time;
|
||||
|
|
|
|||
1426
hive-sh4re/src/stream_enrich.rs
Normal file
1426
hive-sh4re/src/stream_enrich.rs
Normal file
File diff suppressed because it is too large
Load diff
277
hive-sh4re/src/term_msg.rs
Normal file
277
hive-sh4re/src/term_msg.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
//! Terminal-message wire shape: one classified terminal row, as the
|
||||
//! per-agent web UI's live/history endpoints serve it and as the swarm queue
|
||||
//! carries it from an agent and from its subagents.
|
||||
//!
|
||||
//! 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: whoever classifies an
|
||||
//! event stamps every row it produced with the time of that event, 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;
|
||||
|
||||
#[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 with [`TermMsg::at`] by the
|
||||
/// caller that classified the event — 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
|
||||
/// (`hive-agent`'s `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)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a row that lost its body to the payload limit carries instead.
|
||||
pub const DROPPED_BODY: &str = "[body dropped: over the queue's payload limit]";
|
||||
|
||||
/// Bring `msg` under `limit` serialized bytes, or report that it cannot be.
|
||||
///
|
||||
/// The body is the only field that carries arbitrary length — a diff, a whole
|
||||
/// tool result — so it is the only one worth spending: replacing it keeps the
|
||||
/// row's identity, level, summary and time, which is what makes the row
|
||||
/// readable at all, and a reader sees that something was there rather than
|
||||
/// seeing nothing.
|
||||
///
|
||||
/// `None` means even the degraded row does not fit, so the caller reports it
|
||||
/// instead of publishing. That matters more than it looks: an oversize publish
|
||||
/// is not truncated by the server, it is refused and the connection is closed,
|
||||
/// which costs the row *and* every row racing behind it through the reconnect.
|
||||
#[must_use]
|
||||
pub fn fit(msg: TermMsg, limit: usize) -> Option<TermMsg> {
|
||||
if serialized_len(&msg)? <= limit {
|
||||
return Some(msg);
|
||||
}
|
||||
// Rebuilt rather than mutated in place: `body_format` describes the body,
|
||||
// and a marker left tagged `Diff` renders as a broken diff downstream.
|
||||
let degraded = TermMsg {
|
||||
body: Some(DROPPED_BODY.to_owned()),
|
||||
body_format: None,
|
||||
..msg
|
||||
};
|
||||
(serialized_len(°raded)? <= limit).then_some(degraded)
|
||||
}
|
||||
|
||||
/// Serialized size of a row, or `None` if it does not serialize at all.
|
||||
///
|
||||
/// Measured by serializing rather than estimated from field lengths: the
|
||||
/// payload is what the server measures, and JSON escaping makes the two differ
|
||||
/// by an unbounded factor on exactly the rows that are already near the limit.
|
||||
fn serialized_len(msg: &TermMsg) -> Option<usize> {
|
||||
serde_json::to_vec(msg).ok().map(|v| v.len())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BodyFormat, DROPPED_BODY, Level, TermMsg, fit};
|
||||
|
||||
/// A row's time, for the tests below that need one.
|
||||
const ROW_TS: &str = "2026-09-13T12:35:03Z";
|
||||
|
||||
#[test]
|
||||
fn a_row_that_already_fits_is_published_unchanged() {
|
||||
let msg = TermMsg::new(Level::Info, "turn ok")
|
||||
.icon("✅")
|
||||
.body("a short body", Some(BodyFormat::Markdown));
|
||||
let fitted = fit(msg, 4096).expect("a small row fits");
|
||||
assert_eq!(fitted.body.as_deref(), Some("a short body"));
|
||||
assert_eq!(fitted.body_format, Some(BodyFormat::Markdown));
|
||||
}
|
||||
|
||||
/// The case the degrade exists for: a body larger than the limit costs the
|
||||
/// body and nothing else, and what comes back is actually under the limit
|
||||
/// rather than merely smaller.
|
||||
#[test]
|
||||
fn an_oversize_body_is_replaced_and_the_result_fits() {
|
||||
let limit = 512;
|
||||
let msg = TermMsg::new(Level::Info, "Edit(src/main.rs)")
|
||||
.icon("🔧")
|
||||
.body("x".repeat(limit * 4), Some(BodyFormat::Diff))
|
||||
.coalesce("tool-1")
|
||||
.at(ROW_TS);
|
||||
let fitted = fit(msg, limit).expect("dropping the body brings this under the limit");
|
||||
assert_eq!(fitted.body.as_deref(), Some(DROPPED_BODY));
|
||||
// The tag describes a body that is no longer there; left set, a reader
|
||||
// renders the marker as a diff.
|
||||
assert_eq!(fitted.body_format, None);
|
||||
// The fields that make the row readable survive.
|
||||
assert_eq!(fitted.summary, "Edit(src/main.rs)");
|
||||
assert_eq!(fitted.icon.as_deref(), Some("🔧"));
|
||||
assert_eq!(fitted.coalesce_key.as_deref(), Some("tool-1"));
|
||||
// Including the time: a degraded row that lost it would be a row a
|
||||
// subscriber cannot place, and nothing downstream could tell.
|
||||
assert_eq!(fitted.ts, ROW_TS);
|
||||
assert!(
|
||||
serde_json::to_vec(&fitted).expect("serialises").len() <= limit,
|
||||
"the degraded row must be under the limit, not merely smaller"
|
||||
);
|
||||
}
|
||||
|
||||
/// A row whose summary alone exceeds the limit cannot be degraded into
|
||||
/// one, and publishing it anyway would cost the connection rather than the
|
||||
/// row. Reported by the caller, never sent.
|
||||
#[test]
|
||||
fn a_row_too_large_even_without_its_body_is_refused() {
|
||||
let limit = 256;
|
||||
let msg = TermMsg::new(Level::Warn, "s".repeat(limit * 4)).body("x".repeat(limit), None);
|
||||
assert!(fit(msg, limit).is_none());
|
||||
}
|
||||
|
||||
/// Control for the test above: the same oversize summary with a body that
|
||||
/// would fit still refuses, so the refusal is about total size and not
|
||||
/// about the body having been present.
|
||||
#[test]
|
||||
fn the_refusal_is_about_size_rather_than_the_body_being_present() {
|
||||
let limit = 256;
|
||||
let msg = TermMsg::new(Level::Warn, "s".repeat(limit * 4));
|
||||
assert!(fit(msg, limit).is_none());
|
||||
}
|
||||
|
||||
/// JSON escaping is why the size is measured by serializing: a body of
|
||||
/// quotes serializes to twice its own length, so a row that fits by
|
||||
/// character count can still be refused on the wire.
|
||||
#[test]
|
||||
fn the_limit_is_measured_on_the_serialized_bytes() {
|
||||
let body = "\"".repeat(200);
|
||||
let msg = TermMsg::new(Level::Info, "quotes").body(body.clone(), None);
|
||||
let limit = body.len() + 64;
|
||||
// Well under the limit as characters, over it once escaped.
|
||||
assert!(
|
||||
serde_json::to_vec(&msg).expect("serialises").len() > limit,
|
||||
"this fixture must be oversize only after escaping"
|
||||
);
|
||||
let fitted = fit(msg, limit).expect("dropping the body fits");
|
||||
assert_eq!(fitted.body.as_deref(), Some(DROPPED_BODY));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue