agentd/subagent/protocol.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The supervisor↔subagent control protocol. RFC 0005 §control-protocol,
3//! RFC 0009 §spawn-payload.
4//!
5//! A minimal JSON-RPC *sibling* — not literal MCP (no `initialize`
6//! handshake) — carried length-framed (4-byte prefix, [`crate::json::frame`])
7//! over the child's stdio pipes, so payloads that contain newlines
8//! (instructions, context seeds, distilled results) survive. Two directions:
9//! [`ControlMsg`] flows down (supervisor→child), [`AgentMsg`] flows up.
10//!
11//! The control reader inside the child runs on a thread **separate from the
12//! agentic loop** (RFC 0003), so `Ping`/`Pong` liveness survives a long
13//! in-flight tool/model call. This module is just the wire types; the spawn
14//! mechanics are `supervisor/spawn.rs`, the child side `subagent/control.rs`.
15
16use crate::agentloop::stop::Outcome;
17use crate::config::{A2aPeerSpec, McpServerSpec, SwapPolicy};
18use crate::wire::intel::Usage;
19use serde::{Deserialize, Serialize};
20use serde_json::Value;
21
22/// The environment variable the supervisor sets on the child so its `main`
23/// takes the subagent path instead of re-parsing CLI config.
24pub const SUBAGENT_ENV: &str = "AGENT_SUBAGENT";
25
26// ---- downward: supervisor -> subagent ----
27
28#[derive(Debug, Clone, Serialize, Deserialize)]
29#[serde(tag = "type", rename_all = "snake_case")]
30pub enum ControlMsg {
31 /// The first frame: everything the child needs to run. Sent exactly once.
32 Spawn(Box<SpawnPayload>),
33 /// Liveness probe; the child's control thread answers [`AgentMsg::Pong`].
34 Ping { seq: u64 },
35 /// Suspend the agentic loop at its next turn boundary (RFC 0005 §4.3,
36 /// RFC 0015 §4.3). The child's control thread sets a `paused` flag; the loop
37 /// waits between turns until [`ControlMsg::Resume`] clears it. The control
38 /// thread keeps running while the loop is suspended, so `Resume`/`Ping`/
39 /// `Cancel` still arrive — `Cancel` always wins over a pause.
40 Pause,
41 /// Clear a prior [`ControlMsg::Pause`]: the loop resumes at the next turn.
42 Resume,
43 /// Ask the child to wind down at the next turn boundary (graceful).
44 Cancel { reason: String },
45 /// Inject a message into the child's running warm session (parent `send` /
46 /// reactive continue); forwarded to the loop by the control reader thread.
47 Inject { message: String },
48 /// Hot-swap the child's intelligence config at its next turn boundary (RFC
49 /// 0018 §5.2). Sent by the supervisor's reload fan-out to every in-flight
50 /// child when a reload's diff touches `intelligence`/`model`/`model_swap` —
51 /// the same fan-out shape as [`ControlMsg::Pause`], with a payload. The
52 /// child's control thread stores it into a child-local LIVE handle; the
53 /// agentic loop reads it ONCE at the next turn boundary (where `pause_wait`
54 /// sits), rebuilds its [`crate::intel::client::IntelClient`] from the new
55 /// endpoint list (fresh health/breaker — a repointed endpoint starts CLOSED,
56 /// §5.2 step 2), and adopts the new model. An in-flight `complete_once` is
57 /// NEVER torn; the transcript is CONTINUOUS (§5.3, no context reset). The
58 /// `token` is a credential carried on the wire like [`SpawnPayload`]'s — it
59 /// is NEVER logged (the swap event/logs carry transport+index only).
60 SwapIntel(Box<SwapIntel>),
61 /// agentd 2.0 (RFC 0026 §2): the answer to an [`AgentMsg::ToolRequest`] —
62 /// the supervisor executed the internal tool; `result` is the tool's
63 /// output (or an error message when `is_error`).
64 ToolResult {
65 id: u64,
66 result: Value,
67 #[serde(default)]
68 is_error: bool,
69 },
70 /// agentd 2.0 (RFC 0026 §7): the answer to an [`AgentMsg::BudgetRequest`].
71 /// `ok` = proceed now; else wait `wait_ms` and ask again (`wait`/`slow`),
72 /// or the request is refused (`reason`); `model` = a degrade swap.
73 BudgetGrant {
74 id: u64,
75 ok: bool,
76 #[serde(default)]
77 wait_ms: u64,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 model: Option<String>,
80 #[serde(default, skip_serializing_if = "Option::is_none")]
81 reason: Option<String>,
82 },
83}
84
85/// The intelligence config the child rebuilds its client from on a hot-swap (RFC
86/// 0018 §5.2). The endpoint-list URI + the default endpoint-1 credential + the
87/// model + the swap policy — exactly the parts [`IntelConfig`] carries plus the
88/// policy. Boxed in [`ControlMsg`] to keep the enum small (like [`SpawnPayload`]).
89#[derive(Debug, Clone, Serialize, Deserialize)]
90pub struct SwapIntel {
91 /// The new endpoint *list* URI (RFC 0018 §3.1) — a single element is RFC 0006.
92 pub uri: String,
93 /// Endpoint 1's resolved default credential when its env override is unset
94 /// (the same role as [`IntelConfig::token`]); NEVER logged.
95 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub token: Option<String>,
97 /// The new model (`None` ⇒ unchanged from the spawn payload's resolved model).
98 #[serde(default, skip_serializing_if = "Option::is_none")]
99 pub model: Option<String>,
100 /// The model-swap policy (RFC 0018 §5.3): `finish-on-old` (default) |
101 /// `restart-turn`. Only matters when `model` actually changed.
102 #[serde(default)]
103 pub policy: SwapPolicy,
104}
105
106// ---- upward: subagent -> supervisor ----
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109#[serde(tag = "type", rename_all = "snake_case")]
110pub enum AgentMsg {
111 /// Setup done (intel + scoped MCP connected); the child is about to loop.
112 /// The supervisor's crash-on-spawn fast-fail waits for this (RFC 0003).
113 Ready,
114 /// Answer to a [`ControlMsg::Ping`].
115 Pong { seq: u64 },
116 /// A progress event (loop.step, tool.call, …) — also resets the
117 /// no-progress watchdog (Detector B, RFC 0003). `fields` is opaque to the
118 /// supervisor except for correlation.
119 Event { event: String, fields: Value },
120 /// Incremental token/step usage for hierarchical accounting (RFC 0003).
121 Usage(Usage),
122 /// A **warm session** finished one turn (its reaction to one delivered
123 /// event) and stays alive for the next. Carries that turn's distilled
124 /// outcome; unlike [`AgentMsg::Result`] it is **not** terminal (RFC 0008
125 /// §spawn-vs-continue). The supervisor applies the turn's self-schedule /
126 /// self-subscribe effects and may then `Inject` the next event.
127 Turn { outcome: Outcome },
128 /// Terminal: the distilled result + final status. Sent exactly once.
129 Result { outcome: Outcome },
130 /// Terminal: a fatal infrastructure failure (intel/mcp unreachable).
131 Failed { error: String },
132 /// A HUMAN GATE opened (RFC 0021 §7): a workflow `human` node suspended
133 /// awaiting input. `node` is the workflow node id; `payload` is the resolved
134 /// gate payload (what the human is being asked to look at). The supervisor
135 /// records it (the served A2A task projects `input-required`, the gate
136 /// resource serves the payload) and later fans the human's reply DOWN as
137 /// [`ControlMsg::Inject`]. Non-terminal; also progress for liveness.
138 Gate { node: String, payload: Value },
139 /// The gate resolved (`via` = `"reply"` | `"uri"` | `"timeout"`): the
140 /// supervisor clears the recorded gate and the A2A task returns to
141 /// `working`. Non-terminal.
142 GateClosed { node: String, via: String },
143 /// The child's intelligence reachability, edge-triggered at the breaker/
144 /// failover seam (RFC 0018 §6). Emitted ONLY on a transition: on **entering**
145 /// all-endpoints-down (every configured endpoint's breaker open / the failover
146 /// sweep exhausted) and on **recovering** (any endpoint usable again). The
147 /// supervisor has no LLM of its own and no live view of a child's breaker
148 /// state, so the child reports it upward; the supervisor latches it into the
149 /// `intel_all_down` process-global the readiness probe + `agentd_intel_all_down`
150 /// gauge + `agentd://intelligence`/`capacity` bodies read (the one latched
151 /// truth, eventually-consistent — see [`crate::signals::set_intel_all_down`]).
152 /// `active` is best-effort transport+index ONLY — NEVER a URL or credential
153 /// (mirrors the `agentd://intelligence` redaction, RFC 0012 §3.7).
154 IntelHealth {
155 all_down: bool,
156 #[serde(default, skip_serializing_if = "Option::is_none")]
157 active: Option<IntelActive>,
158 },
159 /// agentd 2.0 (RFC 0026 §2): a turn worker / subagent asks the supervisor
160 /// to execute an **internal** tool (memory, plan, subagent.run, sleep…) —
161 /// state changes are made by the state owner. Answered by
162 /// [`ControlMsg::ToolResult`] with the same `id`.
163 ToolRequest { id: u64, name: String, args: Value },
164 /// agentd 2.0 (RFC 0026 §7): budget admission before a model call.
165 /// Answered by [`ControlMsg::BudgetGrant`].
166 BudgetRequest { id: u64, estimate: u64 },
167 /// agentd 2.0: a `Role::Turn` worker finished its turn (terminal for the
168 /// worker). Carries the transcript delta, the usage, and the outcome.
169 TurnDone { turn: Box<TurnResult> },
170}
171
172/// Which endpoint is serving the child's intelligence, for [`AgentMsg::IntelHealth`].
173/// The bounded structural identity ONLY — the list index + the transport scheme
174/// (`unix`/`vsock`/`https`) — never the URL/cid/host or any credential (RFC 0012
175/// §3.7, mirroring the `agentd://intelligence` resource redaction).
176#[derive(Debug, Clone, Serialize, Deserialize)]
177pub struct IntelActive {
178 pub index: usize,
179 pub transport: String,
180}
181
182// ---- spawn payload ----
183
184/// Everything a subagent needs to run, minted by the supervisor. The child
185/// trusts none of this from its own request — `depth` in particular is
186/// **minted by the supervisor** from the caller's handle (RFC 0009).
187#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SpawnPayload {
189 /// The task. For a delegated child this is the parent's `instruction`
190 /// argument; see also `output_contract`.
191 pub instruction: String,
192 /// Objective + required output format + boundaries — a real delegation
193 /// contract, not a bare string (RFC 0009 §spawn-payload).
194 #[serde(default, skip_serializing_if = "Option::is_none")]
195 pub output_contract: Option<String>,
196 /// The narrowed context the parent chose to share — never the full
197 /// transcript (context hygiene + injection firewall, RFC 0012).
198 #[serde(default, skip_serializing_if = "Vec::is_empty")]
199 pub context_seed: Vec<SeedMessage>,
200 /// How to reach the LLM (env/flag-sourced; never logged).
201 pub intelligence: IntelConfig,
202 /// The child's **scoped** MCP server subset (⊆ parent's; RFC 0009).
203 #[serde(default)]
204 pub mcp_servers: Vec<McpServerSpec>,
205 /// Declared remote-A2A delegation peers (RFC 0020 §3). Inherited by children
206 /// like `mcp_servers` so a subagent can also delegate over A2A; the
207 /// `a2a.delegate` self-tool dials these. `#[serde(default)]` keeps older
208 /// frames (and non-`a2a` peers, which simply send an empty vec) parseable.
209 #[serde(default, skip_serializing_if = "Vec::is_empty")]
210 pub a2a_peers: Vec<A2aPeerSpec>,
211 /// Extra PEM CA **file path** for outbound TLS trust (`--tls-ca`, the
212 /// private/in-cluster PKI anchor). PUBLIC material — a path to a CA
213 /// certificate, never key bytes — so it may ride the payload; the child
214 /// installs it process-wide before its first dial and passes it on to its
215 /// own children. `#[serde(default)]` keeps older frames parseable.
216 #[serde(default, skip_serializing_if = "Option::is_none")]
217 pub tls_ca: Option<String>,
218 /// AAuth [DRAFT] agent-identity settings (RFC 0023): inherited by every
219 /// subagent so the whole process tree signs MCP requests under ONE identity.
220 /// The key file is a shared-fs path (like `tls_ca`); no secret rides here
221 /// (the enrollment token is a `{{secret:…}}` template). `#[serde(default)]`
222 /// keeps older frames parseable.
223 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub aauth: Option<crate::config::AAuthSettings>,
225 pub limits: Limits,
226 pub telemetry: Telemetry,
227 /// Supervisor-minted tree depth (0 = root).
228 pub depth: u32,
229 /// Run as a **warm continue-session**: after each turn, stay alive and wait
230 /// for the next injected event ([`ControlMsg::Inject`]) instead of exiting,
231 /// continuing the same transcript (RFC 0008 §spawn-vs-continue). Default
232 /// (false) = a one-shot per-event run. `#[serde(default)]` keeps older frames
233 /// parseable.
234 #[serde(default)]
235 pub warm: bool,
236 /// agentd 2.0 (RFC 0026 §2): the child's role. `agent` (default) is the
237 /// RFC 0009 subagent (ReAct loop / workflow driver); `turn` is a **turn
238 /// worker** driven by `turn` below. `#[serde(default)]` keeps older frames
239 /// parseable.
240 #[serde(default)]
241 pub role: Role,
242 /// The turn worker's input (`role: turn`).
243 #[serde(default, skip_serializing_if = "Option::is_none")]
244 pub turn: Option<Box<TurnSpec>>,
245}
246
247/// The reserved [`SeedMessage::role`] that carries a child's **tool allow-list**
248/// — `subagent.run`'s `tools:` narrowing (RFC 0009: scope narrows monotonically
249/// down the tree). Minted by the supervisor with
250/// [`SpawnPayload::narrow_tools`], enforced by the child in
251/// [`crate::agentloop::runner::Session::prepare`], which filters its assembled
252/// catalogue AND its dispatch against it.
253///
254/// The grant rides `context_seed` because that is the one part of the payload the
255/// child forwards VERBATIM into the loop's `LoopInput` (`subagent/control.rs`), so
256/// it reaches the one place the catalogue is assembled without a second adapter
257/// hop. The loop CONSUMES it — it is a grant, not a message, and never enters the
258/// transcript. The slash makes it uninhabitable by a real role (`system`/`user`/
259/// `assistant`/`tool`), and the direction is fail-safe: a marker can only ever
260/// REMOVE tools from the grant the supervisor already made, never add one.
261pub const ALLOWED_TOOLS_ROLE: &str = "agentd/allowed-tools";
262
263/// Parse an allow-list marker's body (a JSON array of registry patterns: `*`, an
264/// exact name, `prefix*`). An unreadable body narrows to NOTHING rather than to
265/// everything — a grant that cannot be read is not a grant (fail closed).
266pub fn parse_allowed_tools(content: &str) -> Vec<String> {
267 serde_json::from_str::<Vec<String>>(content).unwrap_or_default()
268}
269
270impl SpawnPayload {
271 /// Narrow this child's tool grant to `allow` (RFC 0009). Any allow-list entry
272 /// already in the seed is dropped first, so the SUPERVISOR's mint is the only
273 /// grant the child sees — a caller-supplied `context` array cannot forge or
274 /// widen one. An empty `allow` is a real narrowing to nothing, not "no
275 /// narrowing"; leave the marker off entirely for the unnarrowed case.
276 pub fn narrow_tools(&mut self, allow: &[String]) {
277 self.context_seed.retain(|m| m.role != ALLOWED_TOOLS_ROLE);
278 self.context_seed.insert(
279 0,
280 SeedMessage {
281 role: ALLOWED_TOOLS_ROLE.to_string(),
282 content: serde_json::to_string(allow).unwrap_or_else(|_| "[]".to_string()),
283 },
284 );
285 }
286
287 /// The narrowed grant this payload carries (`None` = unnarrowed: the full
288 /// catalogue the granted servers publish). Reads back what
289 /// [`SpawnPayload::narrow_tools`] minted — including after a restore, which
290 /// re-spawns from the stored payload.
291 pub fn allowed_tools(&self) -> Option<Vec<String>> {
292 self.context_seed
293 .iter()
294 .find(|m| m.role == ALLOWED_TOOLS_ROLE)
295 .map(|m| parse_allowed_tools(&m.content))
296 }
297}
298
299/// A checkpoint-resume reference (RFC 0021 §8.4). Retained for the (dead) v1
300/// `Config` `--workflow-resume` parsing until the full v1-`Config` deletion; the
301/// v1 in-child workflow driver that consumed it was removed with the mode
302/// cut-over.
303#[cfg(feature = "workflow")]
304#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
305pub struct WorkflowResumeRef {
306 pub server: String,
307 pub key: String,
308 #[serde(default, skip_serializing_if = "Option::is_none")]
309 pub seq: Option<u64>,
310 #[serde(default)]
311 pub force: bool,
312}
313
314/// The child's role (agentd 2.0, RFC 0026 §2).
315#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
316#[serde(rename_all = "snake_case")]
317pub enum Role {
318 /// An RFC 0009 subagent: the ReAct loop on `instruction` (or a workflow).
319 #[default]
320 Agent,
321 /// A turn worker: ONE turn over a supplied context slice, internal tools
322 /// round-tripped to the supervisor (RFC 0026 §2).
323 Turn,
324}
325
326/// What kind of turn a `Role::Turn` worker runs.
327#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
328#[serde(rename_all = "snake_case")]
329pub enum TurnKind {
330 /// A root/conversation turn: tools, may act, ends with a reply.
331 #[default]
332 Turn,
333 /// A structured reasoning call: no tools, an object out (`think`, preflight,
334 /// compaction).
335 Think,
336 /// A bounded agentic run for a workflow `agent` step / subagent: tools,
337 /// output contract/schema, ends with a result.
338 Agent,
339}
340
341/// The turn worker's input (RFC 0026 §3.2). Everything the child needs to run
342/// one turn: the system prompt, the context slice, the tool definitions and
343/// which of them round-trip, the output schema, and the knobs.
344#[derive(Debug, Clone, Serialize, Deserialize, Default)]
345pub struct TurnSpec {
346 #[serde(default)]
347 pub kind: TurnKind,
348 /// The full system prompt (instruction + capabilities + skills + summary).
349 pub system: String,
350 /// The transcript slice (context messages incl. the triggering event).
351 #[serde(default)]
352 pub messages: Vec<crate::context::Msg>,
353 /// LLM-facing tool definitions (every class).
354 #[serde(default)]
355 pub tools: Vec<crate::wire::intel::ToolDef>,
356 /// Tool names that ROUND-TRIP to the supervisor (internal + mapped).
357 #[serde(default)]
358 pub internal: Vec<String>,
359 /// MCP-class tools the child calls itself: tool name → (server, wire tool).
360 #[serde(default)]
361 pub mcp_routes: std::collections::BTreeMap<String, (String, String)>,
362 /// Validate the final answer against this schema (structured turns).
363 #[serde(default, skip_serializing_if = "Option::is_none")]
364 pub output_schema: Option<Value>,
365 /// Max model rounds in this turn (0 = the payload's `limits.max_steps`).
366 #[serde(default)]
367 pub max_rounds: u32,
368 /// Ask the supervisor for budget admission before every model call.
369 #[serde(default)]
370 pub budget_admission: bool,
371 /// The idempotency-key prefix for effects (`<ctx>/<turn>`), RFC 0025 §7.
372 #[serde(default)]
373 pub idempotency_prefix: String,
374 /// Extra `_meta` stamped on MCP tool calls (run/ctx/principal).
375 #[serde(default, skip_serializing_if = "Option::is_none")]
376 pub tool_meta: Option<Value>,
377 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub temperature: Option<f32>,
379 /// Per-response completion cap (0 = default).
380 #[serde(default)]
381 pub max_tokens_per_call: u32,
382 /// The turn id (for logs and idempotency).
383 #[serde(default)]
384 pub turn_id: String,
385}
386
387/// A finished turn (RFC 0026 §3.2 step 4). `messages` is the transcript delta
388/// (assistant/tool messages appended during the turn, in order).
389#[derive(Debug, Clone, Serialize, Deserialize, Default)]
390pub struct TurnResult {
391 /// `completed` | `refused` | `exhausted_steps` | `exhausted_tokens` |
392 /// `deadline` | `loop_detected` | `cancelled` | `failed`.
393 pub status: String,
394 #[serde(default)]
395 pub messages: Vec<crate::context::Msg>,
396 /// The final text (a reply / the answer).
397 #[serde(default, skip_serializing_if = "Option::is_none")]
398 pub text: Option<String>,
399 /// The parsed structured value (structured turns / schema'd answers).
400 #[serde(default, skip_serializing_if = "Option::is_none")]
401 pub value: Option<Value>,
402 #[serde(default)]
403 pub usage: Usage,
404 #[serde(default)]
405 pub rounds: u32,
406 #[serde(default)]
407 pub tool_calls: u32,
408 #[serde(default, skip_serializing_if = "Option::is_none")]
409 pub error: Option<String>,
410 /// The `finish` call, if the model made one (`{status, output, reason, exit}`).
411 #[serde(default, skip_serializing_if = "Option::is_none")]
412 pub finish: Option<Value>,
413}
414
415/// A single seed message — a minimal {role, content} pair. Roles mirror the
416/// loop's: `system` | `user` | `assistant` | `tool`.
417#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct SeedMessage {
419 pub role: String,
420 pub content: String,
421}
422
423#[derive(Debug, Clone, Serialize, Deserialize)]
424pub struct IntelConfig {
425 pub uri: String,
426 #[serde(default, skip_serializing_if = "Option::is_none")]
427 pub token: Option<String>,
428 #[serde(default, skip_serializing_if = "Option::is_none")]
429 pub model: Option<String>,
430 /// The resolved `intelligence.headers` (RFC 0031) — arbitrary per-dial
431 /// headers (e.g. a gateway routing header). Secret-free at rest is not
432 /// guaranteed (values may resolve secrets), so this rides the payload
433 /// resolved and is never logged. Empty by default.
434 #[serde(default, skip_serializing_if = "Vec::is_empty")]
435 pub headers: Vec<(String, String)>,
436 /// An `intelligence.auth: { kind: aws }` spec (RFC 0031) so the child can
437 /// SigV4-sign the LLM dial. Secret-free (creds come from env/imds/irsa/the
438 /// SSO cache at dial time). `None` for the non-AWS path.
439 #[serde(default, skip_serializing_if = "Option::is_none")]
440 pub aws_auth: Option<crate::config::AuthSpec>,
441 /// The wire dialect (RFC 0031 §8): `openai` (default), `anthropic`, or
442 /// `bedrock`. `None` ⇒ OpenAI-compatible (the byte-identical legacy path).
443 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub dialect: Option<String>,
445}
446
447#[derive(Debug, Clone, Serialize, Deserialize)]
448pub struct Limits {
449 pub max_steps: u32,
450 pub max_tokens: u64,
451 /// Wall-clock deadline in milliseconds from the child's start. The child
452 /// arms its own deadline; the supervisor also tracks an absolute one
453 /// (Detector A, RFC 0003).
454 pub deadline_ms: u64,
455 pub max_depth: u32,
456}
457
458/// The correlation block stamped into the child's logs (RFC 0010
459/// §tree-correlation).
460#[derive(Debug, Clone, Serialize, Deserialize)]
461pub struct Telemetry {
462 pub run_id: String,
463 pub agent_id: String,
464 pub agent_path: String,
465 #[serde(default, skip_serializing_if = "Option::is_none")]
466 pub trace_id: Option<String>,
467 pub log_level: String,
468 /// Content-capture policy (RFC 0010 §2.9): when true the child logs tool
469 /// args/results, not just lengths. Inherited from the parent's `--log-content`.
470 #[serde(default)]
471 pub log_content: bool,
472}
473
474#[cfg(test)]
475mod tests {
476 use super::*;
477 use crate::agentloop::stop::TerminalStatus;
478 use crate::json::frame;
479 use serde_json::json;
480 use std::io::Cursor;
481
482 fn payload() -> SpawnPayload {
483 SpawnPayload {
484 instruction: "summarize the file".into(),
485 output_contract: Some("Return a 3-bullet summary.".into()),
486 context_seed: vec![SeedMessage {
487 role: "user".into(),
488 content: "prior note".into(),
489 }],
490 intelligence: IntelConfig {
491 uri: "https://intel.example".into(),
492 token: Some("secret".into()),
493 model: Some("m".into()),
494 headers: Vec::new(),
495 aws_auth: None,
496 dialect: None,
497 },
498 mcp_servers: vec![McpServerSpec {
499 name: "fs".into(),
500 endpoint: "unix:/mcp-fs.sock".into(),
501 tags: Vec::new(),
502 ..Default::default()
503 }],
504 a2a_peers: Vec::new(),
505 tls_ca: None,
506 aauth: None,
507 limits: Limits {
508 max_steps: 20,
509 max_tokens: 100_000,
510 deadline_ms: 600_000,
511 max_depth: 4,
512 },
513 telemetry: Telemetry {
514 run_id: "r1".into(),
515 agent_id: "0.1".into(),
516 agent_path: "0.1".into(),
517 trace_id: None,
518 log_level: "info".into(),
519 log_content: false,
520 },
521 depth: 1,
522 warm: false,
523 role: crate::subagent::protocol::Role::Agent,
524 turn: None,
525 }
526 }
527
528 #[test]
529 fn control_spawn_frames_roundtrip() {
530 // The whole point of length-framing: an instruction with newlines.
531 let mut p = payload();
532 p.instruction = "line1\nline2".into();
533 let msg = ControlMsg::Spawn(Box::new(p));
534 let mut buf = Vec::new();
535 frame::write_frame(&mut buf, &msg).unwrap();
536 let mut cur = Cursor::new(buf);
537 let bytes = frame::read_frame(&mut cur).unwrap().unwrap();
538 let back: ControlMsg = serde_json::from_slice(&bytes).unwrap();
539 match back {
540 ControlMsg::Spawn(p) => assert_eq!(p.instruction, "line1\nline2"),
541 other => panic!("expected spawn, got {other:?}"),
542 }
543 }
544
545 #[test]
546 fn agent_messages_tag_correctly() {
547 let result = AgentMsg::Result {
548 outcome: Outcome {
549 status: TerminalStatus::Completed,
550 partial: false,
551 result: json!("done"),
552 scheduled: Vec::new(),
553 subscriptions: Vec::new(),
554 },
555 };
556 let s = serde_json::to_string(&result).unwrap();
557 assert!(s.contains("\"type\":\"result\""));
558 assert!(s.contains("\"status\":\"completed\""));
559
560 let pong = serde_json::to_string(&AgentMsg::Pong { seq: 7 }).unwrap();
561 assert!(pong.contains("\"type\":\"pong\""));
562 assert!(pong.contains("\"seq\":7"));
563 }
564
565 #[test]
566 fn control_ping_cancel_tags() {
567 assert!(
568 serde_json::to_string(&ControlMsg::Ping { seq: 1 })
569 .unwrap()
570 .contains("\"type\":\"ping\"")
571 );
572 assert!(
573 serde_json::to_string(&ControlMsg::Cancel {
574 reason: "drain".into()
575 })
576 .unwrap()
577 .contains("\"type\":\"cancel\"")
578 );
579 }
580
581 #[test]
582 fn control_swap_intel_roundtrip_and_policy_default() {
583 // RFC 0018 §5.2: the swap frame carries the new list/model/policy. The
584 // token rides the wire (like Spawn) but is never logged — the resource
585 // body / events carry transport+index only.
586 let swap = ControlMsg::SwapIntel(Box::new(SwapIntel {
587 uri: "https://gw-a.example,https://gw-b.example".into(),
588 token: Some("rotated-secret".into()),
589 model: Some("claude-haiku-4".into()),
590 policy: SwapPolicy::RestartTurn,
591 }));
592 let s = serde_json::to_string(&swap).unwrap();
593 assert!(s.contains("\"type\":\"swap_intel\""));
594 assert!(s.contains("\"policy\":\"restart-turn\""));
595 let back: ControlMsg = serde_json::from_str(&s).unwrap();
596 match back {
597 ControlMsg::SwapIntel(p) => {
598 assert_eq!(p.uri, "https://gw-a.example,https://gw-b.example");
599 assert_eq!(p.model.as_deref(), Some("claude-haiku-4"));
600 assert_eq!(p.policy, SwapPolicy::RestartTurn);
601 }
602 other => panic!("expected swap_intel, got {other:?}"),
603 }
604 // A frame with no model/token defaults to finish-on-old (an endpoint
605 // repoint with no model change).
606 let minimal: SwapIntel = serde_json::from_str(r#"{"uri":"https://a.example"}"#).unwrap();
607 assert_eq!(minimal.policy, SwapPolicy::FinishOnOld);
608 assert!(minimal.model.is_none() && minimal.token.is_none());
609 }
610
611 #[test]
612 fn intel_health_roundtrips_and_carries_no_url_or_secret() {
613 // The child→supervisor reachability report (RFC 0018 §6): tagged like the
614 // other AgentMsgs, edge-triggered, transport+index ONLY (never a URL/cred).
615 let down = AgentMsg::IntelHealth {
616 all_down: true,
617 active: None,
618 };
619 let s = serde_json::to_string(&down).unwrap();
620 assert!(s.contains("\"type\":\"intel_health\""));
621 assert!(s.contains("\"all_down\":true"));
622 // `active` is omitted when absent (the all-down report has no serving ep).
623 assert!(!s.contains("active"));
624 let back: AgentMsg = serde_json::from_str(&s).unwrap();
625 assert!(matches!(
626 back,
627 AgentMsg::IntelHealth {
628 all_down: true,
629 active: None
630 }
631 ));
632
633 // The recovered report carries the best-effort active transport+index.
634 let up = AgentMsg::IntelHealth {
635 all_down: false,
636 active: Some(IntelActive {
637 index: 1,
638 transport: "https".into(),
639 }),
640 };
641 let s = serde_json::to_string(&up).unwrap();
642 assert!(s.contains("\"all_down\":false"));
643 assert!(s.contains("\"index\":1"));
644 assert!(s.contains("\"transport\":\"https\""));
645 // RFC 0012 §3.7: the structural transport scheme only — no scheme-borne
646 // address/cid/host/credential rides this message.
647 assert!(!s.contains("https://"), "no full URI in the report: {s}");
648 let back: AgentMsg = serde_json::from_str(&s).unwrap();
649 match back {
650 AgentMsg::IntelHealth { all_down, active } => {
651 assert!(!all_down);
652 let a = active.unwrap();
653 assert_eq!(a.index, 1);
654 assert_eq!(a.transport, "https");
655 }
656 other => panic!("expected intel_health, got {other:?}"),
657 }
658 }
659
660 #[test]
661 fn narrow_tools_mints_one_supervisor_grant_and_survives_the_wire() {
662 // RFC 0009: `subagent.run`'s `tools:` is a GRANT the child enforces. It
663 // rides the seed under the reserved role, exactly once, minted by the
664 // supervisor — a forged entry a caller smuggled in through `context` is
665 // dropped, so the child can never see two disagreeing grants.
666 let mut p = payload();
667 p.context_seed.insert(
668 0,
669 SeedMessage {
670 role: ALLOWED_TOOLS_ROLE.into(),
671 content: "[\"*\"]".into(),
672 },
673 );
674 p.narrow_tools(&["knowledge.search".to_string()]);
675 assert_eq!(
676 p.context_seed
677 .iter()
678 .filter(|m| m.role == ALLOWED_TOOLS_ROLE)
679 .count(),
680 1,
681 "one grant only — the forged `*` is gone"
682 );
683 assert_eq!(
684 p.allowed_tools(),
685 Some(vec!["knowledge.search".to_string()])
686 );
687 // The real seed messages are untouched by the mint.
688 assert!(p.context_seed.iter().any(|m| m.content == "prior note"));
689
690 // It survives the control frame (a restore re-spawns from this payload).
691 let msg = ControlMsg::Spawn(Box::new(p));
692 let mut buf = Vec::new();
693 frame::write_frame(&mut buf, &msg).unwrap();
694 let back: ControlMsg =
695 serde_json::from_slice(&frame::read_frame(&mut Cursor::new(buf)).unwrap().unwrap())
696 .unwrap();
697 match back {
698 ControlMsg::Spawn(p) => assert_eq!(
699 p.allowed_tools(),
700 Some(vec!["knowledge.search".to_string()])
701 ),
702 other => panic!("expected spawn, got {other:?}"),
703 }
704 }
705
706 #[test]
707 fn an_unnarrowed_payload_has_no_grant_and_a_broken_one_grants_nothing() {
708 // No marker = no narrowing (the root/embedded shape) — the child keeps
709 // the full catalogue its granted servers publish.
710 assert_eq!(payload().allowed_tools(), None);
711 // A body that will not parse is NOT read as "everything": fail closed.
712 assert!(parse_allowed_tools("not json").is_empty());
713 assert!(parse_allowed_tools("[\"a\",\"b.*\"]").len() == 2);
714 }
715
716 #[test]
717 fn control_pause_resume_roundtrip() {
718 // No-param, serde-tagged like Ready/Pong (RFC 0005 §4.3 / RFC 0015 §4.3).
719 let pause = serde_json::to_string(&ControlMsg::Pause).unwrap();
720 assert_eq!(pause, "{\"type\":\"pause\"}");
721 let resume = serde_json::to_string(&ControlMsg::Resume).unwrap();
722 assert_eq!(resume, "{\"type\":\"resume\"}");
723 assert!(matches!(
724 serde_json::from_str::<ControlMsg>(&pause).unwrap(),
725 ControlMsg::Pause
726 ));
727 assert!(matches!(
728 serde_json::from_str::<ControlMsg>(&resume).unwrap(),
729 ControlMsg::Resume
730 ));
731 }
732}