Skip to main content

agentd/runtime/
subagents.rs

1// SPDX-License-Identifier: AGPL-3.0-only
2//! The **subagent registry** behind the `subagent.*` tools: flat
3//! children spawned from the one chokepoint (caps: depth/breadth/total/rate),
4//! recorded durably as `subagent/<handle>` (payload, mode, status, result),
5//! with `sync` (the caller waits), `async` (a handle; `subagent.await`),
6//! `detached` (fire and forget) and `warm` (stays alive; `subagent.send`).
7
8use super::children::ChildKind;
9use super::reactor::{PendingKind, Runtime, SubagentRecord, is_terminal_status};
10use super::tools::{ToolCaller, ToolOutcome};
11use crate::agentloop::stop::Outcome;
12use crate::context::Msg;
13use crate::state::now_ms;
14use crate::subagent::protocol::{
15    ControlMsg, IntelConfig, Limits, Role, SeedMessage, SpawnPayload, Telemetry,
16};
17use crate::supervisor::tree::{NodeId, TokenBucket};
18use serde_json::{Value, json};
19use std::time::Duration;
20
21/// Distillation cap for a subagent result carried back to a caller.
22const DISTILL_CAP: usize = 8_000;
23
24impl Runtime {
25    /// The `subagent.*` built-ins.
26    pub(crate) fn subagent_tool(
27        &mut self,
28        caller: &ToolCaller,
29        name: &str,
30        args: Value,
31    ) -> ToolOutcome {
32        let err = |e: String| ToolOutcome::Ready(Value::String(e), true);
33        match name {
34            "subagent.run" => self.subagent_run(caller, &args),
35            "subagent.send" => {
36                let handle = args["handle"].as_str().unwrap_or("").to_string();
37                let message = args["message"].as_str().unwrap_or("").to_string();
38                // An instance-tier child is a separate agent, not a worker in
39                // this process's tree, so it receives over its A2A socket and
40                // the message lands in its own conversation surface.
41                if self
42                    .subagents
43                    .get(&handle)
44                    .is_some_and(|s| s.tier.as_deref() == Some("instance"))
45                {
46                    return self.instance_send(&handle, &message);
47                }
48                let Some(node) = self.subagents.get(&handle).and_then(|s| s.node) else {
49                    return err(format!("subagent {handle:?} is not running"));
50                };
51                if !self
52                    .subagents
53                    .get(&handle)
54                    .is_some_and(|s| s.mode == "warm")
55                {
56                    return err(format!("subagent {handle:?} is not a warm subagent"));
57                }
58                if self.children.send(node, &ControlMsg::Inject { message }) {
59                    ToolOutcome::Ready(json!({"ok": true, "handle": handle}), false)
60                } else {
61                    err(format!("subagent {handle:?}: send failed"))
62                }
63            }
64            "subagent.retire" => {
65                // Begin graceful retirement of an instance child: SIGTERM, then
66                // the child drains its own runs and exits cleanly.
67                let handle = args["handle"].as_str().unwrap_or("").to_string();
68                match self.subagents.get(&handle) {
69                    None => err(format!("no such subagent {handle:?}")),
70                    Some(s) if s.tier.as_deref() != Some("instance") => err(format!(
71                        "subagent {handle:?} is not an instance-tier child (use subagent.kill for flat workers)"
72                    )),
73                    Some(_) => {
74                        if self.retire_instance(&handle, "subagent.retire") {
75                            ToolOutcome::Ready(
76                                json!({"ok": true, "handle": handle, "status": "retiring"}),
77                                false,
78                            )
79                        } else {
80                            err(format!(
81                                "subagent {handle:?} is not retirable (already terminal?)"
82                            ))
83                        }
84                    }
85                }
86            }
87            "subagent.kill" => {
88                let handle = args["handle"].as_str().unwrap_or("").to_string();
89                let reason = args
90                    .get("reason")
91                    .and_then(Value::as_str)
92                    .unwrap_or("killed by request")
93                    .to_string();
94                let Some(node) = self.subagents.get(&handle).and_then(|s| s.node) else {
95                    return err(format!("subagent {handle:?} is not running"));
96                };
97                self.children.cancel(node, &reason);
98                if let Some(s) = self.subagents.get_mut(&handle) {
99                    s.status = "cancelled".into();
100                    s.error = Some(reason);
101                    s.updated = now_ms();
102                    s.dirty = true;
103                }
104                self.log.info("subagent.kill", json!({"handle": handle}));
105                ToolOutcome::Ready(json!({"ok": true, "handle": handle}), false)
106            }
107            "subagent.status" => {
108                let handle = args["handle"].as_str().unwrap_or("").to_string();
109                match self.subagents.get(&handle) {
110                    Some(s) => ToolOutcome::Ready(
111                        json!({"handle": handle, "status": s.status, "mode": s.mode, "result": s.result, "error": s.error, "tokens": s.tokens}),
112                        false,
113                    ),
114                    None => err(format!("no such subagent {handle:?}")),
115                }
116            }
117            "subagent.await" => {
118                let handle = args["handle"].as_str().unwrap_or("").to_string();
119                match self.subagents.get(&handle) {
120                    None => err(format!("no such subagent {handle:?}")),
121                    Some(s) if is_terminal_status(&s.status) => ToolOutcome::Ready(
122                        json!({"handle": handle, "status": s.status, "result": s.result, "error": s.error}),
123                        false,
124                    ),
125                    Some(_) => ToolOutcome::Deferred(PendingKind::Subagent { handle }),
126                }
127            }
128            "subagent.list" => ToolOutcome::Ready(
129                json!({"subagents": self.subagents.values().map(|s| json!({"handle": s.handle, "mode": s.mode, "status": s.status, "instruction": s.instruction.chars().take(80).collect::<String>(), "created": s.created})).collect::<Vec<_>>()}),
130                false,
131            ),
132            _ => err(format!("unknown subagent tool {name}")),
133        }
134    }
135
136    /// `subagent.run`: caps → durable record → spawn, in that order, so a
137    /// child that survives a crash always has a record to be reconciled
138    /// against. A `template:` reference resolves first — a flat template merges
139    /// into the args below; an instance template takes the daemon-child path.
140    fn subagent_run(&mut self, caller: &ToolCaller, args: &Value) -> ToolOutcome {
141        let err = |e: String| ToolOutcome::Ready(Value::String(e), true);
142        let (eff_args, tmeta) = match self.resolve_spawn_args(args) {
143            Ok(x) => x,
144            Err(e) => return err(e),
145        };
146        if let Some((tname, tier)) = &tmeta
147            && tier == "instance"
148        {
149            let tname = tname.clone();
150            return self.instance_run(caller, &tname, &eff_args);
151        }
152        let args = &eff_args;
153        let instruction = args["instruction"]
154            .as_str()
155            .unwrap_or("")
156            .trim()
157            .to_string();
158        if instruction.is_empty() {
159            return err("subagent.run: instruction must be non-empty".into());
160        }
161        let mode = args
162            .get("mode")
163            .and_then(Value::as_str)
164            .unwrap_or("sync")
165            .to_string();
166        if !matches!(mode.as_str(), "sync" | "async" | "detached" | "warm") {
167            return err("subagent.run: mode must be sync|async|detached|warm".into());
168        }
169        // Caps: breadth (live), total (lifetime), rate, depth. A subagent
170        // asking for a subagent goes through this same chokepoint, so the
171        // logical depth is the requester's depth + 1 and no branch of the tree
172        // can grow past the configured limits by recursing.
173        let live = self
174            .subagents
175            .values()
176            .filter(|s| !is_terminal_status(&s.status))
177            .count() as u32;
178        let breadth = self.settings.limits.subagents.breadth.unwrap_or(8);
179        if live >= breadth {
180            return err(format!(
181                "subagent.run refused: {live} subagents live (limits.subagents.breadth = {breadth})"
182            ));
183        }
184        let total = self.settings.limits.subagents.total.unwrap_or(64) as usize;
185        if self.subagents.len() >= total {
186            return err(format!(
187                "subagent.run refused: {} subagents spawned (limits.subagents.total = {total})",
188                self.subagents.len()
189            ));
190        }
191        let depth = caller
192            .subagent
193            .as_ref()
194            .and_then(|h| self.subagents.get(h))
195            .map(|s| {
196                s.requested_by
197                    .as_ref()
198                    .and_then(|r| r["depth"].as_u64())
199                    .unwrap_or(0) as u32
200                    + 1
201            })
202            .unwrap_or(0);
203        let max_depth = self.settings.limits.subagents.depth.unwrap_or(3);
204        if depth >= max_depth {
205            return err(format!(
206                "subagent.run refused: delegation depth {depth} reaches limits.subagents.depth = {max_depth}"
207            ));
208        }
209        if !self.spawn_bucket_take() {
210            return err("subagent.run refused: spawn rate exceeded (limits.subagents.rate)".into());
211        }
212        if self.pressure.shedding() {
213            return err(format!(
214                "subagent.run refused: {} pressure (shedding new work; in-flight work drains)",
215                self.pressure.cause()
216            ));
217        }
218        // Tools / servers narrowing.
219        let allow: Option<Vec<String>> = args.get("tools").and_then(Value::as_array).map(|a| {
220            a.iter()
221                .filter_map(Value::as_str)
222                .map(str::to_string)
223                .collect()
224        });
225        let servers: Vec<String> = match args.get("servers").and_then(Value::as_array) {
226            Some(a) => a
227                .iter()
228                .filter_map(Value::as_str)
229                .filter(|s| self.mcp_specs.contains_key(*s))
230                .map(str::to_string)
231                .collect(),
232            None => self.mcp_specs.keys().cloned().collect(),
233        };
234        // The trifecta gate over the narrowed grant.
235        let tags: Vec<crate::sec::scope::TrifectaTag> = servers
236            .iter()
237            .filter_map(|s| self.mcp_specs.get(s))
238            .flat_map(|s| s.tags.iter().copied())
239            .collect();
240        if crate::sec::scope::check_trifecta(
241            tags.iter().copied(),
242            self.settings.security.allow_trifecta,
243        )
244        .is_refused()
245        {
246            return err("subagent.run refused: the requested MCP servers form a lethal trifecta (untrusted input + sensitive + egress); set security.allow_trifecta to override".into());
247        }
248        let handle = self.next_id("sub");
249        let limits = args.get("limits").cloned().unwrap_or(json!({}));
250        let steps = limits
251            .get("steps")
252            .and_then(Value::as_u64)
253            .map(|s| s as u32)
254            .unwrap_or(self.settings.limits.run.steps());
255        let tokens = limits
256            .get("tokens")
257            .and_then(Value::as_u64)
258            .unwrap_or(self.settings.limits.run.tokens());
259        let deadline_ms = limits
260            .get("deadline")
261            .and_then(Value::as_str)
262            .and_then(|d| crate::config::parse_duration(d).ok())
263            .map(|d| d.as_millis() as u64)
264            .unwrap_or(self.settings.limits.run.deadline().as_millis() as u64);
265        // OS-level allocation (RLIMIT_AS / RLIMIT_CPU, applied fork→exec) and
266        // the priority→niceness mapping. Parse errors refuse the spawn — a cap
267        // that silently failed to parse is a cap that silently does not exist.
268        let memory_bytes = match limits.get("memory").and_then(Value::as_str) {
269            None => None,
270            Some(m) => match crate::runtime::pressure::parse_bytes(m) {
271                Ok(b) => Some(b),
272                Err(e) => return err(format!("subagent.run: limits.memory: {e}")),
273            },
274        };
275        let cpu_seconds = match limits.get("cpu").and_then(Value::as_str) {
276            None => None,
277            Some(c) => match crate::config::parse_duration(c) {
278                Ok(d) => Some(d.as_secs().max(1)),
279                Err(e) => return err(format!("subagent.run: limits.cpu: {e}")),
280            },
281        };
282        let priority = match crate::engine::model::Priority::from_spec(args.get("priority")) {
283            Ok(p) => p,
284            Err(e) => return err(format!("subagent.run: {e}")),
285        };
286        if let Some(cause) = self
287            .pressure
288            .refusal(priority == crate::engine::model::Priority::Low)
289        {
290            return err(format!("subagent.run refused: {cause}"));
291        }
292        let context_seed: Vec<SeedMessage> = args
293            .get("context")
294            .and_then(Value::as_array)
295            .map(|a| {
296                a.iter()
297                    .filter_map(|m| {
298                        let role = m["role"].as_str()?;
299                        // The tool grant is minted below from `tools:` alone —
300                        // a caller cannot smuggle one in through `context`
301                        // (a forged one could only narrow its own child, but the
302                        // supervisor owns the grant, so there is one source).
303                        if role == crate::subagent::protocol::ALLOWED_TOOLS_ROLE {
304                            return None;
305                        }
306                        Some(SeedMessage {
307                            role: role.to_string(),
308                            content: m["content"].as_str()?.to_string(),
309                        })
310                    })
311                    .collect()
312            })
313            .unwrap_or_default();
314        let output_contract = args
315            .get("output_contract")
316            .and_then(Value::as_str)
317            .map(str::to_string)
318            .or_else(|| {
319                args.get("output_schema").map(|s| {
320                    format!("Reply with ONLY one JSON object matching this JSON Schema: {s}")
321                })
322            });
323        // Which of this child's tools a policy rule might touch. It calls its
324        // MCP servers directly, so anything named here has to come back to the
325        // supervisor or the rule silently never applies to the caller an
326        // operator is most likely narrowing.
327        let gated_tools: Vec<String> = if self.settings.security.policies.is_empty() {
328            Vec::new()
329        } else {
330            self.registry
331                .defs_for(&crate::registry::Caller::Subagent { allow: None }, None)
332                .iter()
333                .map(|d| d.name.clone())
334                .filter(|n| {
335                    crate::sec::policy::could_apply(
336                        &self.settings.security.policies,
337                        n,
338                        &self.registry.tags_of(std::slice::from_ref(n)),
339                        crate::config::v2::PolicyCaller::Subagent,
340                    )
341                })
342                .collect()
343        };
344        let mut payload = SpawnPayload {
345            instruction: instruction.clone(),
346            output_contract,
347            context_seed,
348            gated_tools,
349            intelligence: IntelConfig {
350                uri: self.intel_uri.clone(),
351                token: self.current_intel_bearer(),
352                // An explicit `model:` (from the call, the template or the
353                // spawn defaults) overrides the parent's model; otherwise the
354                // child inherits it.
355                // Resolved through the tier catalogue like every other model
356                // reference, so a caller naming a TIER does not leak that name
357                // to the provider as if it were a model.
358                model: args
359                    .get("model")
360                    .and_then(Value::as_str)
361                    .map(|m| self.settings.intelligence.wire_model(m))
362                    .or_else(|| Some(self.model.clone())),
363                headers: self.intel_headers.clone(),
364                aws_auth: self.intel_aws_auth(),
365                dialect: self.intel_dialect(),
366            },
367            mcp_servers: servers
368                .iter()
369                .filter_map(|s| self.mcp_specs.get(s).cloned())
370                .collect(),
371            a2a_peers: Vec::new(),
372            tls_ca: self.settings.security.tls_ca.clone(),
373            aauth: None,
374            limits: Limits {
375                max_steps: steps,
376                max_tokens: tokens,
377                deadline_ms: deadline_ms.max(1000),
378                max_depth: max_depth.saturating_sub(depth + 1),
379                memory_bytes,
380                cpu_seconds,
381                nice: priority.nice(),
382            },
383            telemetry: Telemetry {
384                run_id: self.run_id.clone(),
385                agent_id: handle.clone(),
386                agent_path: format!("sub/{handle}"),
387                trace_id: self.trace_id.clone(),
388                log_level: self
389                    .settings
390                    .observability
391                    .log_level
392                    .clone()
393                    .unwrap_or_else(|| "info".into()),
394                log_content: self.settings.observability.log_content,
395            },
396            depth: depth + 1,
397            warm: mode == "warm",
398            role: Role::Agent,
399            turn: None,
400        };
401        // The `tools:` narrowing is a GRANT, not a note: minted into the payload
402        // here and ENFORCED by the child, which filters both its catalogue and
403        // its dispatch against it (`agentloop::runner::Session::prepare`). Without
404        // the mint the argument would be recorded and ignored, and a parent
405        // bounding an untrusted sub-task would silently get a child holding
406        // everything, when a child's authority must only ever narrow.
407        if let Some(a) = &allow {
408            payload.narrow_tools(a);
409        }
410        // A durable record BEFORE the spawn (restore re-spawns pending ones).
411        let mut record = SubagentRecord {
412            handle: handle.clone(),
413            instruction: instruction.clone(),
414            mode: mode.clone(),
415            status: "spawned".into(),
416            attempt: 1,
417            result: None,
418            error: None,
419            requested_by: Some(
420                json!({"caller": caller.node.map(|n| n.0), "ctx": caller.ctx, "run": caller.run, "step": caller.step, "subagent": caller.subagent, "depth": depth}),
421            ),
422            tokens: 0,
423            created: now_ms(),
424            updated: now_ms(),
425            payload: Some(secret_free_payload(&payload)),
426            template: tmeta.as_ref().map(|(n, _)| n.clone()),
427            tier: tmeta.as_ref().map(|(_, t)| t.clone()),
428            pid: None,
429            config_path: None,
430            socket: None,
431            retire_at: None,
432            retiring_since: None,
433            durable: args
434                .get("durable")
435                .and_then(Value::as_bool)
436                .unwrap_or_else(|| self.work_durable_default()),
437            node: None,
438            dirty: true,
439        };
440        match self.children.spawn(
441            &payload,
442            ChildKind::Subagent {
443                handle: handle.clone(),
444            },
445            Duration::from_millis(deadline_ms),
446        ) {
447            Ok(node) => {
448                record.node = Some(node);
449                record.status = "running".into();
450                self.log.info("subagent.spawn", json!({"handle": handle, "mode": mode, "node": node.0, "pid": self.children.pid_of(node), "depth": depth + 1, "servers": servers.len(), "priority": priority.as_str(), "memory_bytes": memory_bytes, "cpu_seconds": cpu_seconds}));
451                let persist = record.durable;
452                self.subagents.insert(handle.clone(), record);
453                if persist {
454                    let _ = self.durable.put(
455                        crate::state::Kind::Subagent,
456                        &handle,
457                        serde_json::to_value(self.subagents.get(&handle).unwrap())
458                            .unwrap_or(Value::Null),
459                        None,
460                    );
461                }
462                if let Some(s) = self.subagents.get_mut(&handle) {
463                    s.dirty = false;
464                }
465                match mode.as_str() {
466                    "sync" => ToolOutcome::Deferred(PendingKind::Subagent { handle }),
467                    _ => ToolOutcome::Ready(json!({"handle": handle, "status": "running"}), false),
468                }
469            }
470            Err(e) => {
471                record.status = "failed".into();
472                record.error = Some(format!("spawn: {e}"));
473                self.subagents.insert(handle.clone(), record);
474                err(format!("subagent.run: spawn failed: {e}"))
475            }
476        }
477    }
478
479    /// Resolve `template:`/`params:` into effective spawn args.
480    /// Returns the args plus `(template, tier)` when a template was named.
481    /// Freeform spawns pass through with defaults applied — unless
482    /// `subagents.allow_freeform: false` refuses them.
483    fn resolve_spawn_args(
484        &self,
485        args: &Value,
486    ) -> Result<(Value, Option<(String, String)>), String> {
487        use crate::config::templates as tpl;
488        let defaults = self.settings.subagents.defaults.clone();
489        let mut eff = args.clone();
490        let Some(o) = eff.as_object_mut() else {
491            return Err("subagent.run: args must be an object".into());
492        };
493        let tname = o
494            .get("template")
495            .and_then(Value::as_str)
496            .map(str::to_string);
497        let Some(tname) = tname else {
498            if self.settings.subagents.allow_freeform == Some(false) {
499                return Err(
500                    "subagent.run refused: freeform spawns are disabled (subagents.allow_freeform: false) — instantiate a declared template".into(),
501                );
502            }
503            apply_spawn_defaults(o, &defaults);
504            return Ok((eff, None));
505        };
506        if o.get("instruction")
507            .and_then(Value::as_str)
508            .is_some_and(|s| !s.trim().is_empty())
509        {
510            return Err("subagent.run: `template` and `instruction` are mutually exclusive".into());
511        }
512        for k in ["tools", "servers"] {
513            if o.contains_key(k) {
514                return Err(format!(
515                    "subagent.run: `{k}` may not be passed with `template` — the template defines the grant"
516                ));
517            }
518        }
519        let compiled = tpl::compile_templates(&self.settings)
520            .map_err(|es| format!("subagent.run: template compile: {}", es.join("; ")))?;
521        let Some(t) = compiled.get(&tname) else {
522            return Err(format!(
523                "subagent.run: no template '{tname}' (subagents.templates declares: {:?})",
524                compiled.keys().collect::<Vec<_>>()
525            ));
526        };
527        let params = tpl::validate_params(&t.spec.params, o.get("params").unwrap_or(&Value::Null))
528            .map_err(|e| format!("subagent.run: template '{tname}': {e}"))?;
529        let folded = tpl::fold_params(&t.cleaned, &params);
530        if tpl::params_introduced_machinery(&folded) {
531            return Err(format!(
532                "subagent.run refused: params for template '{tname}' introduced directive machinery"
533            ));
534        }
535        if t.tier == tpl::Tier::Instance {
536            // `instance_run` recompiles and refolds; hand it the validated
537            // params so refusals there are consistent with here.
538            o.insert("params".into(), Value::Object(params));
539            return Ok((eff, Some((tname, "instance".into()))));
540        }
541        // Flat: the template's fields merge under the call site's.
542        let spec = &t.spec;
543        o.insert("instruction".into(), json!(folded));
544        if let Some(v) = &spec.tools {
545            o.insert("tools".into(), json!(v));
546        }
547        if let Some(v) = &spec.servers {
548            o.insert("servers".into(), json!(v));
549        }
550        for (key, val) in [
551            ("limits", spec.limits.clone()),
552            ("skills", spec.skills.clone()),
553            ("output_schema", spec.output_schema.clone()),
554        ] {
555            if !o.contains_key(key)
556                && let Some(v) = val
557            {
558                o.insert(key.into(), v);
559            }
560        }
561        for (key, val) in [
562            ("mode", spec.mode.clone()),
563            ("priority", spec.priority.clone()),
564            ("model", spec.model.clone()),
565            ("output_contract", spec.output_contract.clone()),
566        ] {
567            if !o.contains_key(key)
568                && let Some(v) = val
569            {
570                o.insert(key.into(), json!(v));
571            }
572        }
573        if !o.contains_key("durable")
574            && let Some(v) = spec.durable
575        {
576            o.insert("durable".into(), json!(v));
577        }
578        // Template context seeds first, the call site's appended after.
579        if let Some(tc) = spec.context.as_ref().and_then(Value::as_array) {
580            let mut merged = tc.clone();
581            if let Some(cc) = o.get("context").and_then(Value::as_array) {
582                merged.extend(cc.clone());
583            }
584            o.insert("context".into(), json!(merged));
585        }
586        apply_spawn_defaults(o, &defaults);
587        Ok((eff, Some((tname, "flat".into()))))
588    }
589
590    fn spawn_bucket_take(&mut self) -> bool {
591        // A process-lifetime bucket parsed from `limits.subagents.rate` ("8/2s").
592        static BUCKET: std::sync::Mutex<Option<TokenBucket>> = std::sync::Mutex::new(None);
593        let mut g = BUCKET.lock().unwrap_or_else(|e| e.into_inner());
594        if g.is_none() {
595            let (burst, per_sec) = parse_rate(
596                self.settings
597                    .limits
598                    .subagents
599                    .rate
600                    .as_deref()
601                    .unwrap_or("8/2s"),
602            );
603            *g = Some(TokenBucket::new(burst, per_sec));
604        }
605        g.as_mut().map(|b| b.try_take()).unwrap_or(true)
606    }
607
608    /// A warm subagent finished a turn (non-terminal).
609    pub(crate) fn on_subagent_turn(&mut self, node: NodeId, outcome: Outcome) {
610        let Some(ChildKind::Subagent { handle }) = self.children.get(node).map(|c| c.kind.clone())
611        else {
612            return;
613        };
614        if let Some(s) = self.subagents.get_mut(&handle) {
615            s.result = Some(distill(&outcome.result));
616            s.updated = now_ms();
617            s.dirty = true;
618        }
619        self.log.info(
620            "subagent.turn",
621            json!({"handle": handle, "status": outcome.status.as_str()}),
622        );
623        // Notify the root context (wake policy: subagent_result).
624        self.note_root(format!(
625            "subagent {handle} finished a turn: {}",
626            distill_text(&outcome.result)
627        ));
628    }
629
630    /// A subagent finished (result or failure).
631    pub(crate) fn on_subagent_result(&mut self, node: NodeId, outcome: Result<Outcome, String>) {
632        let Some(ChildKind::Subagent { handle }) = self.children.get(node).map(|c| c.kind.clone())
633        else {
634            return;
635        };
636        let tokens = self.children.get(node).map(|c| c.tokens).unwrap_or(0);
637        let (status, result, error) = match &outcome {
638            Ok(o) => (
639                o.status.as_str().to_string(),
640                Some(distill(&o.result)),
641                None,
642            ),
643            Err(e) => ("failed".to_string(), None, Some(e.clone())),
644        };
645        if let Some(s) = self.subagents.get_mut(&handle) {
646            s.status = status.clone();
647            s.result = result.clone();
648            s.error = error.clone();
649            s.tokens = tokens;
650            s.node = None;
651            s.updated = now_ms();
652            s.dirty = true;
653        }
654        self.log.info(
655            "subagent.result",
656            json!({"handle": handle, "status": status, "tokens": tokens, "err": error}),
657        );
658        // Answer waiters.
659        let waiting: Vec<super::reactor::Target> = self
660            .pending
661            .iter()
662            .filter(|p| matches!(&p.kind, PendingKind::Subagent { handle: h } if *h == handle))
663            .map(|p| p.target.clone())
664            .collect();
665        self.pending
666            .retain(|p| !matches!(&p.kind, PendingKind::Subagent { handle: h } if *h == handle));
667        for t in waiting {
668            self.reply(
669                &t,
670                json!({"handle": handle, "status": status, "result": result, "error": error}),
671                false,
672            );
673        }
674        // Settle whatever plan item this subagent was bound to, then note the
675        // outcome to the root only when the wake policy asks for subagent
676        // results — an unwanted note would wake a root that has nothing to do.
677        let ok = status == "completed";
678        let note = result
679            .as_ref()
680            .map(distill_text)
681            .or(error.clone())
682            .unwrap_or_default();
683        self.settle_plan_bindings(&plan_binding_subagent(&handle), ok, &note);
684        if self
685            .settings
686            .agent
687            .wake_on()
688            .contains(&crate::config::v2::WakeEvent::SubagentResult)
689        {
690            self.note_root(format!("subagent {handle} {status}: {note}"));
691        }
692    }
693
694    /// Append a note to the root context (durable; the next root turn sees it).
695    pub(crate) fn note_root(&mut self, text: String) {
696        let window = self.model_window();
697        let c = self.contexts.root();
698        if c.model_window == 0 {
699            c.model_window = window;
700        }
701        c.append(Msg::note(text));
702    }
703
704    /// Auto-advance plan items bound to a finished run/subagent (every context).
705    pub(crate) fn settle_plan_bindings(
706        &mut self,
707        binding: &crate::context::plan::Binding,
708        ok: bool,
709        note: &str,
710    ) {
711        for id in self.contexts.ids() {
712            if let Some(c) = self.contexts.get_mut(&id)
713                && let Some(p) = c.plan.as_mut()
714            {
715                let advanced = p.settle_binding(binding, ok, Some(note));
716                if !advanced.is_empty() {
717                    c.touch();
718                    self.log.info(
719                        "plan.updated",
720                        json!({"ctx": id, "op": "auto", "items": advanced}),
721                    );
722                }
723            }
724        }
725    }
726
727    /// Restore: re-spawn non-detached, non-terminal subagents (`attempt + 1`).
728    pub(crate) fn respawn_restored_subagents(&mut self) {
729        let handles: Vec<String> = self
730            .subagents
731            .values()
732            .filter(|s| !is_terminal_status(&s.status) && s.mode != "detached")
733            .map(|s| s.handle.clone())
734            .collect();
735        for handle in handles {
736            let Some(payload_v) = self.subagents.get(&handle).and_then(|s| s.payload.clone())
737            else {
738                continue;
739            };
740            let Ok(mut payload) = serde_json::from_value::<SpawnPayload>(payload_v) else {
741                self.log
742                    .warn("subagent.restore.bad_payload", json!({"handle": handle}));
743                if let Some(s) = self.subagents.get_mut(&handle) {
744                    s.status = "failed".into();
745                    s.error = Some("payload not restorable".into());
746                    s.dirty = true;
747                }
748                continue;
749            };
750            payload.intelligence = IntelConfig {
751                uri: self.intel_uri.clone(),
752                token: self.current_intel_bearer(),
753                model: Some(self.model.clone()),
754                headers: self.intel_headers.clone(),
755                aws_auth: self.intel_aws_auth(),
756                dialect: self.intel_dialect(),
757            };
758            let deadline = Duration::from_millis(payload.limits.deadline_ms.max(1000));
759            match self.children.spawn(
760                &payload,
761                ChildKind::Subagent {
762                    handle: handle.clone(),
763                },
764                deadline,
765            ) {
766                Ok(node) => {
767                    if let Some(s) = self.subagents.get_mut(&handle) {
768                        s.node = Some(node);
769                        s.attempt += 1;
770                        s.status = "running".into();
771                        s.dirty = true;
772                    }
773                    self.log.info(
774                        "subagent.respawn",
775                        json!({"handle": handle, "node": node.0}),
776                    );
777                }
778                Err(e) => {
779                    if let Some(s) = self.subagents.get_mut(&handle) {
780                        s.status = "failed".into();
781                        s.error = Some(format!("respawn: {e}"));
782                        s.dirty = true;
783                    }
784                }
785            }
786        }
787    }
788}
789
790fn plan_binding_subagent(handle: &str) -> crate::context::plan::Binding {
791    crate::context::plan::Binding::Subagent {
792        handle: handle.to_string(),
793    }
794}
795
796/// `"<burst>/<per>s"` → (burst, per_sec).
797pub fn parse_rate(s: &str) -> (u32, f64) {
798    let (b, p) = s.split_once('/').unwrap_or(("8", "2s"));
799    let burst = b.trim().parse::<u32>().unwrap_or(8).max(1);
800    let per = crate::config::parse_duration(p.trim())
801        .map(|d| d.as_secs_f64())
802        .unwrap_or(2.0)
803        .max(0.001);
804    (burst, burst as f64 / per)
805}
806
807/// Apply `subagents.defaults` to a spawn: each field lands only when neither
808/// the call site nor the template set it, so a default never overrides an
809/// explicit choice.
810fn apply_spawn_defaults(
811    o: &mut serde_json::Map<String, Value>,
812    d: &crate::config::v2::SubagentDefaults,
813) {
814    for (key, val) in [
815        ("mode", d.mode.clone()),
816        ("model", d.model.clone()),
817        ("priority", d.priority.clone()),
818    ] {
819        if !o.contains_key(key)
820            && let Some(v) = val
821        {
822            o.insert(key.into(), json!(v));
823        }
824    }
825    if !o.contains_key("limits")
826        && let Some(l) = &d.limits
827    {
828        o.insert("limits".into(), l.clone());
829    }
830    if !o.contains_key("durable")
831        && let Some(v) = d.durable
832    {
833        o.insert("durable".into(), json!(v));
834    }
835}
836
837/// The payload as stored: no credential, because the intelligence token is
838/// re-supplied from the live settings on restore.
839fn secret_free_payload(p: &SpawnPayload) -> Value {
840    let mut clean = p.clone();
841    clean.intelligence.token = None;
842    let mut v = serde_json::to_value(&clean).unwrap_or(Value::Null);
843    // The record keeps surfacing the narrowed grant for audits/`subagent.status`
844    // — read back OFF THE PAYLOAD that actually carries it, so the record can
845    // never claim a confinement the child was not given. The payload itself
846    // carries it into a restore-time respawn.
847    if let Some(a) = p.allowed_tools() {
848        v["allowed_tools"] = json!(a);
849    }
850    v
851}
852
853fn distill(v: &Value) -> Value {
854    match v {
855        Value::String(s) if s.len() > DISTILL_CAP => Value::String(format!(
856            "{}… [truncated]",
857            &s[..{
858                let mut cut = DISTILL_CAP;
859                while !s.is_char_boundary(cut) {
860                    cut -= 1;
861                }
862                cut
863            }]
864        )),
865        Value::String(s) => {
866            serde_json::from_str::<Value>(s).unwrap_or_else(|_| Value::String(s.clone()))
867        }
868        other => other.clone(),
869    }
870}
871
872fn distill_text(v: &Value) -> String {
873    let s = match v {
874        Value::String(s) => s.clone(),
875        other => other.to_string(),
876    };
877    if s.chars().count() > 400 {
878        format!("{}…", s.chars().take(400).collect::<String>())
879    } else {
880        s
881    }
882}
883
884#[cfg(test)]
885mod tests {
886    use super::*;
887
888    #[test]
889    fn rates_and_distillation() {
890        assert_eq!(parse_rate("8/2s"), (8, 4.0));
891        assert_eq!(parse_rate("1/1s"), (1, 1.0));
892        assert_eq!(parse_rate("garbage").0, 8);
893        assert_eq!(distill(&json!("{\"a\":1}")), json!({"a": 1}));
894        assert!(
895            distill(&Value::String("x".repeat(9000)))
896                .as_str()
897                .unwrap()
898                .ends_with("[truncated]")
899        );
900        assert!(distill_text(&json!({"k": "v"})).contains("\"k\""));
901    }
902}