Watch
0
0
Fork
You've already forked hyperhive
0

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:
atlas 2026-10-02 22:09:43 +02:00 • committed by mara
commit d6f94e5247
35 changed files with 1529 additions and 340 deletions

View file

@ -29,6 +29,7 @@ use tokio::sync::broadcast;
use crate::events::BusEvent;
use crate::swarm_queue::{Connection, Presented};
use crate::term_msg::{ClassifyCtx, TermMsg, classify};
use hive_sh4re::term_msg::fit;
/// Subject family carrying agent terminal rows, the swarm-wide agreement this
/// publisher holds up its end of. One leaf subject per agent, so a subscriber
@ -47,9 +48,6 @@ const SUBJECT_PREFIX: &str = "$SWARM.term";
const CLIENT_ID_PREFIX: &str = "hive-";
const CLIENT_ID_SUFFIX: &str = "-agent";
/// What a row that lost its body to the payload limit carries instead.
const DROPPED_BODY: &str = "[body dropped: over the queue's payload limit]";
/// How much room to leave under the announced limit for everything the
/// publish adds around the payload — subject, headers, protocol framing.
///
@ -78,41 +76,6 @@ fn hive_from_client_id(client_id: &str) -> Option<&str> {
(!hive.is_empty()).then_some(hive)
}
/// 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.
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(&degraded)? <= 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())
}
/// The subject this agent's rows go to under the credential it connected
/// with, or `None` when a hive client id names no hive.
fn subject(presented: &Presented, agent: &str) -> Option<String> {
@ -217,12 +180,12 @@ async fn publish(client: &async_nats::Client, subject: &str, msg: TermMsg) {
#[cfg(test)]
mod tests {
use super::{DROPPED_BODY, Presented, fit, hive_from_client_id, subject};
use super::{Presented, hive_from_client_id, subject};
use crate::events::LiveEvent;
use crate::term_msg::{BodyFormat, ClassifyCtx, Level, TermMsg, classify};
use crate::term_msg::{ClassifyCtx, classify};
use hive_sh4re::term_msg::fit;
/// A row's time as `classify` renders it, for the tests below that need
/// one without classifying an event to get it.
/// The time `classify` renders for the event the test below classifies.
const ROW_TS: &str = "2026-09-13T12:35:03Z";
#[test]
@ -278,82 +241,6 @@ mod tests {
assert_eq!(subject(&unparseable, "mara"), None);
}
#[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));
}
/// What a subscriber actually receives: the payload this module hands
/// `publish` is the bare row, so the time has to be *in* it. A `ts` that
/// existed in Rust but never serialized would leave the queue exactly as