Watch
0
0
Fork
You've already forked hyperhive
0

swarm: keep an agent's terminal rows and replay them on attach

swarm-controller now creates `term-main-<agent>` for every live agent, in
the same one-minute pass and with the same config style as
`term-sub-<agent>` (#4897): subjects `$SWARM.term.<agent>` only, max_age
24h, max_bytes 64 MiB, discard Old, max_message_size from the queue's
max_payload. An existing stream is opened as it is. The agent keeps its
plain publish on `$SWARM.term.<agent>` and gets no JetStream subject.

`GET /api/agents/{name}/term/stream` creates an ephemeral push consumer on
that stream starting 100 rows back, so a swarm-ui terminal opens with
recent history and then follows new rows, in order, from one
subscription. Each SSE event carries the row's stream sequence as its id,
and a reconnect's Last-Event-ID resumes after it. Without the stream the
route falls back to the live core subscription it used before.

The stream is `term-main-<agent>`, not `term-<agent>`: an agent may be
named `sub-x`, whose `term-<agent>` would be agent `x`'s subagent stream.
Its one literal subject cannot match `$SWARM.term.<agent>.sub.>`, which
needs more tokens, so the two streams never overlap.

The reader gains `$JS.API.CONSUMER.CREATE.*.*.$SWARM.term.*`, the
extended create form whose last tokens are the consumer's filter. Checked
against nats-server 2.14.4, the trailing `*` also admits a literal `*` or
`>` filter, so a consumer under it reaches any terminal row, main or
subagent; those are rows the reader already subscribes to and reads with
`STREAM.MSG.GET.*`.

Closes #4826
This commit is contained in:
atlas 2026-10-11 12:35:31 +02:00
commit ae9ca4331e
12 changed files with 533 additions and 38 deletions

View file

@ -189,9 +189,10 @@ own web UI renders also goes to `$SWARM.term.<agent>`, one subject per agent, so
a swarm-level terminal can follow one agent without subscribing to the swarm's a swarm-level terminal can follow one agent without subscribing to the swarm's
whole traffic. The queue grants that subject to the agent's own credential, whole traffic. The queue grants that subject to the agent's own credential,
and the swarm controller relays it. Publishing only: an and the swarm controller relays it. Publishing only: an
agent talks about itself here and reads nothing. Rows aren't retained — a agent talks about itself here and reads nothing. The queue keeps the rows in
subscriber that wasn't listening missed them, the same as on the agent's own the stream `term-main-<agent>` for 24 hours or 64 MiB, the same limits and the
live stream. same creator as the subagent stream below, so a swarm terminal that attaches
later replays the newest of them. The two streams hold disjoint subjects.
An agent's **subagents** publish their terminals too, from the agent's An agent's **subagents** publish their terminals too, from the agent's
subagent daemon: each subagent's rows go to `$SWARM.term.<agent>.sub.<subagent>`, subagent daemon: each subagent's rows go to `$SWARM.term.<agent>.sub.<subagent>`,

View file

@ -30,8 +30,9 @@ terminal in the same preview, and **expand** opens it in its own tab, as it
does for the agent. The list refreshes every 10 seconds, so a subagent the does for the agent. The list refreshes every 10 seconds, so a subagent the
agent spawns shows up once it writes output. agent spawns shows up once it writes output.
A preview shows rows published while it's open. A subagent terminal has no An agent's terminal opens with its last 100 rows from the past 24 hours, then
turn-state badges. follows new ones; a reconnect picks up after the last row it showed. A subagent
terminal shows rows published while it's open, and has no turn-state badges.
### Scheduled prompts ### Scheduled prompts
@ -102,6 +103,21 @@ store identity, or no queue address, publishes no subagent terminals.
</details> </details>
<details><summary>Where an agent's terminal history comes from</summary>
The harness publishes the agent's terminal rows on `$SWARM.term.<agent>`, into
a stream named `term-main-<agent>` that swarm-controller creates for every
declared agent, with the same 24 hours or 64 MiB as the subagent stream. That
stream holds this one subject, and the subagent stream holds only subjects
below `$SWARM.term.<agent>.sub`, so they never overlap.
`GET /api/agents/<name>/term/stream` creates a short-lived consumer on it
that starts 100 rows back, so the SSE stream sends those rows and then each
new one. Each event's `id` is the row's stream sequence, and a request with
`Last-Event-ID` resumes after it. Until the stream exists, the route sends
live rows only.
</details>
### Inference settings ### Inference settings
The agent's detail panel shows its model and effort under **inference**, each The agent's detail panel shows its model and effort under **inference**, each

View file

@ -3,9 +3,10 @@
// `term_stream.rs`). Deliberately NOT `@hive/agent`'s `useLiveStream` — // `term_stream.rs`). Deliberately NOT `@hive/agent`'s `useLiveStream` —
// that hook's wire shape is `TermEnvelope` (`{seq, msgs: []}`) plus a // that hook's wire shape is `TermEnvelope` (`{seq, msgs: []}`) plus a
// history/backfill dance; the swarm relay forwards one bare `TermMsg` // history/backfill dance; the swarm relay forwards one bare `TermMsg`
// per SSE event with no envelope and no history endpoint (a core NATS // per SSE event, its replayed rows first and in order on the same
// subject, not `JetStream` — see `term_stream.rs`'s own module doc), so // stream, so there's nothing to buffer/dedupe/backfill against. The
// there's nothing to buffer/dedupe/backfill against. Same row-coalescing // browser's own `Last-Event-ID` resumes a reconnect, so rows are kept
// across one. Same row-coalescing
// rule as `useLiveStream`'s `appendRow` (ported, not imported — one // rule as `useLiveStream`'s `appendRow` (ported, not imported — one
// small helper isn't worth a cross-package hop for). // small helper isn't worth a cross-package hop for).
import { useEffect, useRef, useState } from "preact/hooks"; import { useEffect, useRef, useState } from "preact/hooks";

View file

@ -13,11 +13,11 @@
//! restarted its correlation state per reader would lose the `tool_use` → //! restarted its correlation state per reader would lose the `tool_use` →
//! name mapping a `tool_result` needs. //! name mapping a `tool_result` needs.
//! //!
//! **A core subject, not `JetStream`.** A terminal row has no current value a //! **A plain core publish.** The queue keeps the rows in the stream
//! late reader can ask for, and nothing here is worth the durability of the //! `term-main-<agent>`, which swarm-controller creates
//! notices stream: a subscriber that was not listening missed the row, the //! (`swarm_queue_client::agent_term`), so this needs no `JetStream` call and
//! same way it would have missed it on the agent's own SSE stream. That also //! the agent's grant stays a plain publish. A row published before that
//! keeps the agent's grant to a plain publish. //! stream exists reaches live subscribers only.
//! //!
//! **Best-effort per row.** The turn loop and the web UI never wait on the //! **Best-effort per row.** The turn loop and the web UI never wait on the
//! queue, so the bus receiver is the only thing this task blocks on; a failed //! queue, so the bus receiver is the only thing this task blocks on; a failed

View file

@ -92,7 +92,13 @@ swarm-authelia-bridge-sock.workspace = true
# #
# `subagent-term`: this daemon creates every agent's subagent stream, with the # `subagent-term`: this daemon creates every agent's subagent stream, with the
# config the crate spells. # config the crate spells.
swarm-queue-client = { workspace = true, features = ["kv", "subagent-term"] } # `agent-term`: likewise every agent's terminal stream, plus the consumer it
# replays that stream through.
swarm-queue-client = { workspace = true, features = [
"kv",
"subagent-term",
"agent-term",
] }
# `matrix_account.rs` writes the credential this daemon's route accepts. Same # `matrix_account.rs` writes the credential this daemon's route accepts. Same
# crate the hive reads it back with, which is the point: the path, the field # crate the hive reads it back with, which is the point: the path, the field
# name and the object's shape are agreements between the two ends, and a # name and the object's shape are agreements between the two ends, and a

View file

@ -2621,8 +2621,8 @@ fn spawn_agent_renewal(
}); });
} }
/// Start creating every live agent's subagent stream when a swarm queue is /// Start creating every live agent's subagent and terminal streams when a swarm
/// wired up. Lifted out of `main` for `clippy::too_many_lines`. /// queue is wired up. Lifted out of `main` for `clippy::too_many_lines`.
fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) { fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else { let (Some(status), Some(wanted)) = (status, wanted_writer(status)) else {
return; return;
@ -2631,7 +2631,7 @@ fn spawn_subagent_streams(status: Option<&Arc<status::StatusReader>>, hives: &[H
subagent_term::spawn(status.queue_client(), wanted, hives); subagent_term::spawn(status.queue_client(), wanted, hives);
} }
/// The per-agent background passes: subagent streams and schedule keys. /// The per-agent background passes: terminal streams and schedule keys.
/// Lifted out of `main` for `clippy::too_many_lines`. /// Lifted out of `main` for `clippy::too_many_lines`.
fn spawn_per_agent_upkeep(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) { fn spawn_per_agent_upkeep(status: Option<&Arc<status::StatusReader>>, hives: &[HiveEntry]) {
spawn_subagent_streams(status, hives); spawn_subagent_streams(status, hives);

View file

@ -5,7 +5,8 @@
//! `$SWARM.term.{agent}.sub.{subagent}` under the agent's own queue credential, //! `$SWARM.term.{agent}.sub.{subagent}` under the agent's own queue credential,
//! which may publish there and nothing else of these streams. [`spawn`] //! which may publish there and nothing else of these streams. [`spawn`]
//! creates the stream `term-sub-{agent}` for every live agent, with the config //! creates the stream `term-sub-{agent}` for every live agent, with the config
//! `swarm_queue_client::subagent_term` spells. Subagents are spawned on demand //! `swarm_queue_client::subagent_term` spells, and in the same pass the
//! agent's own terminal stream (`swarm_queue_client::agent_term`). Subagents are spawned on demand
//! under caller-chosen names, so the list is the set of subjects that stream //! under caller-chosen names, so the list is the set of subjects that stream
//! holds rows for, read with `STREAM.INFO`. A missing stream lists nothing. //! holds rows for, read with `STREAM.INFO`. A missing stream lists nothing.
//! Each subagent carries the stream's own publish time of its newest row, so //! Each subagent carries the stream's own publish time of its newest row, so
@ -30,7 +31,7 @@ use axum::http::StatusCode;
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::{Stream, StreamExt as _, TryStreamExt as _}; use futures_util::{Stream, StreamExt as _, TryStreamExt as _};
use serde::Serialize; use serde::Serialize;
use swarm_queue_client::subagent_term; use swarm_queue_client::{agent_term, subagent_term};
use utoipa::ToSchema; use utoipa::ToSchema;
use crate::AppState; use crate::AppState;
@ -49,8 +50,9 @@ const LOOKUP_TIMEOUT: Duration = Duration::from_secs(2);
/// How many newest-row lookups [`list_subagents`] has in flight at once. /// How many newest-row lookups [`list_subagents`] has in flight at once.
const LOOKUP_CONCURRENCY: usize = 16; const LOOKUP_CONCURRENCY: usize = 16;
/// Create the subagent stream of every live agent that has none, now and every /// Create the subagent stream and the terminal stream (`agent_term`) of every
/// [`ENSURE_INTERVAL`] after. An existing stream is left as it is. /// live agent that lacks one, now and every [`ENSURE_INTERVAL`] after. An
/// existing stream is left as it is.
/// ///
/// The live agents are the ones some hive's wanted-state declaration names in /// The live agents are the ones some hive's wanted-state declaration names in
/// a state other than `Destroyed` (`agent_renewal::live_agents`). A pass skips /// a state other than `Destroyed` (`agent_renewal::live_agents`). A pass skips
@ -63,7 +65,7 @@ pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, hives
loop { loop {
ticker.tick().await; ticker.tick().await;
if swarm_queue_client::ensure_connected(&client).is_err() { if swarm_queue_client::ensure_connected(&client).is_err() {
tracing::debug!("subagent streams: queue not connected; next pass"); tracing::debug!("terminal streams: queue not connected; next pass");
continue; continue;
} }
for agent in live_agents(&wanted, &hives).await { for agent in live_agents(&wanted, &hives).await {
@ -74,6 +76,13 @@ pub(crate) fn spawn(client: async_nats::Client, wanted: Arc<WantedWriter>, hives
"subagent streams: creating the agent's stream failed; next pass" "subagent streams: creating the agent's stream failed; next pass"
); );
} }
if let Err(e) = agent_term::open_or_create(&client, &agent).await {
tracing::warn!(
%agent,
error = %swarm_queue_client::chain(&e),
"terminal streams: creating the agent's stream failed; next pass"
);
}
} }
} }
}); });

View file

@ -1,4 +1,4 @@
//! `GET /api/agents/{name}/term/stream` — a live SSE relay of one agent's //! `GET /api/agents/{name}/term/stream` — an SSE relay of one agent's
//! terminal rows, sourced straight from the swarm queue. //! terminal rows, sourced straight from the swarm queue.
//! //!
//! **No `hive` in the path, on purpose.** An agent is not pinned to a //! **No `hive` in the path, on purpose.** An agent is not pinned to a
@ -6,15 +6,20 @@
//! moment it did. `crate::agent_status`'s bucket is read only to answer //! moment it did. `crate::agent_status`'s bucket is read only to answer
//! 404 for an agent that has never reported. //! 404 for an agent that has never reported.
//! //!
//! **Live tail only, on purpose.** `hive-agent` publishes each //! **Replay, then the live tail, through one consumer.** `hive-agent`
//! already-classified `TermMsg` row to the core subject //! publishes each already-classified `TermMsg` row to the core subject
//! `$SWARM.term.{agent}` (see `hive-agent::swarm_term`'s module doc) — //! `$SWARM.term.{agent}`, and the stream `term-main-{agent}`
//! a core subject, not `JetStream`, so a subscriber who was not //! (`swarm_queue_client::agent_term`) keeps them. The handler creates an
//! listening missed the row, same as on the agent's own local SSE //! ephemeral consumer on that stream starting at the newest
//! stream. This handler relays exactly that: no replay, no last-N //! `agent_term::REPLAY_ROWS` rows, so the reader gets those and then every
//! history. The publish side's own retention half was deliberately //! new row, in stream order, with no gap and no duplicate between the two.
//! never built, and this route's own job — a live tail — never needed //! Each event's `id` is the row's stream sequence; a browser that
//! it either. //! reconnects sends it back as `Last-Event-ID` and resumes right after it.
//!
//! **No stream, no replay.** While the stream does not exist yet (up to a
//! minute after the agent is declared) or cannot be read, the handler
//! falls back to a core subscription: rows published while the reader is
//! attached, and nothing before.
//! //!
//! **The payload is passed through opaquely**, the same way //! **The payload is passed through opaquely**, the same way
//! `crate::status::HiveStatus::snapshot` stores a hive's status //! `crate::status::HiveStatus::snapshot` stores a hive's status
@ -26,8 +31,11 @@ use std::convert::Infallible;
use std::time::SystemTime; use std::time::SystemTime;
use axum::extract::{Path, State}; use axum::extract::{Path, State};
use axum::http::HeaderMap;
use axum::response::sse::{Event, KeepAlive, Sse}; use axum::response::sse::{Event, KeepAlive, Sse};
use futures_util::stream::BoxStream;
use futures_util::{Stream, StreamExt as _}; use futures_util::{Stream, StreamExt as _};
use swarm_queue_client::agent_term;
use crate::AppState; use crate::AppState;
@ -38,7 +46,9 @@ use crate::AppState;
responses( responses(
(status = 200, description = "server-sent event stream; each event's `data` is \ (status = 200, description = "server-sent event stream; each event's `data` is \
one already-classified TermMsg row, JSON as hive-agent published it \ one already-classified TermMsg row, JSON as hive-agent published it \
(opaque to this daemon) — live only, no replay", (opaque to this daemon); the newest retained rows first, then live rows. \
Each event's `id` is the row's stream sequence, and a `Last-Event-ID` \
request header resumes right after that row",
body = String, content_type = "text/event-stream"), body = String, content_type = "text/event-stream"),
(status = 400, description = "the agent name is not shaped like an identifier \ (status = 400, description = "the agent name is not shaped like an identifier \
(problem+json)", body = String), (problem+json)", body = String),
@ -55,6 +65,7 @@ use crate::AppState;
pub(crate) async fn stream_agent_term( pub(crate) async fn stream_agent_term(
State(state): State<AppState>, State(state): State<AppState>,
Path(agent): Path<String>, Path(agent): Path<String>,
headers: HeaderMap,
) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, problem_details::ProblemDetails> { ) -> Result<Sse<impl Stream<Item = Result<Event, Infallible>>>, problem_details::ProblemDetails> {
let Some(status) = state.status.as_ref() else { let Some(status) = state.status.as_ref() else {
return Err(crate::error_problem( return Err(crate::error_problem(
@ -105,12 +116,91 @@ pub(crate) async fn stream_agent_term(
) )
})?; })?;
let subscriber = subscribe(&client, swarm_queue_client::term_subject(&agent), "term").await?; let stream = match replay(&client, &agent, last_event_id(&headers)).await {
let stream = Ok(stream) => stream,
subscriber.map(|msg| Ok(Event::default().data(String::from_utf8_lossy(&msg.payload)))); Err(error) => {
tracing::warn!(%agent, %error, "term stream: no replay, live rows only");
let subscriber =
subscribe(&client, swarm_queue_client::term_subject(&agent), "term").await?;
subscriber
.map(|msg| Ok(Event::default().data(String::from_utf8_lossy(&msg.payload))))
.boxed()
}
};
Ok(Sse::new(stream).keep_alive(KeepAlive::default())) Ok(Sse::new(stream).keep_alive(KeepAlive::default()))
} }
/// The agent's rows from its terminal stream: the replay
/// [`agent_term::replay_start`] picks, then every new row.
async fn replay(
client: &async_nats::Client,
agent: &str,
resume_after: Option<u64>,
) -> Result<BoxStream<'static, Result<Event, Infallible>>, String> {
let js = async_nats::jetstream::new(client.clone());
let name = agent_term::stream_name(agent);
let stream = js
.get_stream(&name)
.await
.map_err(|e| format!("opening {name}: {}", swarm_queue_client::chain(&e)))?;
let start = agent_term::replay_start(stream.cached_info().state.last_sequence, resume_after);
let deliver_subject = client.new_inbox();
// The inbox's last token is a fresh nuid, which is also a valid name.
let consumer_name = format!(
"term-replay-{}",
deliver_subject.rsplit('.').next().unwrap_or_default()
);
let config = agent_term::replay_consumer_config(agent, consumer_name, deliver_subject, start);
let messages = stream
.create_consumer(config)
.await
.map_err(|e| {
format!(
"creating a consumer on {name}: {}",
swarm_queue_client::chain(&e)
)
})?
.messages()
.await
.map_err(|e| format!("subscribing to {name}: {}", swarm_queue_client::chain(&e)))?;
tracing::info!(stream = %name, start, "term stream: client attached");
let agent = agent.to_owned();
Ok(messages
.filter_map(move |msg| {
let event = match msg {
Ok(msg) => Some(Ok(row_event(&msg))),
Err(e) => {
tracing::warn!(%agent, error = %e, "term stream: row skipped");
None
}
};
std::future::ready(event)
})
.boxed())
}
/// One row as an SSE event, carrying its stream sequence as the event `id`
/// when the delivery names one.
fn row_event(msg: &async_nats::jetstream::Message) -> Event {
let event = Event::default().data(String::from_utf8_lossy(&msg.payload));
match msg.info() {
Ok(info) => event.id(info.stream_sequence.to_string()),
Err(_) => event,
}
}
/// The stream sequence a reconnecting browser last received, from its
/// `Last-Event-ID` header. Absent or unparseable means a fresh reader.
fn last_event_id(headers: &HeaderMap) -> Option<u64> {
headers
.get("last-event-id")?
.to_str()
.ok()?
.trim()
.parse()
.ok()
}
/// Subscribe to `subject`. `what` names the relay in logs and errors. /// Subscribe to `subject`. `what` names the relay in logs and errors.
pub(crate) async fn subscribe( pub(crate) async fn subscribe(
client: &async_nats::Client, client: &async_nats::Client,
@ -127,3 +217,33 @@ pub(crate) async fn subscribe(
tracing::info!(%subject, "{what} stream: client attached"); tracing::info!(%subject, "{what} stream: client attached");
Ok(subscriber) Ok(subscriber)
} }
#[cfg(test)]
mod tests {
use super::*;
fn with_last_event_id(value: &str) -> HeaderMap {
let mut headers = HeaderMap::new();
headers.insert(
"last-event-id",
value.parse().expect("a valid header value"),
);
headers
}
#[test]
fn a_reconnect_resumes_after_the_sequence_it_sends() {
assert_eq!(last_event_id(&with_last_event_id("917")), Some(917));
assert_eq!(
agent_term::replay_start(1000, last_event_id(&with_last_event_id("917"))),
918
);
}
#[test]
fn no_or_a_foreign_last_event_id_is_a_fresh_reader() {
assert_eq!(last_event_id(&HeaderMap::new()), None);
assert_eq!(last_event_id(&with_last_event_id("r12")), None);
assert_eq!(last_event_id(&with_last_event_id("")), None);
}
}

View file

@ -538,6 +538,16 @@ impl Policy {
// `MSG.GET`, the same width as `STREAM.INFO.*` above. A refused // `MSG.GET`, the same width as `STREAM.INFO.*` above. A refused
// publish reaches the client as a timeout. // publish reaches the client as a timeout.
"$JS.API.STREAM.MSG.GET.*".to_owned(), "$JS.API.STREAM.MSG.GET.*".to_owned(),
// Replaying an agent's terminal from `term-main-<agent>` to
// swarm-ui. Consumer create on every stream, but only in the
// extended form whose last tokens are the consumer's filter,
// which the server checks against the payload. So a consumer
// delivers only rows below `$SWARM.term`, every agent's and every
// subagent's included (the `*` also matches a literal `*` or `>`),
// which this client already subscribes to and reads by
// `MSG.GET` above. No bare `CONSUMER.CREATE.<stream>`, whose
// filter is unchecked.
swarm_queue_client::agent_term::consumer_create_grant(),
"$KV.*.>".to_owned(), "$KV.*.>".to_owned(),
]); ]);
// The per-agent status bucket, read-side only. Same five subjects as // The per-agent status bucket, read-side only. Same five subjects as
@ -1215,6 +1225,47 @@ mod tests {
assert!(p.publish.contains(&"$JS.API.STREAM.CREATE.*".to_owned())); assert!(p.publish.contains(&"$JS.API.STREAM.CREATE.*".to_owned()));
} }
#[test]
fn the_controller_may_replay_terminal_rows_and_no_other_subject() {
use swarm_queue_client::agent_term::stream_name;
use swarm_queue_client::{subagent_term, term_subject};
let p = policy()
.permissions("swarm-controller")
.expect("a reader is admitted");
let may_publish = |s: &str| p.publish.iter().any(|g| covers(g, s));
let create = |stream: &str, filter: &str| {
format!("$JS.API.CONSUMER.CREATE.{stream}.term-replay-n1.{filter}")
};
assert!(may_publish(&create(
&stream_name("atlas"),
&term_subject("atlas")
)));
for refused in [
// The bare form, whose filter rides in the payload alone.
format!("$JS.API.CONSUMER.CREATE.{}", stream_name("atlas")),
create(
&subagent_term::stream_name("atlas"),
&subagent_term::subject("atlas", "scout"),
),
create("hive-notices", "hive-notices.alpha"),
create(&stream_name("atlas"), "$SWARM.agent-state.atlas"),
create(
&stream_name("atlas"),
&format!("{}.x", term_subject("atlas")),
),
] {
assert!(!may_publish(&refused), "the reader may publish {refused}");
}
// Control: no hive holds the grant.
assert!(
!policy()
.hive_subjects("alpha")
.iter()
.any(|s| covers(s, &create(&stream_name("atlas"), &term_subject("atlas"))))
);
}
#[test] #[test]
fn the_controller_may_read_a_subagents_newest_row() { fn the_controller_may_read_a_subagents_newest_row() {
let p = policy() let p = policy()

View file

@ -33,6 +33,10 @@ notices = ["async-nats/jetstream"]
# `subagent_term::open_or_create`, for swarm-controller, which creates every # `subagent_term::open_or_create`, for swarm-controller, which creates every
# agent's subagent stream. Explicit for the same reason as `notices`. # agent's subagent stream. Explicit for the same reason as `notices`.
subagent-term = ["async-nats/jetstream"] subagent-term = ["async-nats/jetstream"]
# `agent_term::open_or_create` and the replay consumer, for swarm-controller,
# which creates every agent's terminal stream and replays it to swarm-ui.
# Explicit for the same reason as `notices`.
agent-term = ["async-nats/jetstream"]
# `agent_schedules::read_own`, for the agent harness, which reads its own # `agent_schedules::read_own`, for the agent harness, which reads its own
# schedule key without opening the bucket. Explicit for the same reason as # schedule key without opening the bucket. Explicit for the same reason as
# `notices`. # `notices`.

View file

@ -0,0 +1,281 @@
//! An agent's own terminal: the stream that keeps its rows, and the consumer
//! a reader replays them through.
//!
//! The agent publishes its classified terminal rows on
//! [`crate::term_subject`] with a plain core publish, under its own queue
//! credential, which may publish there and nothing else of these streams.
//! `swarm-controller` creates one stream per agent with the fixed config
//! [`crate::agent_term::open_or_create`] spells, so the queue keeps the rows a
//! reader who attaches later replays.
//!
//! The stream captures exactly one literal subject, `$SWARM.term.<agent>`.
//! [`crate::subagent_term`]'s stream captures `$SWARM.term.<agent>.sub.>`,
//! which only matches subjects with at least two more tokens, so no subject
//! is in both and the two streams never overlap. The `JetStream` server refuses
//! to create a stream whose subjects overlap another's.
//!
//! The names live here because three crates must agree on them: the
//! publisher in `hive-agent`, `swarm-controller`, and the reader's consumer
//! grant in `swarm-nats-auth`.
/// The token between the prefix and the agent in [`stream_name`]. It differs
/// from `term-sub-` at the same position, so no agent name makes this
/// stream's name equal another agent's subagent stream.
const STREAM_KIND: &str = "main";
/// How long the stream keeps a row. The same window as the subagent stream.
pub const MAX_AGE: std::time::Duration = std::time::Duration::from_hours(24);
/// How large one agent's stream may grow before the discard policy reclaims
/// space. The same cap as the subagent stream, for the same reason: it
/// bounds the disk one agent can claim.
#[cfg(feature = "agent-term")]
const MAX_BYTES: i64 = 64 * 1024 * 1024;
/// How many of the newest rows a reader that attaches fresh is replayed
/// before the live tail. The agent's own web UI loads the same number of
/// events as its first history page.
pub const REPLAY_ROWS: u64 = 100;
/// The stream holding `agent`'s terminal rows: `term-main-<agent>`.
#[must_use]
pub fn stream_name(agent: &str) -> String {
format!("term-{STREAM_KIND}-{agent}")
}
/// The one subject [`stream_name`]'s stream captures.
#[must_use]
pub fn stream_subject(agent: &str) -> String {
crate::term_subject(agent)
}
/// The reader's grant to create a replay consumer: on any stream, under any
/// consumer name, in the extended create form whose last tokens are the
/// consumer's filter.
///
/// The server refuses a create whose payload filter differs from those
/// tokens, so a consumer made under this grant delivers only rows on
/// subjects below `$SWARM.term`. The grant's last `*` also matches a literal
/// `*` or `>` token, so besides one agent's [`crate::term_subject`] it admits
/// `$SWARM.term.*` and `$SWARM.term.>`, which take in every agent's and every
/// subagent's terminal. The bare `CONSUMER.CREATE.<stream>` form, whose
/// filter rides only in the payload, is not covered.
#[must_use]
pub fn consumer_create_grant() -> String {
format!(
"$JS.API.CONSUMER.CREATE.*.*.{}",
crate::term_subject_wildcard()
)
}
/// The first stream sequence to replay from.
///
/// `resume_after` is the last sequence the reader already holds, from a
/// reconnecting browser's `Last-Event-ID`; the replay continues right after
/// it. A fresh reader gets the newest [`REPLAY_ROWS`] below `last_sequence`,
/// the stream's newest row when it was read. A sequence the stream has since
/// discarded starts the consumer at the oldest row it still has.
#[must_use]
pub fn replay_start(last_sequence: u64, resume_after: Option<u64>) -> u64 {
match resume_after {
Some(seen) => seen.saturating_add(1),
None => last_sequence.saturating_sub(REPLAY_ROWS - 1).max(1),
}
}
/// The config [`open_or_create`] creates `agent`'s stream with.
///
/// `max_message_size` tracks the queue's live `max_payload`, as the subagent
/// stream's does, so the stream never rejects a row the queue accepted.
#[cfg(feature = "agent-term")]
fn stream_config(agent: &str, max_message_size: i32) -> async_nats::jetstream::stream::Config {
async_nats::jetstream::stream::Config {
name: stream_name(agent),
description: Some(format!("Terminal rows of {agent}")),
subjects: vec![stream_subject(agent)],
max_age: MAX_AGE,
max_bytes: MAX_BYTES,
discard: async_nats::jetstream::stream::DiscardPolicy::Old,
max_message_size,
..Default::default()
}
}
/// Open `agent`'s terminal stream, creating it if it does not exist yet. An
/// existing stream is opened as it is.
#[cfg(feature = "agent-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, "agent terminal stream not available, creating it");
let max_message_size = i32::try_from(crate::max_payload(client)).unwrap_or(i32::MAX);
js.create_stream(stream_config(agent, max_message_size))
.await
.map_err(|source| crate::Error::CreateAgentStream {
stream: name,
source,
})
}
}
}
/// The consumer that replays `agent`'s rows from `start_sequence` and then
/// follows new ones, delivered to `deliver_subject` in stream order.
///
/// Ephemeral and unacknowledged: the server drops it once nothing listens
/// on `deliver_subject`, so a reader that goes away needs no delete grant.
/// `name` must be set, because the server only takes the filter from the
/// subject [`consumer_create_grant`] covers when the create names a consumer.
#[cfg(feature = "agent-term")]
#[must_use]
pub fn replay_consumer_config(
agent: &str,
name: String,
deliver_subject: String,
start_sequence: u64,
) -> async_nats::jetstream::consumer::push::Config {
async_nats::jetstream::consumer::push::Config {
name: Some(name),
deliver_subject,
filter_subject: stream_subject(agent),
deliver_policy: async_nats::jetstream::consumer::DeliverPolicy::ByStartSequence {
start_sequence,
},
ack_policy: async_nats::jetstream::consumer::AckPolicy::None,
..Default::default()
}
}
#[cfg(test)]
mod tests {
use super::*;
/// Token-wise NATS subject overlap: whether some literal subject matches
/// both patterns.
fn overlaps(a: &str, b: &str) -> bool {
let (mut a, mut b) = (a.split('.'), b.split('.'));
loop {
match (a.next(), b.next()) {
(Some(">"), Some(_)) | (Some(_), Some(">")) | (None, None) => return true,
(None, _) | (_, None) => return false,
(Some(x), Some(y)) if x != "*" && y != "*" && x != y => return false,
_ => {}
}
}
}
#[test]
fn names_match_the_publisher_and_the_grant() {
assert_eq!(stream_name("iris"), "term-main-iris");
assert_eq!(stream_subject("iris"), "$SWARM.term.iris");
assert_eq!(
consumer_create_grant(),
"$JS.API.CONSUMER.CREATE.*.*.$SWARM.term.*"
);
}
/// The server refuses to create a stream whose subjects overlap
/// another's, so this stream must share no subject with any agent's
/// subagent stream, its own agent's included.
#[test]
fn the_subject_is_disjoint_from_every_subagent_stream() {
for other in ["iris", "argus", "sub", "iris-2"] {
assert!(
!overlaps(
&stream_subject("iris"),
&crate::subagent_term::stream_subjects(other)
),
"term-main-iris overlaps term-sub-{other}"
);
}
// Control: the checker does report a real overlap.
assert!(overlaps(
"$SWARM.term.iris.>",
&crate::subagent_term::stream_subjects("iris")
));
assert!(overlaps(&stream_subject("iris"), "$SWARM.term.*"));
}
/// No agent name makes this stream's name another agent's subagent
/// stream: an agent may be called `sub-x`.
#[test]
fn the_name_never_collides_with_a_subagent_stream() {
for (agent, other) in [("sub-x", "x"), ("x", "sub-x"), ("main-x", "x")] {
assert_ne!(stream_name(agent), crate::subagent_term::stream_name(other));
}
}
/// The create the controller sends is one the grant covers, and every
/// filter the grant admits stays below `$SWARM.term`.
#[test]
fn the_grant_admits_only_filters_below_the_terminal_prefix() {
let grant = consumer_create_grant();
let create = |filter: &str| format!("$JS.API.CONSUMER.CREATE.term-main-iris.r1.{filter}");
assert!(overlaps(&grant, &create(&stream_subject("iris"))));
// The width the doc states: a literal wildcard as the last token.
assert!(overlaps(&grant, &create("$SWARM.term.>")));
for other in [
crate::subagent_term::subject("iris", "scout"),
crate::subagent_term::stream_subjects("iris"),
crate::agent_state_subject("iris"),
] {
assert!(!overlaps(&grant, &create(&other)), "{other}");
}
assert!(!overlaps(&grant, "$JS.API.CONSUMER.CREATE.term-main-iris"));
}
#[test]
fn a_fresh_reader_replays_the_newest_rows() {
assert_eq!(replay_start(1000, None), 1000 - REPLAY_ROWS + 1);
assert_eq!(replay_start(5, None), 1);
assert_eq!(replay_start(0, None), 1);
}
#[test]
fn a_reconnecting_reader_resumes_after_its_last_row() {
assert_eq!(replay_start(1000, Some(990)), 991);
assert_eq!(replay_start(1000, Some(1000)), 1001);
}
/// The consumer starts at a stored sequence, not at the next new row, so
/// the replay reaches the reader before the live tail, in stream order,
/// through the one subscription.
#[cfg(feature = "agent-term")]
#[test]
fn the_consumer_replays_before_it_follows() {
let cfg = replay_consumer_config("iris", "r1".to_owned(), "_INBOX.x".to_owned(), 901);
assert_eq!(
cfg.deliver_policy,
async_nats::jetstream::consumer::DeliverPolicy::ByStartSequence {
start_sequence: 901
}
);
assert_eq!(cfg.filter_subject, "$SWARM.term.iris");
assert_eq!(cfg.name.as_deref(), Some("r1"));
assert_eq!(
cfg.ack_policy,
async_nats::jetstream::consumer::AckPolicy::None
);
assert!(cfg.durable_name.is_none());
}
#[cfg(feature = "agent-term")]
#[test]
fn stream_config_matches_the_subagent_streams_limits() {
let cfg = stream_config("iris", 8_388_608);
assert_eq!(cfg.subjects, ["$SWARM.term.iris"]);
assert_eq!(cfg.max_age, crate::subagent_term::MAX_AGE);
assert_eq!(cfg.max_bytes, MAX_BYTES);
assert_eq!(
cfg.discard,
async_nats::jetstream::stream::DiscardPolicy::Old
);
assert_eq!(cfg.max_message_size, 8_388_608);
}
}

View file

@ -135,10 +135,11 @@ pub enum Error {
source: async_nats::jetstream::context::CreateStreamError, source: async_nats::jetstream::context::CreateStreamError,
}, },
#[cfg(feature = "subagent-term")] #[cfg(any(feature = "subagent-term", feature = "agent-term"))]
#[error("creating the {stream} stream")] #[error("creating the {stream} stream")]
CreateAgentStream { CreateAgentStream {
// Owned: the name carries the agent's (`subagent_term::stream_name`). // Owned: the name carries the agent's (`subagent_term::stream_name`,
// `agent_term::stream_name`).
stream: String, stream: String,
#[source] #[source]
source: async_nats::jetstream::context::CreateStreamError, source: async_nats::jetstream::context::CreateStreamError,
@ -381,6 +382,11 @@ pub struct AgentInput {
#[cfg(feature = "notices")] #[cfg(feature = "notices")]
pub mod notices; pub mod notices;
/// The stream that keeps an agent's own terminal rows, and the consumer a
/// reader replays them through. Only the `JetStream` calls need the
/// `agent-term` feature; the names are unconditional.
pub mod agent_term;
/// The subject an agent's subagents' terminal rows go to, and the per-agent /// 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` /// stream that lists them. Only opening the stream needs the `subagent-term`
/// feature; the names are unconditional, like [`agent_icon`]'s. /// feature; the names are unconditional, like [`agent_icon`]'s.