Skip to main content

agentd/subagent/
protocol.rs

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