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
3
Cargo.lock
generated
3
Cargo.lock
generated
|
|
@ -2036,6 +2036,7 @@ name = "hive-subagent-mcp"
|
|||
version = "0.1.0"
|
||||
dependencies = [
|
||||
"anyhow",
|
||||
"async-nats",
|
||||
"axum",
|
||||
"clap",
|
||||
"hive-agent-sock",
|
||||
|
|
@ -2049,6 +2050,8 @@ dependencies = [
|
|||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"swarm-queue-client",
|
||||
"swarm-secret-client",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
|
|||
|
|
@ -169,6 +169,16 @@ agent talks about itself here and reads nothing. Rows aren't retained — a
|
|||
subscriber that wasn't listening missed them, the same as on the agent's own
|
||||
live stream.
|
||||
|
||||
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>`,
|
||||
classified the same way. The queue grants that family to the agent's own queue
|
||||
credential alone, so the daemon reads it from the store under the agent's store
|
||||
identity, exactly as the harness does, and publishes nothing without it. The
|
||||
queue keeps these rows: the daemon creates the stream `term-sub-<agent>` on
|
||||
first use, holding rows for 24 hours, and the swarm lists an agent's subagents
|
||||
from that stream's subjects. The grant covers `CREATE` and `INFO` on that one stream name and no
|
||||
other `JetStream` subject. Subagents publish output only and read nothing.
|
||||
|
||||
The queue would refuse a row too large for its `max_payload` outright and
|
||||
take the connection down with it, so the harness drops such a row's body before
|
||||
sending and leaves a marker in its place; the summary, level and icon still
|
||||
|
|
|
|||
|
|
@ -7,18 +7,45 @@ domain, covers host-level detail for one hive.
|
|||
|
||||
## What it shows
|
||||
|
||||
| route | what |
|
||||
| ------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `/` | the hive directory, each hive with its last reported status |
|
||||
| `/agents` | every agent: status, config PR, wanted state; create agents, link forge, matrix and GitHub accounts |
|
||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||
| `/issues` | a cross-repo issue report |
|
||||
| route | what |
|
||||
| ---------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `/` | the hive directory, each hive with its last reported status |
|
||||
| `/agents` | every agent: status, config PR, wanted state; create agents, link forge, matrix and GitHub accounts |
|
||||
| `/agents/<name>/terminal` | one agent's live terminal |
|
||||
| `/agents/<name>/subagents/<subagent>/terminal` | one of that agent's subagents' live terminal |
|
||||
| `/jobs` | the controller's job graph — where agent creation and credential mints show progress |
|
||||
| `/issues` | a cross-repo issue report |
|
||||
|
||||
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
|
||||
An agent created here or with `swarmctl agent create` starts `paused`; set it
|
||||
`up` from its card.
|
||||
|
||||
### Agent and subagent terminals
|
||||
|
||||
An agent's detail panel on `/agents` shows a small live preview of its
|
||||
terminal, and below it the agent's **subagents**: every subagent the swarm
|
||||
queue holds terminal rows for from the last 24 hours. Picking one shows its
|
||||
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
|
||||
agent spawns shows up once it writes output.
|
||||
|
||||
Both terminals are live views with no input box: a preview shows rows
|
||||
published while it's open. Subagents take no input from the swarm. A subagent
|
||||
terminal has no turn-state badges.
|
||||
|
||||
<details><summary>Where the subagent list and rows come from</summary>
|
||||
|
||||
The agent's subagent daemon publishes each subagent's terminal rows on
|
||||
`$SWARM.term.<agent>.sub.<subagent>` with the agent's own queue credential,
|
||||
into a stream named `term-sub-<agent>` that it creates on first use. The
|
||||
stream keeps rows for 24 hours. swarm-controller serves the list as
|
||||
`GET /api/agents/<name>/subagents`, the subagents named by the subjects in that
|
||||
stream, and relays one subagent's rows as SSE on
|
||||
`GET /api/agents/<name>/subagents/<subagent>/term/stream`. An agent with no
|
||||
store identity, or no queue address, publishes no subagent terminals.
|
||||
|
||||
</details>
|
||||
|
||||
### Linking external accounts
|
||||
|
||||
Each agent on `/agents` opens three dialogs that write a credential for it
|
||||
|
|
|
|||
|
|
@ -25,6 +25,10 @@ export function App() {
|
|||
<Route path="/" component={HivesPage} />
|
||||
<Route path="/agents" component={AgentsPage} />
|
||||
<Route path="/agents/:name/terminal" component={AgentTerminalPage} />
|
||||
<Route
|
||||
path="/agents/:name/subagents/:subagent/terminal"
|
||||
component={AgentTerminalPage}
|
||||
/>
|
||||
<Route path="/jobs" component={JobsPage} />
|
||||
<Route path="/issues" component={IssueReportPage} />
|
||||
<Route path="/components" component={ComponentsPage} />
|
||||
|
|
|
|||
|
|
@ -2,9 +2,10 @@
|
|||
// page, a small read-only preview embedded below `AgentsPage`'s detail
|
||||
// panel fields. No input — sending input back to the agent is explicitly
|
||||
// follow-up scope, not this issue's. Consumes swarm-controller's
|
||||
// `GET /api/agents/{name}/term/stream` via `useSwarmTermStream`,
|
||||
// rendering through the same `@hive/shared` `Row` component the per-hive
|
||||
// local terminal uses — "share components between hive and swarm level
|
||||
// `GET /api/agents/{name}/term/stream`, or for a subagent
|
||||
// `GET /api/agents/{name}/subagents/{subagent}/term/stream`, via
|
||||
// `useSwarmTermStream`, rendering through the same `@hive/shared` `Row`
|
||||
// component the per-hive local terminal uses — "share components between hive and swarm level
|
||||
// term as much as possible" (mara).
|
||||
//
|
||||
// **Floating header badges** — turn state / model / ctx / cost parity
|
||||
|
|
@ -100,21 +101,50 @@ function fmtTokens(n: number): string {
|
|||
return String(n);
|
||||
}
|
||||
|
||||
// The paths one terminal is reached at. A subagent's terminal sits under its
|
||||
// agent's, and has no turn-state header: the swarm relays none for it.
|
||||
interface TermPaths {
|
||||
label: string;
|
||||
page: string;
|
||||
stream: string;
|
||||
state: string | null;
|
||||
}
|
||||
|
||||
export function termPaths(agentName: string, subagent?: string): TermPaths {
|
||||
const agent = `/agents/${encodeURIComponent(agentName)}`;
|
||||
if (subagent === undefined) {
|
||||
return {
|
||||
label: agentName,
|
||||
page: `${agent}/terminal`,
|
||||
stream: `/api${agent}/term/stream`,
|
||||
state: `/api${agent}/state/stream`,
|
||||
};
|
||||
}
|
||||
const sub = `${agent}/subagents/${encodeURIComponent(subagent)}`;
|
||||
return {
|
||||
label: `${agentName}/${subagent}`,
|
||||
page: `${sub}/terminal`,
|
||||
stream: `/api${sub}/term/stream`,
|
||||
state: null,
|
||||
};
|
||||
}
|
||||
|
||||
export function AgentTermPreview({
|
||||
agentName,
|
||||
subagent,
|
||||
showHeaderBadges = true,
|
||||
fullHeight = false,
|
||||
}: {
|
||||
agentName: string;
|
||||
// Set: this is one of `agentName`'s subagents, output only like the
|
||||
// agent's own.
|
||||
subagent?: string;
|
||||
showHeaderBadges?: boolean;
|
||||
fullHeight?: boolean;
|
||||
}) {
|
||||
const { rows, connection } = useSwarmTermStream(
|
||||
`/api/agents/${encodeURIComponent(agentName)}/term/stream`,
|
||||
);
|
||||
const { header } = useSwarmAgentStateStream(
|
||||
`/api/agents/${encodeURIComponent(agentName)}/state/stream`,
|
||||
);
|
||||
const paths = termPaths(agentName, subagent);
|
||||
const { rows, connection } = useSwarmTermStream(paths.stream);
|
||||
const { header } = useSwarmAgentStateStream(paths.state);
|
||||
const { open: openTab } = useDynamicTabs();
|
||||
const logRef = useRef<HTMLDivElement>(null);
|
||||
const [stickToBottom, setStickToBottom] = useState(true);
|
||||
|
|
@ -156,13 +186,8 @@ export function AgentTermPreview({
|
|||
variant="quiet"
|
||||
icon={<ExpandIcon />}
|
||||
value="expand"
|
||||
title={`open ${agentName}'s terminal in its own tab`}
|
||||
onClick={() =>
|
||||
openTab({
|
||||
href: `/agents/${encodeURIComponent(agentName)}/terminal`,
|
||||
label: agentName,
|
||||
})
|
||||
}
|
||||
title={`open ${paths.label}'s terminal in its own tab`}
|
||||
onClick={() => openTab({ href: paths.page, label: paths.label })}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -13,30 +13,42 @@
|
|||
// idempotent (dedupes by href), so this is safe to call every mount.
|
||||
import { useEffect } from "preact/hooks";
|
||||
import type { RouteComponentProps } from "wouter-preact";
|
||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||
import { AgentTermPreview, termPaths } from "./AgentTermPreview.js";
|
||||
import { useDynamicTabs } from "../../shell/useDynamicTabs.js";
|
||||
import "./AgentTerminalPage.css";
|
||||
|
||||
// Also mounted at `/agents/:name/subagents/:subagent/terminal`, for one of
|
||||
// the agent's subagents.
|
||||
export function AgentTerminalPage({
|
||||
params,
|
||||
}: RouteComponentProps<{ name: string }>) {
|
||||
}: RouteComponentProps<{ name: string; subagent?: string }>) {
|
||||
const { open } = useDynamicTabs();
|
||||
const name = decodeURIComponent(params.name);
|
||||
const subagent =
|
||||
params.subagent === undefined
|
||||
? undefined
|
||||
: decodeURIComponent(params.subagent);
|
||||
const { page, label } = termPaths(name, subagent);
|
||||
|
||||
// Runs once per mounted agent name — re-registering on every render
|
||||
// Runs once per mounted terminal — re-registering on every render
|
||||
// would just be redundant work against an already-deduped list, but
|
||||
// there's no reason to pay it more than once per identity.
|
||||
useEffect(() => {
|
||||
open({ href: `/agents/${encodeURIComponent(name)}/terminal`, label: name });
|
||||
open({ href: page, label });
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- `open` is a
|
||||
// fresh function identity every render (`useDynamicTabs` doesn't
|
||||
// memoize it); including it would re-fire this effect every render
|
||||
// instead of once per agent `name`.
|
||||
}, [name]);
|
||||
// instead of once per terminal `page`.
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div class="ui-agent-terminal-page">
|
||||
<AgentTermPreview agentName={name} fullHeight showHeaderBadges />
|
||||
<AgentTermPreview
|
||||
agentName={name}
|
||||
subagent={subagent}
|
||||
fullHeight
|
||||
showHeaderBadges
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ import { Badge } from "@hive/shared/badge.js";
|
|||
import { LinkIcon } from "@hive/shared/icons.js";
|
||||
import { AgentCard } from "./AgentCard.js";
|
||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||
import { SubagentTerms } from "./SubagentTerms.js";
|
||||
import { FRESHNESS, type AgentRow } from "./AgentTypes.js";
|
||||
import { LinkedAccounts } from "./LinkedAccounts.js";
|
||||
import { Button } from "../../ui/button/Button.js";
|
||||
|
|
@ -654,6 +655,10 @@ export function AgentsPage() {
|
|||
preview, no header/no input — the full terminal
|
||||
(+ sending input back) is explicit follow-up scope. */}
|
||||
<AgentTermPreview agentName={detailTarget.name} />
|
||||
<SubagentTerms
|
||||
key={detailTarget.name}
|
||||
agentName={detailTarget.name}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<p class="ui-agents-empty">select an agent to see its details</p>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,17 @@
|
|||
/* <SubagentTerms> — one row of subagent names under the agent's own
|
||||
terminal preview; the picked subagent's preview sits below it. */
|
||||
.ui-subagent-terms {
|
||||
margin-block-start: 1rem;
|
||||
}
|
||||
|
||||
.ui-subagent-terms-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5em;
|
||||
}
|
||||
|
||||
.ui-subagent-terms-heading,
|
||||
.ui-subagent-terms-empty {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
|
@ -0,0 +1,76 @@
|
|||
// <SubagentTerms> — an agent's subagents, under its own terminal preview in
|
||||
// `AgentsPage`'s detail panel. The list is `GET /api/agents/{name}/subagents`:
|
||||
// the subagents the swarm queue holds terminal rows for. Subagents are
|
||||
// spawned on demand, so it is refetched every `REFRESH_MS`. Picking one
|
||||
// shows its terminal in `AgentTermPreview`, expand-to-tab included. Read-only
|
||||
// like the agent's own: subagents take no input.
|
||||
//
|
||||
// Mount it keyed by agent name: the refresh timer is armed once per mount.
|
||||
import { useState } from "preact/hooks";
|
||||
import { ApiErrorPanel } from "@hive/shared/api-error-panel.js";
|
||||
import { readApiError, type ProblemDetails } from "@hive/shared/api-error.js";
|
||||
import { Badge } from "@hive/shared/badge.js";
|
||||
import { AgentTermPreview } from "./AgentTermPreview.js";
|
||||
import { useRefreshInterval } from "../../ui/refresh-interval/RefreshInterval.js";
|
||||
import "./SubagentTerms.css";
|
||||
|
||||
const REFRESH_MS = 10_000;
|
||||
|
||||
export function SubagentTerms({ agentName }: { agentName: string }) {
|
||||
const [names, setNames] = useState<string[] | null>(null);
|
||||
const [error, setError] = useState<ProblemDetails | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
const res = await fetch(
|
||||
`/api/agents/${encodeURIComponent(agentName)}/subagents`,
|
||||
);
|
||||
if (!res.ok) {
|
||||
setError(await readApiError(res));
|
||||
return;
|
||||
}
|
||||
setNames((await res.json()) as string[]);
|
||||
setError(null);
|
||||
}
|
||||
|
||||
useRefreshInterval(REFRESH_MS, () => {
|
||||
refresh().catch((e: unknown) => setError({ detail: String(e) }));
|
||||
});
|
||||
|
||||
return (
|
||||
<section class="ui-subagent-terms" aria-label={`${agentName}'s subagents`}>
|
||||
<div class="ui-subagent-terms-list">
|
||||
<span class="ui-subagent-terms-heading">subagents</span>
|
||||
{names === null && !error && <Badge value="loading…" />}
|
||||
{names?.length === 0 && (
|
||||
<span class="ui-subagent-terms-empty">
|
||||
none with output on record
|
||||
</span>
|
||||
)}
|
||||
{names?.map((name) => (
|
||||
<Badge
|
||||
key={name}
|
||||
variant={name === selected ? "default" : "quiet"}
|
||||
value={name}
|
||||
expanded={name === selected}
|
||||
title={
|
||||
name === selected
|
||||
? `hide ${name}'s terminal`
|
||||
: `show ${name}'s terminal`
|
||||
}
|
||||
onClick={() => setSelected(name === selected ? null : name)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{error && (
|
||||
<ApiErrorPanel
|
||||
problem={error}
|
||||
context={`failed to list ${agentName}'s subagents`}
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<AgentTermPreview agentName={agentName} subagent={selected} />
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
|
@ -47,7 +47,7 @@ export interface UseSwarmAgentStateStreamResult {
|
|||
}
|
||||
|
||||
export function useSwarmAgentStateStream(
|
||||
streamUrl: string,
|
||||
streamUrl: string | null,
|
||||
): UseSwarmAgentStateStreamResult {
|
||||
const [header, setHeader] = useState<AgentStateHeader | null>(null);
|
||||
const [connection, setConnection] = useState<ConnectionState>("connecting");
|
||||
|
|
@ -55,6 +55,8 @@ export function useSwarmAgentStateStream(
|
|||
useEffect(() => {
|
||||
setHeader(null);
|
||||
setConnection("connecting");
|
||||
// `null`: a terminal with no header to follow (a subagent's).
|
||||
if (streamUrl === null) return;
|
||||
const es = new EventSource(streamUrl);
|
||||
es.onopen = () => setConnection("open");
|
||||
es.onmessage = (e) => {
|
||||
|
|
|
|||
|
|
@ -62,13 +62,15 @@ const NAV_ITEMS: { href: string; label: string; accent: string }[] = [
|
|||
// its own table view (plus the list+detail split) hits the same cap.
|
||||
const WIDE_BODY_ROUTES = new Set(["/issues", "/agents"]);
|
||||
|
||||
// `/agents/:name/terminal` needs the same widening but can't join
|
||||
// `/agents/:name/terminal` (and a subagent's, under
|
||||
// `/agents/:name/subagents/:subagent/`) needs the same widening but can't join
|
||||
// the `Set` above — it's a parameterized path, one literal string can't
|
||||
// match every agent name. A 60em-capped terminal pane reads as cramped
|
||||
// (this is a full tab, not a sidebar preview), same reasoning as the
|
||||
// table routes above just for a different reason (a wide pane, not a
|
||||
// wide table).
|
||||
const WIDE_BODY_ROUTE_PATTERN = /^\/agents\/[^/]+\/terminal$/;
|
||||
const WIDE_BODY_ROUTE_PATTERN =
|
||||
/^\/agents\/[^/]+(\/subagents\/[^/]+)?\/terminal$/;
|
||||
|
||||
function isWideBodyRoute(location: string): boolean {
|
||||
return (
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ mod reminders;
|
|||
mod serve_common;
|
||||
mod state_entry_watch;
|
||||
mod stats;
|
||||
mod stream_enrich;
|
||||
mod swarm_agent_icon;
|
||||
mod swarm_agent_state;
|
||||
mod swarm_queue;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
//! Terminal-message wire shape: what the per-agent web UI's live/history
|
||||
//! endpoints actually serve for the "terminal" event stream, as opposed to
|
||||
//! The harness's events as terminal rows: what the per-agent web UI's
|
||||
//! live/history endpoints serve for the "terminal" event stream, as opposed to
|
||||
//! agent-state changes (`StatusChanged`/`ModelChanged`/`EffortChanged`/
|
||||
//! `TokenUsageChanged`/`TurnStateChanged`), which have never rendered as
|
||||
//! terminal rows (the header/badges poll `/api/state`, not this stream) and
|
||||
|
|
@ -9,167 +9,17 @@
|
|||
//! map to exactly one, but `LiveEvent::Stream` (one raw claude
|
||||
//! `stream-json` line) can expand to several: an `assistant` message with
|
||||
//! both a text block and a `tool_use` block produces two rows.
|
||||
//!
|
||||
//! Seven fields, and classification belongs **here**, not in the client: the
|
||||
//! web UI renders what it is handed and owns no per-tool dispatch table, so
|
||||
//! a new tool needs no frontend change. `level` carries styling, so a row
|
||||
//! never names a CSS class. `kind`, `unread`, `from` and `expanded_default`
|
||||
//! are deliberately absent — adding one back is a design change, not an
|
||||
//! oversight.
|
||||
//!
|
||||
//! The seventh field is `ts`, and it is the row's own: [`classify`] stamps
|
||||
//! every row it returns with the time of the event it classified, not the
|
||||
//! time it ran. The swarm queue publishes the bare row with no envelope
|
||||
//! around it, so a subscriber that reads a row has nowhere else to learn
|
||||
//! when the thing happened; carrying the source event's time means a row
|
||||
//! replayed out of sqlite months later still says when it happened rather
|
||||
//! than when it was read.
|
||||
|
||||
use chrono::{DateTime, SecondsFormat};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
pub use hive_sh4re::term_msg::{ClassifyCtx, Level, TermMsg, iso8601_utc};
|
||||
|
||||
use crate::events::LiveEvent;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Level {
|
||||
/// Low-signal / ambient chatter — thinking, progress ticks, harness
|
||||
/// housekeeping. The client's default rendering can dim/de-emphasize
|
||||
/// these without hiding them outright.
|
||||
Debug,
|
||||
/// Routine substantive content — turn boundaries, assistant text, tool
|
||||
/// calls/results, message bodies.
|
||||
Info,
|
||||
/// Heads-up, not necessarily broken — stderr lines, an unclassified
|
||||
/// event shape landing (the old `.sys` catch-all), API retries.
|
||||
Warn,
|
||||
/// Something actually failed — a turn ending non-ok, a tool result with
|
||||
/// `is_error: true`.
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BodyFormat {
|
||||
Markdown,
|
||||
Diff,
|
||||
}
|
||||
|
||||
/// One terminal row. `body_format: None` with `body: Some(_)` means plain
|
||||
/// text (the common case — no explicit tag on the wire for it, same logic
|
||||
/// as `body` itself being absent meaning "nothing to expand").
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TermMsg {
|
||||
/// When the classified event happened, ISO 8601 / RFC 3339 UTC.
|
||||
///
|
||||
/// Empty on a freshly built row and filled by [`classify`], which is
|
||||
/// the only path a row reaches either wire by — a builder that had to
|
||||
/// be handed the time would repeat the same value across the several
|
||||
/// rows one `stream-json` line expands into.
|
||||
pub ts: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
pub level: Level,
|
||||
pub summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body_format: Option<BodyFormat>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub coalesce_key: Option<String>,
|
||||
}
|
||||
|
||||
impl TermMsg {
|
||||
pub fn new(level: Level, summary: impl Into<String>) -> Self {
|
||||
Self {
|
||||
ts: String::new(),
|
||||
icon: None,
|
||||
level,
|
||||
summary: summary.into(),
|
||||
body: None,
|
||||
body_format: None,
|
||||
coalesce_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn icon(mut self, icon: impl Into<String>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn body(mut self, body: impl Into<String>, format: Option<BodyFormat>) -> Self {
|
||||
self.body = Some(body.into());
|
||||
self.body_format = format;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn coalesce(mut self, key: impl Into<String>) -> Self {
|
||||
self.coalesce_key = Some(key.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Stamp the row with the time of the event it came from.
|
||||
#[must_use]
|
||||
pub fn at(mut self, ts: impl Into<String>) -> Self {
|
||||
self.ts = ts.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Render an event's unix-seconds stamp as ISO 8601 / RFC 3339 UTC.
|
||||
///
|
||||
/// Seconds rather than milliseconds because that is the resolution the
|
||||
/// event bus and the sqlite `events.ts` column actually carry
|
||||
/// (`crate::events`) — a `.000` on every row would be precision the source
|
||||
/// does not have. UTC rather than local: the reader of a swarm-published
|
||||
/// row is not on the machine that wrote it, and an offset-carrying stamp
|
||||
/// would make two agents' rows sort by string differently than by time.
|
||||
///
|
||||
/// A stamp outside the representable range renders as the epoch: a row
|
||||
/// whose only defect is an absurd clock is still worth reading.
|
||||
#[must_use]
|
||||
pub fn iso8601_utc(unix_seconds: i64) -> String {
|
||||
DateTime::from_timestamp(unix_seconds, 0)
|
||||
.unwrap_or_default()
|
||||
.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
/// Per-connection/per-request classification state. A live SSE stream keeps
|
||||
/// one of these alive for the connection's lifetime — `tool_use` id → name
|
||||
/// correlation, so a `tool_result` can tell it's answering a `recv` call and
|
||||
/// render its body as markdown instead of plain text. The history endpoint
|
||||
/// uses a fresh one per page: correlation only works within the page
|
||||
/// actually returned, not across the live/history boundary. Accepted
|
||||
/// degradation — the only user-visible effect is a `recv` result whose
|
||||
/// `tool_use` fell on the other side of a page/reconnect boundary rendering
|
||||
/// its body as plain text instead of markdown.
|
||||
#[derive(Default)]
|
||||
pub struct ClassifyCtx {
|
||||
tool_name_by_id: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ClassifyCtx {
|
||||
pub fn record_tool_use(&mut self, id: &str, name: &str) {
|
||||
self.tool_name_by_id.insert(id.to_owned(), name.to_owned());
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tool_name(&self, id: &str) -> Option<&str> {
|
||||
self.tool_name_by_id.get(id).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// Classify one [`LiveEvent`] into zero or more terminal rows, each
|
||||
/// stamped with `ts` — the event's own unix-seconds time, from the bus on
|
||||
/// the live path and from the `events` table on replay.
|
||||
///
|
||||
/// The stamp is applied here, over the rows the classifiers return, so that
|
||||
/// every row reaching a wire carries one: a row built anywhere else has an
|
||||
/// empty `ts` and cannot get out without passing through this function.
|
||||
/// every row the harness puts on a wire carries one.
|
||||
pub fn classify(ev: &LiveEvent, ts: i64, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
|
||||
let ts = iso8601_utc(ts);
|
||||
classify_rows(ev, ctx)
|
||||
|
|
@ -202,7 +52,7 @@ fn classify_rows(ev: &LiveEvent, ctx: &mut ClassifyCtx) -> Vec<TermMsg> {
|
|||
vec![msg]
|
||||
}
|
||||
LiveEvent::Note { text } => vec![classify_note(text)],
|
||||
LiveEvent::Stream(v) => crate::stream_enrich::classify_stream_value(v, ctx),
|
||||
LiveEvent::Stream(v) => hive_sh4re::stream_enrich::classify_stream_value(v, ctx),
|
||||
// Agent-state transitions never render as terminal rows — the
|
||||
// header/badges read `/api/state`, not this stream (see module doc).
|
||||
LiveEvent::StatusChanged { .. }
|
||||
|
|
|
|||
|
|
@ -13,11 +13,10 @@ hive-priv-sock.workspace = true
|
|||
hive-types.workspace = true
|
||||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
# `stream_enrich` walks raw claude `stream-json` values into `term_msg` rows.
|
||||
serde_json.workspace = true
|
||||
strum.workspace = true
|
||||
# Facade only, for the one warn in `permissions::ToolGroup::parse_list`: an
|
||||
# unknown tool-group name is skipped rather than fatal, so the log line is the
|
||||
# only trace it leaves.
|
||||
tracing.workspace = true
|
||||
|
||||
[dev-dependencies]
|
||||
serde_json.workspace = true
|
||||
|
|
|
|||
|
|
@ -9,4 +9,6 @@ pub mod manager;
|
|||
pub mod paths;
|
||||
pub mod permissions;
|
||||
pub mod schedule;
|
||||
pub mod stream_enrich;
|
||||
pub mod term_msg;
|
||||
pub mod wire_time;
|
||||
|
|
|
|||
1426
hive-sh4re/src/stream_enrich.rs
Normal file
1426
hive-sh4re/src/stream_enrich.rs
Normal file
File diff suppressed because it is too large
Load diff
277
hive-sh4re/src/term_msg.rs
Normal file
277
hive-sh4re/src/term_msg.rs
Normal file
|
|
@ -0,0 +1,277 @@
|
|||
//! Terminal-message wire shape: one classified terminal row, as the
|
||||
//! per-agent web UI's live/history endpoints serve it and as the swarm queue
|
||||
//! carries it from an agent and from its subagents.
|
||||
//!
|
||||
//! Seven fields, and classification belongs **here**, not in the client: the
|
||||
//! web UI renders what it is handed and owns no per-tool dispatch table, so
|
||||
//! a new tool needs no frontend change. `level` carries styling, so a row
|
||||
//! never names a CSS class. `kind`, `unread`, `from` and `expanded_default`
|
||||
//! are deliberately absent — adding one back is a design change, not an
|
||||
//! oversight.
|
||||
//!
|
||||
//! The seventh field is `ts`, and it is the row's own: whoever classifies an
|
||||
//! event stamps every row it produced with the time of that event, not the
|
||||
//! time it ran. The swarm queue publishes the bare row with no envelope
|
||||
//! around it, so a subscriber that reads a row has nowhere else to learn
|
||||
//! when the thing happened; carrying the source event's time means a row
|
||||
//! replayed out of sqlite months later still says when it happened rather
|
||||
//! than when it was read.
|
||||
|
||||
use chrono::{DateTime, SecondsFormat};
|
||||
use serde::Serialize;
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum Level {
|
||||
/// Low-signal / ambient chatter — thinking, progress ticks, harness
|
||||
/// housekeeping. The client's default rendering can dim/de-emphasize
|
||||
/// these without hiding them outright.
|
||||
Debug,
|
||||
/// Routine substantive content — turn boundaries, assistant text, tool
|
||||
/// calls/results, message bodies.
|
||||
Info,
|
||||
/// Heads-up, not necessarily broken — stderr lines, an unclassified
|
||||
/// event shape landing (the old `.sys` catch-all), API retries.
|
||||
Warn,
|
||||
/// Something actually failed — a turn ending non-ok, a tool result with
|
||||
/// `is_error: true`.
|
||||
Error,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum BodyFormat {
|
||||
Markdown,
|
||||
Diff,
|
||||
}
|
||||
|
||||
/// One terminal row. `body_format: None` with `body: Some(_)` means plain
|
||||
/// text (the common case — no explicit tag on the wire for it, same logic
|
||||
/// as `body` itself being absent meaning "nothing to expand").
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct TermMsg {
|
||||
/// When the classified event happened, ISO 8601 / RFC 3339 UTC.
|
||||
///
|
||||
/// Empty on a freshly built row and filled with [`TermMsg::at`] by the
|
||||
/// caller that classified the event — a builder that had to be handed
|
||||
/// the time would repeat the same value across the several rows one
|
||||
/// `stream-json` line expands into.
|
||||
pub ts: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub icon: Option<String>,
|
||||
pub level: Level,
|
||||
pub summary: String,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body: Option<String>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub body_format: Option<BodyFormat>,
|
||||
#[serde(skip_serializing_if = "Option::is_none")]
|
||||
pub coalesce_key: Option<String>,
|
||||
}
|
||||
|
||||
impl TermMsg {
|
||||
pub fn new(level: Level, summary: impl Into<String>) -> Self {
|
||||
Self {
|
||||
ts: String::new(),
|
||||
icon: None,
|
||||
level,
|
||||
summary: summary.into(),
|
||||
body: None,
|
||||
body_format: None,
|
||||
coalesce_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn icon(mut self, icon: impl Into<String>) -> Self {
|
||||
self.icon = Some(icon.into());
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn body(mut self, body: impl Into<String>, format: Option<BodyFormat>) -> Self {
|
||||
self.body = Some(body.into());
|
||||
self.body_format = format;
|
||||
self
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn coalesce(mut self, key: impl Into<String>) -> Self {
|
||||
self.coalesce_key = Some(key.into());
|
||||
self
|
||||
}
|
||||
|
||||
/// Stamp the row with the time of the event it came from.
|
||||
#[must_use]
|
||||
pub fn at(mut self, ts: impl Into<String>) -> Self {
|
||||
self.ts = ts.into();
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// Render an event's unix-seconds stamp as ISO 8601 / RFC 3339 UTC.
|
||||
///
|
||||
/// Seconds rather than milliseconds because that is the resolution the
|
||||
/// event bus and the sqlite `events.ts` column actually carry
|
||||
/// (`hive-agent`'s `events`) — a `.000` on every row would be precision the source
|
||||
/// does not have. UTC rather than local: the reader of a swarm-published
|
||||
/// row is not on the machine that wrote it, and an offset-carrying stamp
|
||||
/// would make two agents' rows sort by string differently than by time.
|
||||
///
|
||||
/// A stamp outside the representable range renders as the epoch: a row
|
||||
/// whose only defect is an absurd clock is still worth reading.
|
||||
#[must_use]
|
||||
pub fn iso8601_utc(unix_seconds: i64) -> String {
|
||||
DateTime::from_timestamp(unix_seconds, 0)
|
||||
.unwrap_or_default()
|
||||
.to_rfc3339_opts(SecondsFormat::Secs, true)
|
||||
}
|
||||
|
||||
/// Per-connection/per-request classification state. A live SSE stream keeps
|
||||
/// one of these alive for the connection's lifetime — `tool_use` id → name
|
||||
/// correlation, so a `tool_result` can tell it's answering a `recv` call and
|
||||
/// render its body as markdown instead of plain text. The history endpoint
|
||||
/// uses a fresh one per page: correlation only works within the page
|
||||
/// actually returned, not across the live/history boundary. Accepted
|
||||
/// degradation — the only user-visible effect is a `recv` result whose
|
||||
/// `tool_use` fell on the other side of a page/reconnect boundary rendering
|
||||
/// its body as plain text instead of markdown.
|
||||
#[derive(Default)]
|
||||
pub struct ClassifyCtx {
|
||||
tool_name_by_id: HashMap<String, String>,
|
||||
}
|
||||
|
||||
impl ClassifyCtx {
|
||||
pub fn record_tool_use(&mut self, id: &str, name: &str) {
|
||||
self.tool_name_by_id.insert(id.to_owned(), name.to_owned());
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn tool_name(&self, id: &str) -> Option<&str> {
|
||||
self.tool_name_by_id.get(id).map(String::as_str)
|
||||
}
|
||||
}
|
||||
|
||||
/// What a row that lost its body to the payload limit carries instead.
|
||||
pub const DROPPED_BODY: &str = "[body dropped: over the queue's payload limit]";
|
||||
|
||||
/// 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.
|
||||
#[must_use]
|
||||
pub 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())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{BodyFormat, DROPPED_BODY, Level, TermMsg, fit};
|
||||
|
||||
/// A row's time, for the tests below that need one.
|
||||
const ROW_TS: &str = "2026-09-13T12:35:03Z";
|
||||
|
||||
#[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));
|
||||
}
|
||||
}
|
||||
|
|
@ -9,6 +9,8 @@ workspace = true
|
|||
|
||||
[dependencies]
|
||||
anyhow.workspace = true
|
||||
# The swarm-queue client for publishing subagent terminals (`swarm_term`).
|
||||
async-nats.workspace = true
|
||||
axum.workspace = true
|
||||
clap.workspace = true
|
||||
hive-agent-sock.workspace = true
|
||||
|
|
@ -17,6 +19,7 @@ hive-runtime.workspace = true
|
|||
# `permissions::builtin_tools_arg` — the same `--tools` resolution the parent
|
||||
# harness spawns its own claude with, so a subagent's built-in surface is its
|
||||
# parent's rather than a second list that drifts. See `session::build_config`.
|
||||
# Also `term_msg`/`stream_enrich`, the rows `swarm_term` publishes.
|
||||
hive-sh4re.workspace = true
|
||||
hive-sock-client.workspace = true
|
||||
hive-types.workspace = true
|
||||
|
|
@ -25,6 +28,10 @@ rmcp.workspace = true
|
|||
schemars.workspace = true
|
||||
serde.workspace = true
|
||||
serde_json.workspace = true
|
||||
# `subagent_term`: the subject, the stream, and opening it as the agent.
|
||||
swarm-queue-client = { workspace = true, features = ["subagent-term"] }
|
||||
# The agent's own queue credential, read under its store identity.
|
||||
swarm-secret-client.workspace = true
|
||||
tokio.workspace = true
|
||||
tracing.workspace = true
|
||||
tracing-subscriber.workspace = true
|
||||
|
|
|
|||
|
|
@ -21,3 +21,4 @@ pub mod mcp_config;
|
|||
pub mod paths;
|
||||
pub mod role;
|
||||
pub mod session;
|
||||
pub mod swarm_term;
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue