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}