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

@ -58,6 +58,7 @@ mod queue_identity;
mod read_policy;
mod status;
mod store;
mod subagent_term;
mod term_stream;
mod vcs_metrics;
mod wanted;
@ -2992,6 +2993,8 @@ fn build_app(state: AppState) -> axum::Router {
.routes(routes!(linked_accounts::get_linked_accounts))
.routes(routes!(get_hive_wanted))
.routes(routes!(term_stream::stream_agent_term))
.routes(routes!(subagent_term::list_subagents))
.routes(routes!(subagent_term::stream_subagent_term))
.routes(routes!(agent_state_stream::stream_agent_state))
.routes(routes!(issue_report::get_repos))
.routes(routes!(issue_report::get_issue_report_all))
@ -4981,6 +4984,55 @@ mod tests {
"{problem:?}"
);
}
#[tokio::test]
async fn listing_subagents_needs_a_queue_and_an_identifier() {
let (state, _sched) = state_with_roster();
let no_queue = super::subagent_term::list_subagents(
axum::extract::State(state.clone()),
axum::extract::Path("iris".to_owned()),
)
.await
.expect_err("no queue is configured");
assert_eq!(
no_queue.status,
Some(axum::http::StatusCode::SERVICE_UNAVAILABLE),
"{no_queue:?}"
);
let bad_name = super::subagent_term::list_subagents(
axum::extract::State(state),
axum::extract::Path("iris.sub".to_owned()),
)
.await
.expect_err("a dot would widen the subject");
assert_eq!(
bad_name.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{bad_name:?}"
);
}
/// A subagent name is a subject token, so a wildcard or a dot in it is
/// refused before anything subscribes.
#[tokio::test]
async fn a_subagent_stream_refuses_a_name_that_is_not_one_token() {
let (state, _sched) = state_with_roster();
for subagent in ["*", ">", "a.b"] {
let Err(problem) = super::subagent_term::stream_subagent_term(
axum::extract::State(state.clone()),
axum::extract::Path(("iris".to_owned(), subagent.to_owned())),
)
.await
else {
panic!("{subagent:?} must be refused");
};
assert_eq!(
problem.status,
Some(axum::http::StatusCode::BAD_REQUEST),
"{subagent:?}: {problem:?}"
);
}
}
}
#[cfg(test)]

View file

@ -0,0 +1,172 @@
//! An agent's subagents: which ones the swarm has rows for, and a live SSE
//! relay of one subagent's terminal.
//!
//! The agent's subagent daemon publishes each subagent's classified rows on
//! `$SWARM.term.{agent}.sub.{subagent}` under the agent's own queue credential,
//! into the stream `term-sub-{agent}` it creates itself
//! (`swarm_queue_client::subagent_term`). Subagents are spawned on demand
//! under caller-chosen names, so the list is the set of subjects that stream
//! holds rows for, read with `STREAM.INFO`. This daemon never creates the
//! stream: a missing one is an agent with no subagent output yet, and lists
//! nothing.
//!
//! **No hive lookup.** Unlike [`crate::term_stream`], there is no
//! hive-scoped subject to follow: only the agent's own credential is granted
//! these subjects.
//!
//! **Live tail only, output only.** The relay is a core subscription, the
//! same as the agent's own terminal route, so a reader sees rows published
//! while it is attached. Nothing is sent toward a subagent: subagents take no
//! input from the swarm.
use std::convert::Infallible;
use axum::Json;
use axum::extract::{Path, State};
use axum::http::StatusCode;
use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::{Stream, StreamExt as _, TryStreamExt as _};
use swarm_queue_client::subagent_term;
use crate::AppState;
#[utoipa::path(
get,
path = "/api/agents/{name}/subagents",
params(("name" = String, Path, description = "agent whose subagents to list")),
responses(
(status = 200, description = "names of the agent's subagents with terminal rows \
in the swarm queue, sorted; empty when the agent has published none",
body = Vec<String>),
(status = 400, description = "the agent name is not shaped like an identifier \
(problem+json)", body = String),
(status = 503, description = "no swarm queue is configured on this host, or the \
queue could not be reached (problem+json)", body = String),
(status = 500, description = "reading the agent's subagent stream failed \
(problem+json)", body = String),
),
tag = "agents"
)]
pub(crate) async fn list_subagents(
State(state): State<AppState>,
Path(agent): Path<String>,
) -> Result<Json<Vec<String>>, problem_details::ProblemDetails> {
let agent = ident(&agent).map_err(bad_request)?;
let client = connected_client(&state).map_err(|e| unavailable(&e))?;
let js = async_nats::jetstream::new(client);
let stream_name = subagent_term::stream_name(&agent);
let stream = match js.get_stream(&stream_name).await {
Ok(stream) => stream,
Err(e) if is_stream_not_found(&e) => return Ok(Json(Vec::new())),
Err(e) => return Err(read_failed(&agent, &e)),
};
let subjects: Vec<(String, usize)> = stream
.info_with_subjects(subagent_term::stream_subjects(&agent))
.await
.map_err(|e| read_failed(&agent, &e))?
.try_collect()
.await
.map_err(|e| read_failed(&agent, &e))?;
Ok(Json(subagent_term::subagent_names(
&agent,
subjects.iter().map(|(s, _)| s.as_str()),
)))
}
#[utoipa::path(
get,
path = "/api/agents/{name}/subagents/{subagent}/term/stream",
params(
("name" = String, Path, description = "agent the subagent belongs to"),
("subagent" = String, Path, description = "subagent whose terminal to stream"),
),
responses(
(status = 200, description = "server-sent event stream; each event's `data` is \
one already-classified TermMsg row, JSON as the agent's subagent daemon \
published it (opaque to this daemon) — live only, no replay",
body = String, content_type = "text/event-stream"),
(status = 400, description = "the agent or subagent name is not shaped like an \
identifier (problem+json)", body = String),
(status = 503, description = "no swarm queue is configured on this host, or the \
queue could not be reached (problem+json)", body = String),
(status = 500, description = "the subscribe itself failed (problem+json)",
body = String),
),
tag = "agents"
)]
pub(crate) async fn stream_subagent_term(
State(state): State<AppState>,
Path((agent, subagent)): Path<(String, String)>,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, problem_details::ProblemDetails> {
let agent = ident(&agent).map_err(bad_request)?;
let subagent = ident(&subagent).map_err(bad_request)?;
let client = connected_client(&state).map_err(|e| unavailable(&e))?;
let subscriber = crate::term_stream::subscribe_all(
&client,
&[subagent_term::subject(&agent, &subagent)],
"subagent term",
)
.await?;
let stream =
subscriber.map(|msg| Ok(Event::default().data(String::from_utf8_lossy(&msg.payload))));
Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
}
/// The queue client, or why there is none: the 503 every queue-backed route
/// answers.
fn connected_client(state: &AppState) -> Result<async_nats::Client, String> {
let status = state
.status
.as_ref()
.ok_or_else(|| "no swarm queue is configured on this host".to_owned())?;
let client = status.queue_client();
swarm_queue_client::ensure_connected(&client).map_err(|e| swarm_queue_client::chain(&e))?;
Ok(client)
}
/// A path segment as a single subject token, or why it is not one.
fn ident(name: &str) -> Result<String, &'static str> {
hive_types::Ident::parse(name).map(hive_types::Ident::into_string)
}
fn bad_request(reason: &str) -> problem_details::ProblemDetails {
crate::error_problem(StatusCode::BAD_REQUEST, reason)
}
fn unavailable(detail: &str) -> problem_details::ProblemDetails {
crate::error_problem(StatusCode::SERVICE_UNAVAILABLE, detail)
}
fn is_stream_not_found(e: &async_nats::jetstream::context::GetStreamError) -> bool {
matches!(
e.kind(),
async_nats::jetstream::context::GetStreamErrorKind::JetStream(js)
if js.error_code() == async_nats::jetstream::ErrorCode::STREAM_NOT_FOUND
)
}
fn read_failed(agent: &str, e: &dyn std::error::Error) -> problem_details::ProblemDetails {
let detail = swarm_queue_client::chain(e);
tracing::warn!(%agent, error = %detail, "listing subagents: reading the stream failed");
crate::error_problem(StatusCode::INTERNAL_SERVER_ERROR, &detail)
}
#[cfg(test)]
mod tests {
use swarm_queue_client::subagent_term::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
/// list the same way, and the order is the name's, not the stream's.
#[test]
fn the_list_is_the_streams_subjects_by_name() {
let reported = [
("$SWARM.term.atlas.sub.scout".to_owned(), 12),
("$SWARM.term.atlas.sub.builder".to_owned(), 1),
];
assert_eq!(
subagent_names("atlas", reported.iter().map(|(s, _)| s.as_str())),
["builder", "scout"]
);
}
}