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
|
|
@ -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(°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())
|
||||
}
|
||||
|
||||
/// 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
|
||||
|
|
|
|||
Loading…
Reference in a new issue