Skip to main content

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}