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 /// OS-level caps, applied between fork and exec (`setrlimit`) — real
457 /// resource allocation, not protocol accounting. `None` = inherit.
458 /// `memory_bytes` → `RLIMIT_AS`; `cpu_seconds` → `RLIMIT_CPU` (the kernel
459 /// sends SIGXCPU at the soft cap, SIGKILL at hard = soft + 5 s).
460 #[serde(default, skip_serializing_if = "Option::is_none")]
461 pub memory_bytes: Option<u64>,
462 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub cpu_seconds: Option<u64>,
464 /// Niceness delta from `priority:` (`low` → +10, `high` → −5 best-effort —
465 /// raising needs privilege and is skipped silently without it).
466 #[serde(default, skip_serializing_if = "Option::is_none")]
467 pub nice: Option<i32>,
468}
469
470/// The correlation block stamped into the child's logs (RFC 0010
471/// §tree-correlation).
472#[derive(Debug, Clone, Serialize, Deserialize)]
473pub struct Telemetry {
474 pub run_id: String,
475 pub agent_id: String,
476 pub agent_path: String,
477 #[serde(default, skip_serializing_if = "Option::is_none")]
478 pub trace_id: Option<String>,
479 pub log_level: String,
480 /// Content-capture policy (RFC 0010 §2.9): when true the child logs tool
481 /// args/results, not just lengths. Inherited from the parent's `--log-content`.
482 #[serde(default)]
483 pub log_content: bool,
484}
485
486#[cfg(test)]
487mod tests {
488 use super::*;
489 use crate::agentloop::stop::TerminalStatus;
490 use crate::json::frame;
491 use serde_json::json;
492 use std::io::Cursor;
493
494 fn payload() -> SpawnPayload {
495 SpawnPayload {
496 instruction: "summarize the file".into(),
497 output_contract: Some("Return a 3-bullet summary.".into()),
498 context_seed: vec![SeedMessage {
499 role: "user".into(),
500 content: "prior note".into(),
501 }],
502 intelligence: IntelConfig {
503 uri: "https://intel.example".into(),
504 token: Some("secret".into()),
505 model: Some("m".into()),
506 headers: Vec::new(),
507 aws_auth: None,
508 dialect: None,
509 },
510 mcp_servers: vec![McpServerSpec {
511 name: "fs".into(),
512 endpoint: "unix:/mcp-fs.sock".into(),
513 tags: Vec::new(),
514 ..Default::default()
515 }],
516 a2a_peers: Vec::new(),
517 tls_ca: None,
518 aauth: None,
519 limits: Limits {
520 max_steps: 20,
521 max_tokens: 100_000,
522 deadline_ms: 600_000,
523 max_depth: 4,
524 memory_bytes: None,
525 cpu_seconds: None,
526 nice: None,
527 },
528 telemetry: Telemetry {
529 run_id: "r1".into(),
530 agent_id: "0.1".into(),
531 agent_path: "0.1".into(),
532 trace_id: None,
533 log_level: "info".into(),
534 log_content: false,
535 },
536 depth: 1,
537 warm: false,
538 role: crate::subagent::protocol::Role::Agent,
539 turn: None,
540 }
541 }
542
543 #[test]
544 fn control_spawn_frames_roundtrip() {
545 // The whole point of length-framing: an instruction with newlines.
546 let mut p = payload();
547 p.instruction = "line1\nline2".into();
548 let msg = ControlMsg::Spawn(Box::new(p));
549 let mut buf = Vec::new();
550 frame::write_frame(&mut buf, &msg).unwrap();
551 let mut cur = Cursor::new(buf);
552 let bytes = frame::read_frame(&mut cur).unwrap().unwrap();
553 let back: ControlMsg = serde_json::from_slice(&bytes).unwrap();
554 match back {
555 ControlMsg::Spawn(p) => assert_eq!(p.instruction, "line1\nline2"),
556 other => panic!("expected spawn, got {other:?}"),
557 }
558 }
559
560 #[test]
561 fn agent_messages_tag_correctly() {
562 let result = AgentMsg::Result {
563 outcome: Outcome {
564 status: TerminalStatus::Completed,
565 partial: false,
566 result: json!("done"),
567 scheduled: Vec::new(),
568 subscriptions: Vec::new(),
569 },
570 };
571 let s = serde_json::to_string(&result).unwrap();
572 assert!(s.contains("\"type\":\"result\""));
573 assert!(s.contains("\"status\":\"completed\""));
574
575 let pong = serde_json::to_string(&AgentMsg::Pong { seq: 7 }).unwrap();
576 assert!(pong.contains("\"type\":\"pong\""));
577 assert!(pong.contains("\"seq\":7"));
578 }
579
580 #[test]
581 fn control_ping_cancel_tags() {
582 assert!(
583 serde_json::to_string(&ControlMsg::Ping { seq: 1 })
584 .unwrap()
585 .contains("\"type\":\"ping\"")
586 );
587 assert!(
588 serde_json::to_string(&ControlMsg::Cancel {
589 reason: "drain".into()
590 })
591 .unwrap()
592 .contains("\"type\":\"cancel\"")
593 );
594 }
595
596 #[test]
597 fn control_swap_intel_roundtrip_and_policy_default() {
598 // RFC 0018 §5.2: the swap frame carries the new list/model/policy. The
599 // token rides the wire (like Spawn) but is never logged — the resource
600 // body / events carry transport+index only.
601 let swap = ControlMsg::SwapIntel(Box::new(SwapIntel {
602 uri: "https://gw-a.example,https://gw-b.example".into(),
603 token: Some("rotated-secret".into()),
604 model: Some("claude-haiku-4".into()),
605 policy: SwapPolicy::RestartTurn,
606 }));
607 let s = serde_json::to_string(&swap).unwrap();
608 assert!(s.contains("\"type\":\"swap_intel\""));
609 assert!(s.contains("\"policy\":\"restart-turn\""));
610 let back: ControlMsg = serde_json::from_str(&s).unwrap();
611 match back {
612 ControlMsg::SwapIntel(p) => {
613 assert_eq!(p.uri, "https://gw-a.example,https://gw-b.example");
614 assert_eq!(p.model.as_deref(), Some("claude-haiku-4"));
615 assert_eq!(p.policy, SwapPolicy::RestartTurn);
616 }
617 other => panic!("expected swap_intel, got {other:?}"),
618 }
619 // A frame with no model/token defaults to finish-on-old (an endpoint
620 // repoint with no model change).
621 let minimal: SwapIntel = serde_json::from_str(r#"{"uri":"https://a.example"}"#).unwrap();
622 assert_eq!(minimal.policy, SwapPolicy::FinishOnOld);
623 assert!(minimal.model.is_none() && minimal.token.is_none());
624 }
625
626 #[test]
627 fn intel_health_roundtrips_and_carries_no_url_or_secret() {
628 // The child→supervisor reachability report (RFC 0018 §6): tagged like the
629 // other AgentMsgs, edge-triggered, transport+index ONLY (never a URL/cred).
630 let down = AgentMsg::IntelHealth {
631 all_down: true,
632 active: None,
633 };
634 let s = serde_json::to_string(&down).unwrap();
635 assert!(s.contains("\"type\":\"intel_health\""));
636 assert!(s.contains("\"all_down\":true"));
637 // `active` is omitted when absent (the all-down report has no serving ep).
638 assert!(!s.contains("active"));
639 let back: AgentMsg = serde_json::from_str(&s).unwrap();
640 assert!(matches!(
641 back,
642 AgentMsg::IntelHealth {
643 all_down: true,
644 active: None
645 }
646 ));
647
648 // The recovered report carries the best-effort active transport+index.
649 let up = AgentMsg::IntelHealth {
650 all_down: false,
651 active: Some(IntelActive {
652 index: 1,
653 transport: "https".into(),
654 }),
655 };
656 let s = serde_json::to_string(&up).unwrap();
657 assert!(s.contains("\"all_down\":false"));
658 assert!(s.contains("\"index\":1"));
659 assert!(s.contains("\"transport\":\"https\""));
660 // RFC 0012 §3.7: the structural transport scheme only — no scheme-borne
661 // address/cid/host/credential rides this message.
662 assert!(!s.contains("https://"), "no full URI in the report: {s}");
663 let back: AgentMsg = serde_json::from_str(&s).unwrap();
664 match back {
665 AgentMsg::IntelHealth { all_down, active } => {
666 assert!(!all_down);
667 let a = active.unwrap();
668 assert_eq!(a.index, 1);
669 assert_eq!(a.transport, "https");
670 }
671 other => panic!("expected intel_health, got {other:?}"),
672 }
673 }
674
675 #[test]
676 fn narrow_tools_mints_one_supervisor_grant_and_survives_the_wire() {
677 // RFC 0009: `subagent.run`'s `tools:` is a GRANT the child enforces. It
678 // rides the seed under the reserved role, exactly once, minted by the
679 // supervisor — a forged entry a caller smuggled in through `context` is
680 // dropped, so the child can never see two disagreeing grants.
681 let mut p = payload();
682 p.context_seed.insert(
683 0,
684 SeedMessage {
685 role: ALLOWED_TOOLS_ROLE.into(),
686 content: "[\"*\"]".into(),
687 },
688 );
689 p.narrow_tools(&["knowledge.search".to_string()]);
690 assert_eq!(
691 p.context_seed
692 .iter()
693 .filter(|m| m.role == ALLOWED_TOOLS_ROLE)
694 .count(),
695 1,
696 "one grant only — the forged `*` is gone"
697 );
698 assert_eq!(
699 p.allowed_tools(),
700 Some(vec!["knowledge.search".to_string()])
701 );
702 // The real seed messages are untouched by the mint.
703 assert!(p.context_seed.iter().any(|m| m.content == "prior note"));
704
705 // It survives the control frame (a restore re-spawns from this payload).
706 let msg = ControlMsg::Spawn(Box::new(p));
707 let mut buf = Vec::new();
708 frame::write_frame(&mut buf, &msg).unwrap();
709 let back: ControlMsg =
710 serde_json::from_slice(&frame::read_frame(&mut Cursor::new(buf)).unwrap().unwrap())
711 .unwrap();
712 match back {
713 ControlMsg::Spawn(p) => assert_eq!(
714 p.allowed_tools(),
715 Some(vec!["knowledge.search".to_string()])
716 ),
717 other => panic!("expected spawn, got {other:?}"),
718 }
719 }
720
721 #[test]
722 fn an_unnarrowed_payload_has_no_grant_and_a_broken_one_grants_nothing() {
723 // No marker = no narrowing (the root/embedded shape) — the child keeps
724 // the full catalogue its granted servers publish.
725 assert_eq!(payload().allowed_tools(), None);
726 // A body that will not parse is NOT read as "everything": fail closed.
727 assert!(parse_allowed_tools("not json").is_empty());
728 assert!(parse_allowed_tools("[\"a\",\"b.*\"]").len() == 2);
729 }
730
731 #[test]
732 fn control_pause_resume_roundtrip() {
733 // No-param, serde-tagged like Ready/Pong (RFC 0005 §4.3 / RFC 0015 §4.3).
734 let pause = serde_json::to_string(&ControlMsg::Pause).unwrap();
735 assert_eq!(pause, "{\"type\":\"pause\"}");
736 let resume = serde_json::to_string(&ControlMsg::Resume).unwrap();
737 assert_eq!(resume, "{\"type\":\"resume\"}");
738 assert!(matches!(
739 serde_json::from_str::<ControlMsg>(&pause).unwrap(),
740 ControlMsg::Pause
741 ));
742 assert!(matches!(
743 serde_json::from_str::<ControlMsg>(&resume).unwrap(),
744 ControlMsg::Resume
745 ));
746 }
747}