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
|
|
@ -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)]
|
||||
|
|
|
|||
172
swarm-controller/src/subagent_term.rs
Normal file
172
swarm-controller/src/subagent_term.rs
Normal 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"]
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue