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
|
|
@ -30,6 +30,9 @@ kv = ["async-nats/kv"]
|
|||
# `cargo check -p swarm-nats-auth` — no `kv` anywhere in that build —
|
||||
# surfaced it as `cannot find jetstream in async_nats`).
|
||||
notices = ["async-nats/jetstream"]
|
||||
# `subagent_term::open_or_create`, for the one publisher that creates its own
|
||||
# stream. Explicit for the same reason as `notices`.
|
||||
subagent-term = ["async-nats/jetstream"]
|
||||
|
||||
[dependencies]
|
||||
# Bare (no `kv`/`jetstream`) unless a consumer opts into the `kv` feature
|
||||
|
|
|
|||
|
|
@ -125,6 +125,15 @@ pub enum Error {
|
|||
#[source]
|
||||
source: async_nats::jetstream::context::CreateStreamError,
|
||||
},
|
||||
|
||||
#[cfg(feature = "subagent-term")]
|
||||
#[error("creating the {stream} stream")]
|
||||
CreateAgentStream {
|
||||
// Owned: the name carries the agent's (`subagent_term::stream_name`).
|
||||
stream: String,
|
||||
#[source]
|
||||
source: async_nats::jetstream::context::CreateStreamError,
|
||||
},
|
||||
}
|
||||
|
||||
/// Render an error and its source chain on one line.
|
||||
|
|
@ -248,6 +257,11 @@ pub struct DeployRequest {
|
|||
#[cfg(feature = "notices")]
|
||||
pub mod notices;
|
||||
|
||||
/// The subject an agent's subagents' terminal rows go to, and the per-agent
|
||||
/// stream that lists them. Only opening the stream needs the `subagent-term`
|
||||
/// feature; the names are unconditional, like [`agent_icon`]'s.
|
||||
pub mod subagent_term;
|
||||
|
||||
/// Only the fields this needs; authelia returns several.
|
||||
#[derive(serde::Deserialize)]
|
||||
struct TokenResponse {
|
||||
|
|
|
|||
161
swarm-queue-client/src/subagent_term.rs
Normal file
161
swarm-queue-client/src/subagent_term.rs
Normal file
|
|
@ -0,0 +1,161 @@
|
|||
//! Subagent terminals: the subject each subagent's rows go to, and the
|
||||
//! stream that keeps them.
|
||||
//!
|
||||
//! An agent's subagent daemon publishes each subagent's classified terminal
|
||||
//! rows on `$SWARM.term.<agent>.sub.<subagent>`, under the agent's own queue
|
||||
//! credential. The queue grants that credential this subject family and
|
||||
//! `CREATE`/`INFO` on the one stream [`crate::subagent_term::stream_name`]
|
||||
//! names, nothing else of `JetStream`, so the agent creates its own stream on
|
||||
//! first use.
|
||||
//!
|
||||
//! The stream exists so the swarm can list an agent's subagents: subagents
|
||||
//! are spawned on demand under caller-chosen names, and the set of subjects
|
||||
//! the stream holds is the list. A reader takes it from the stream's subject
|
||||
//! counts ([`crate::subagent_term::subagent_names`]) and never creates the
|
||||
//! stream.
|
||||
//!
|
||||
//! The name and the subject live here because three crates must agree on
|
||||
//! them: the publisher in `hive-subagent-mcp`, the reader in
|
||||
//! `swarm-controller`, and the queue grant in `swarm-nats.nix`, whose
|
||||
//! `{agent}` templates spell the same strings.
|
||||
|
||||
/// The subject family every agent terminal shares.
|
||||
const TERM_PREFIX: &str = "$SWARM.term";
|
||||
|
||||
/// The token between the agent and the subagent name. A subagent subject has
|
||||
/// two more tokens than the agent's own `$SWARM.term.<agent>`, so neither
|
||||
/// family's subscriber receives the other's rows.
|
||||
const SUBAGENT_TOKEN: &str = "sub";
|
||||
|
||||
/// How long the stream keeps a row. The subagent list is the subjects with a
|
||||
/// row inside this window, so a subagent drops off the list this long after
|
||||
/// its last output.
|
||||
pub const MAX_AGE: std::time::Duration = std::time::Duration::from_hours(24);
|
||||
|
||||
/// The stream holding `agent`'s subagent rows: `term-sub-<agent>`.
|
||||
#[must_use]
|
||||
pub fn stream_name(agent: &str) -> String {
|
||||
format!("term-sub-{agent}")
|
||||
}
|
||||
|
||||
/// The subject `subagent`'s rows are published on.
|
||||
#[must_use]
|
||||
pub fn subject(agent: &str, subagent: &str) -> String {
|
||||
format!("{TERM_PREFIX}.{agent}.{SUBAGENT_TOKEN}.{subagent}")
|
||||
}
|
||||
|
||||
/// Every subject [`stream_name`]'s stream captures, which is also the publish
|
||||
/// grant's shape.
|
||||
#[must_use]
|
||||
pub fn stream_subjects(agent: &str) -> String {
|
||||
format!("{TERM_PREFIX}.{agent}.{SUBAGENT_TOKEN}.>")
|
||||
}
|
||||
|
||||
/// The subagent a stream subject names, or `None` for a subject that is not
|
||||
/// exactly one subagent token under `agent`'s family.
|
||||
#[must_use]
|
||||
pub fn subagent_from_subject<'a>(agent: &str, subject: &'a str) -> Option<&'a str> {
|
||||
let name = subject
|
||||
.strip_prefix(TERM_PREFIX)?
|
||||
.strip_prefix('.')?
|
||||
.strip_prefix(agent)?
|
||||
.strip_prefix('.')?
|
||||
.strip_prefix(SUBAGENT_TOKEN)?
|
||||
.strip_prefix('.')?;
|
||||
(!name.is_empty() && !name.contains('.')).then_some(name)
|
||||
}
|
||||
|
||||
/// The subagents named by a stream's subjects, sorted and deduplicated.
|
||||
/// Subjects outside `agent`'s family are skipped.
|
||||
#[must_use]
|
||||
pub fn subagent_names<'a>(agent: &str, subjects: impl IntoIterator<Item = &'a str>) -> Vec<String> {
|
||||
let mut names: Vec<String> = subjects
|
||||
.into_iter()
|
||||
.filter_map(|s| subagent_from_subject(agent, s))
|
||||
.map(str::to_owned)
|
||||
.collect();
|
||||
names.sort_unstable();
|
||||
names.dedup();
|
||||
names
|
||||
}
|
||||
|
||||
/// Open `agent`'s subagent stream, creating it if it does not exist yet.
|
||||
///
|
||||
/// Called by the publisher only. The reader opens the stream with `get_stream`
|
||||
/// and reads a missing one as "no subagents".
|
||||
#[cfg(feature = "subagent-term")]
|
||||
pub async fn open_or_create(
|
||||
client: &async_nats::Client,
|
||||
agent: &str,
|
||||
) -> Result<async_nats::jetstream::stream::Stream, crate::Error> {
|
||||
let js = async_nats::jetstream::new(client.clone());
|
||||
let name = stream_name(agent);
|
||||
match js.get_stream(&name).await {
|
||||
Ok(stream) => Ok(stream),
|
||||
Err(e) => {
|
||||
tracing::info!(stream = %name, reason = %e, "subagent terminal stream not available, creating it");
|
||||
js.create_stream(async_nats::jetstream::stream::Config {
|
||||
name: name.clone(),
|
||||
description: Some(format!("Terminal rows of {agent}'s subagents")),
|
||||
subjects: vec![stream_subjects(agent)],
|
||||
max_age: MAX_AGE,
|
||||
..Default::default()
|
||||
})
|
||||
.await
|
||||
.map_err(|source| crate::Error::CreateAgentStream {
|
||||
stream: name,
|
||||
source,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The names the grant in `swarm-nats.nix` spells with `{agent}`.
|
||||
#[test]
|
||||
fn names_match_the_queue_grant() {
|
||||
assert_eq!(stream_name("iris"), "term-sub-iris");
|
||||
assert_eq!(subject("iris", "scout"), "$SWARM.term.iris.sub.scout");
|
||||
assert_eq!(stream_subjects("iris"), "$SWARM.term.iris.sub.>");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_subject_reads_back_as_its_subagent() {
|
||||
assert_eq!(
|
||||
subagent_from_subject("iris", &subject("iris", "scout")),
|
||||
Some("scout")
|
||||
);
|
||||
}
|
||||
|
||||
/// The agent's own terminal, another agent's subagent, and anything
|
||||
/// deeper than one token are not subagents of `iris`.
|
||||
#[test]
|
||||
fn other_subjects_name_no_subagent() {
|
||||
for s in [
|
||||
"$SWARM.term.iris",
|
||||
"$SWARM.term.h1.iris",
|
||||
"$SWARM.term.iris.sub",
|
||||
"$SWARM.term.iris.sub.",
|
||||
"$SWARM.term.iris.sub.a.b",
|
||||
"$SWARM.term.iris-2.sub.scout",
|
||||
"$SWARM.term.argus.sub.scout",
|
||||
"$SWARM.agent-state.iris.sub.scout",
|
||||
] {
|
||||
assert_eq!(subagent_from_subject("iris", s), None, "{s}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_list_is_sorted_unique_and_scoped_to_the_agent() {
|
||||
let subjects = [
|
||||
"$SWARM.term.iris.sub.zed",
|
||||
"$SWARM.term.iris.sub.alpha",
|
||||
"$SWARM.term.argus.sub.other",
|
||||
"$SWARM.term.iris.sub.alpha",
|
||||
];
|
||||
assert_eq!(subagent_names("iris", subjects), ["alpha", "zed"]);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue