Skip to main content

agentd/runtime/
subagents.rs

1// SPDX-License-Identifier: Apache-2.0
2//! The **subagent registry** (RFC 0026 §6, RFC 0028 §3 `subagent.*`): 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                let Some(node) = self.subagents.get(&handle).and_then(|s| s.node) else {
39                    return err(format!("subagent {handle:?} is not running"));
40                };
41                if !self
42                    .subagents
43                    .get(&handle)
44                    .is_some_and(|s| s.mode == "warm")
45                {
46                    return err(format!("subagent {handle:?} is not a warm subagent"));
47                }
48                if self.children.send(node, &ControlMsg::Inject { message }) {
49                    ToolOutcome::Ready(json!({"ok": true, "handle": handle}), false)
50                } else {
51                    err(format!("subagent {handle:?}: send failed"))
52                }
53            }
54            "subagent.kill" => {
55                let handle = args["handle"].as_str().unwrap_or("").to_string();
56                let reason = args
57                    .get("reason")
58                    .and_then(Value::as_str)
59                    .unwrap_or("killed by request")
60                    .to_string();
61                let Some(node) = self.subagents.get(&handle).and_then(|s| s.node) else {
62                    return err(format!("subagent {handle:?} is not running"));
63                };
64                self.children.cancel(node, &reason);
65                if let Some(s) = self.subagents.get_mut(&handle) {
66                    s.status = "cancelled".into();
67                    s.error = Some(reason);
68                    s.updated = now_ms();
69                    s.dirty = true;
70                }
71                self.log.info("subagent.kill", json!({"handle": handle}));
72                ToolOutcome::Ready(json!({"ok": true, "handle": handle}), false)
73            }
74            "subagent.status" => {
75                let handle = args["handle"].as_str().unwrap_or("").to_string();
76                match self.subagents.get(&handle) {
77                    Some(s) => ToolOutcome::Ready(
78                        json!({"handle": handle, "status": s.status, "mode": s.mode, "result": s.result, "error": s.error, "tokens": s.tokens}),
79                        false,
80                    ),
81                    None => err(format!("no such subagent {handle:?}")),
82                }
83            }
84            "subagent.await" => {
85                let handle = args["handle"].as_str().unwrap_or("").to_string();
86                match self.subagents.get(&handle) {
87                    None => err(format!("no such subagent {handle:?}")),
88                    Some(s) if is_terminal_status(&s.status) => ToolOutcome::Ready(
89                        json!({"handle": handle, "status": s.status, "result": s.result, "error": s.error}),
90                        false,
91                    ),
92                    Some(_) => ToolOutcome::Deferred(PendingKind::Subagent { handle }),
93                }
94            }
95            "subagent.list" => ToolOutcome::Ready(
96                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<_>>()}),
97                false,
98            ),
99            _ => err(format!("unknown subagent tool {name}")),
100        }
101    }
102
103    /// `subagent.run`: caps → durable record → spawn (RFC 0026 §6).
104    fn subagent_run(&mut self, caller: &ToolCaller, args: &Value) -> ToolOutcome {
105        let err = |e: String| ToolOutcome::Ready(Value::String(e), true);
106        let instruction = args["instruction"]
107            .as_str()
108            .unwrap_or("")
109            .trim()
110            .to_string();
111        if instruction.is_empty() {
112            return err("subagent.run: instruction must be non-empty".into());
113        }
114        let mode = args
115            .get("mode")
116            .and_then(Value::as_str)
117            .unwrap_or("sync")
118            .to_string();
119        if !matches!(mode.as_str(), "sync" | "async" | "detached" | "warm") {
120            return err("subagent.run: mode must be sync|async|detached|warm".into());
121        }
122        // Caps (RFC 0026 §2): breadth (live), total (lifetime), rate, depth
123        // (a subagent asking for a subagent goes through the same chokepoint;
124        // logical depth = the requester's depth + 1).
125        let live = self
126            .subagents
127            .values()
128            .filter(|s| !is_terminal_status(&s.status))
129            .count() as u32;
130        let breadth = self.settings.limits.subagents.breadth.unwrap_or(8);
131        if live >= breadth {
132            return err(format!(
133                "subagent.run refused: {live} subagents live (limits.subagents.breadth = {breadth})"
134            ));
135        }
136        let total = self.settings.limits.subagents.total.unwrap_or(64) as usize;
137        if self.subagents.len() >= total {
138            return err(format!(
139                "subagent.run refused: {} subagents spawned (limits.subagents.total = {total})",
140                self.subagents.len()
141            ));
142        }
143        let depth = caller
144            .subagent
145            .as_ref()
146            .and_then(|h| self.subagents.get(h))
147            .map(|s| {
148                s.requested_by
149                    .as_ref()
150                    .and_then(|r| r["depth"].as_u64())
151                    .unwrap_or(0) as u32
152                    + 1
153            })
154            .unwrap_or(0);
155        let max_depth = self.settings.limits.subagents.depth.unwrap_or(3);
156        if depth >= max_depth {
157            return err(format!(
158                "subagent.run refused: delegation depth {depth} reaches limits.subagents.depth = {max_depth}"
159            ));
160        }
161        if !self.spawn_bucket_take() {
162            return err("subagent.run refused: spawn rate exceeded (limits.subagents.rate)".into());
163        }
164        if crate::supervisor::cgroup::under_memory_pressure() {
165            return err("subagent.run refused: memory pressure".into());
166        }
167        // Tools / servers narrowing.
168        let allow: Option<Vec<String>> = args.get("tools").and_then(Value::as_array).map(|a| {
169            a.iter()
170                .filter_map(Value::as_str)
171                .map(str::to_string)
172                .collect()
173        });
174        let servers: Vec<String> = match args.get("servers").and_then(Value::as_array) {
175            Some(a) => a
176                .iter()
177                .filter_map(Value::as_str)
178                .filter(|s| self.mcp_specs.contains_key(*s))
179                .map(str::to_string)
180                .collect(),
181            None => self.mcp_specs.keys().cloned().collect(),
182        };
183        // The trifecta gate over the narrowed grant.
184        let tags: Vec<crate::sec::scope::TrifectaTag> = servers
185            .iter()
186            .filter_map(|s| self.mcp_specs.get(s))
187            .flat_map(|s| s.tags.iter().copied())
188            .collect();
189        if crate::sec::scope::check_trifecta(
190            tags.iter().copied(),
191            self.settings.security.allow_trifecta,
192        )
193        .is_refused()
194        {
195            return err("subagent.run refused: the requested MCP servers form a lethal trifecta (untrusted input + sensitive + egress); set security.allow_trifecta to override".into());
196        }
197        let handle = self.next_id("sub");
198        let limits = args.get("limits").cloned().unwrap_or(json!({}));
199        let steps = limits
200            .get("steps")
201            .and_then(Value::as_u64)
202            .map(|s| s as u32)
203            .unwrap_or(self.settings.limits.run.steps());
204        let tokens = limits
205            .get("tokens")
206            .and_then(Value::as_u64)
207            .unwrap_or(self.settings.limits.run.tokens());
208        let deadline_ms = limits
209            .get("deadline")
210            .and_then(Value::as_str)
211            .and_then(|d| crate::config::parse_duration(d).ok())
212            .map(|d| d.as_millis() as u64)
213            .unwrap_or(self.settings.limits.run.deadline().as_millis() as u64);
214        let context_seed: Vec<SeedMessage> = args
215            .get("context")
216            .and_then(Value::as_array)
217            .map(|a| {
218                a.iter()
219                    .filter_map(|m| {
220                        let role = m["role"].as_str()?;
221                        // The tool grant is minted below from `tools:` alone —
222                        // a caller cannot smuggle one in through `context`
223                        // (a forged one could only narrow its own child, but the
224                        // supervisor owns the grant, so there is one source).
225                        if role == crate::subagent::protocol::ALLOWED_TOOLS_ROLE {
226                            return None;
227                        }
228                        Some(SeedMessage {
229                            role: role.to_string(),
230                            content: m["content"].as_str()?.to_string(),
231                        })
232                    })
233                    .collect()
234            })
235            .unwrap_or_default();
236        let output_contract = args
237            .get("output_contract")
238            .and_then(Value::as_str)
239            .map(str::to_string)
240            .or_else(|| {
241                args.get("output_schema").map(|s| {
242                    format!("Reply with ONLY one JSON object matching this JSON Schema: {s}")
243                })
244            });
245        let mut payload = SpawnPayload {
246            instruction: instruction.clone(),
247            output_contract,
248            context_seed,
249            intelligence: IntelConfig {
250                uri: self.intel_uri.clone(),
251                token: self.current_intel_bearer(),
252                model: Some(self.model.clone()),
253                headers: self.intel_headers.clone(),
254                aws_auth: self.intel_aws_auth(),
255                dialect: self.intel_dialect(),
256            },
257            mcp_servers: servers
258                .iter()
259                .filter_map(|s| self.mcp_specs.get(s).cloned())
260                .collect(),
261            a2a_peers: Vec::new(),
262            tls_ca: self.settings.security.tls_ca.clone(),
263            aauth: None,
264            limits: Limits {
265                max_steps: steps,
266                max_tokens: tokens,
267                deadline_ms: deadline_ms.max(1000),
268                max_depth: max_depth.saturating_sub(depth + 1),
269            },
270            telemetry: Telemetry {
271                run_id: self.run_id.clone(),
272                agent_id: handle.clone(),
273                agent_path: format!("sub/{handle}"),
274                trace_id: self.trace_id.clone(),
275                log_level: self
276                    .settings
277                    .observability
278                    .log_level
279                    .clone()
280                    .unwrap_or_else(|| "info".into()),
281                log_content: self.settings.observability.log_content,
282            },
283            depth: depth + 1,
284            warm: mode == "warm",
285            role: Role::Agent,
286            turn: None,
287        };
288        // The `tools:` narrowing is a GRANT, not a note: minted into the payload
289        // here and ENFORCED by the child, which filters both its catalogue and
290        // its dispatch against it (`agentloop::runner::Session::prepare`). Without
291        // the mint the argument would be recorded and ignored, and a parent
292        // bounding an untrusted sub-task would silently get a child holding
293        // everything — the opposite of RFC 0009's monotonically narrowing scope.
294        if let Some(a) = &allow {
295            payload.narrow_tools(a);
296        }
297        // A durable record BEFORE the spawn (restore re-spawns pending ones).
298        let mut record = SubagentRecord {
299            handle: handle.clone(),
300            instruction: instruction.clone(),
301            mode: mode.clone(),
302            status: "spawned".into(),
303            attempt: 1,
304            result: None,
305            error: None,
306            requested_by: Some(
307                json!({"caller": caller.node.map(|n| n.0), "ctx": caller.ctx, "run": caller.run, "step": caller.step, "subagent": caller.subagent, "depth": depth}),
308            ),
309            tokens: 0,
310            created: now_ms(),
311            updated: now_ms(),
312            payload: Some(secret_free_payload(&payload)),
313            node: None,
314            dirty: true,
315        };
316        match self.children.spawn(
317            &payload,
318            ChildKind::Subagent {
319                handle: handle.clone(),
320            },
321            Duration::from_millis(deadline_ms),
322        ) {
323            Ok(node) => {
324                record.node = Some(node);
325                record.status = "running".into();
326                self.log.info("subagent.spawn", json!({"handle": handle, "mode": mode, "node": node.0, "depth": depth + 1, "servers": servers.len()}));
327                self.subagents.insert(handle.clone(), record);
328                let _ = self.durable.put(
329                    crate::state::Kind::Subagent,
330                    &handle,
331                    serde_json::to_value(self.subagents.get(&handle).unwrap())
332                        .unwrap_or(Value::Null),
333                    None,
334                );
335                if let Some(s) = self.subagents.get_mut(&handle) {
336                    s.dirty = false;
337                }
338                match mode.as_str() {
339                    "sync" => ToolOutcome::Deferred(PendingKind::Subagent { handle }),
340                    _ => ToolOutcome::Ready(json!({"handle": handle, "status": "running"}), false),
341                }
342            }
343            Err(e) => {
344                record.status = "failed".into();
345                record.error = Some(format!("spawn: {e}"));
346                self.subagents.insert(handle.clone(), record);
347                err(format!("subagent.run: spawn failed: {e}"))
348            }
349        }
350    }
351
352    fn spawn_bucket_take(&mut self) -> bool {
353        // A process-lifetime bucket parsed from `limits.subagents.rate` ("8/2s").
354        static BUCKET: std::sync::Mutex<Option<TokenBucket>> = std::sync::Mutex::new(None);
355        let mut g = BUCKET.lock().unwrap_or_else(|e| e.into_inner());
356        if g.is_none() {
357            let (burst, per_sec) = parse_rate(
358                self.settings
359                    .limits
360                    .subagents
361                    .rate
362                    .as_deref()
363                    .unwrap_or("8/2s"),
364            );
365            *g = Some(TokenBucket::new(burst, per_sec));
366        }
367        g.as_mut().map(|b| b.try_take()).unwrap_or(true)
368    }
369
370    /// A warm subagent finished a turn (non-terminal).
371    pub(crate) fn on_subagent_turn(&mut self, node: NodeId, outcome: Outcome) {
372        let Some(ChildKind::Subagent { handle }) = self.children.get(node).map(|c| c.kind.clone())
373        else {
374            return;
375        };
376        if let Some(s) = self.subagents.get_mut(&handle) {
377            s.result = Some(distill(&outcome.result));
378            s.updated = now_ms();
379            s.dirty = true;
380        }
381        self.log.info(
382            "subagent.turn",
383            json!({"handle": handle, "status": outcome.status.as_str()}),
384        );
385        // Notify the root context (wake policy: subagent_result).
386        self.note_root(format!(
387            "subagent {handle} finished a turn: {}",
388            distill_text(&outcome.result)
389        ));
390    }
391
392    /// A subagent finished (result or failure).
393    pub(crate) fn on_subagent_result(&mut self, node: NodeId, outcome: Result<Outcome, String>) {
394        let Some(ChildKind::Subagent { handle }) = self.children.get(node).map(|c| c.kind.clone())
395        else {
396            return;
397        };
398        let tokens = self.children.get(node).map(|c| c.tokens).unwrap_or(0);
399        let (status, result, error) = match &outcome {
400            Ok(o) => (
401                o.status.as_str().to_string(),
402                Some(distill(&o.result)),
403                None,
404            ),
405            Err(e) => ("failed".to_string(), None, Some(e.clone())),
406        };
407        if let Some(s) = self.subagents.get_mut(&handle) {
408            s.status = status.clone();
409            s.result = result.clone();
410            s.error = error.clone();
411            s.tokens = tokens;
412            s.node = None;
413            s.updated = now_ms();
414            s.dirty = true;
415        }
416        self.log.info(
417            "subagent.result",
418            json!({"handle": handle, "status": status, "tokens": tokens, "err": error}),
419        );
420        // Answer waiters.
421        let waiting: Vec<super::reactor::Target> = self
422            .pending
423            .iter()
424            .filter(|p| matches!(&p.kind, PendingKind::Subagent { handle: h } if *h == handle))
425            .map(|p| p.target.clone())
426            .collect();
427        self.pending
428            .retain(|p| !matches!(&p.kind, PendingKind::Subagent { handle: h } if *h == handle));
429        for t in waiting {
430            self.reply(
431                &t,
432                json!({"handle": handle, "status": status, "result": result, "error": error}),
433                false,
434            );
435        }
436        // Plan bindings + the root note (RFC 0026 §5.3, §3.1 wake policy).
437        let ok = status == "completed";
438        let note = result
439            .as_ref()
440            .map(distill_text)
441            .or(error.clone())
442            .unwrap_or_default();
443        self.settle_plan_bindings(&plan_binding_subagent(&handle), ok, &note);
444        if self
445            .settings
446            .agent
447            .wake_on()
448            .contains(&crate::config::v2::WakeEvent::SubagentResult)
449        {
450            self.note_root(format!("subagent {handle} {status}: {note}"));
451        }
452    }
453
454    /// Append a note to the root context (durable; the next root turn sees it).
455    pub(crate) fn note_root(&mut self, text: String) {
456        let window = self.model_window();
457        let c = self.contexts.root();
458        if c.model_window == 0 {
459            c.model_window = window;
460        }
461        c.append(Msg::note(text));
462    }
463
464    /// Auto-advance plan items bound to a finished run/subagent (every context).
465    pub(crate) fn settle_plan_bindings(
466        &mut self,
467        binding: &crate::context::plan::Binding,
468        ok: bool,
469        note: &str,
470    ) {
471        for id in self.contexts.ids() {
472            if let Some(c) = self.contexts.get_mut(&id)
473                && let Some(p) = c.plan.as_mut()
474            {
475                let advanced = p.settle_binding(binding, ok, Some(note));
476                if !advanced.is_empty() {
477                    c.touch();
478                    self.log.info(
479                        "plan.updated",
480                        json!({"ctx": id, "op": "auto", "items": advanced}),
481                    );
482                }
483            }
484        }
485    }
486
487    /// Restore: re-spawn non-detached, non-terminal subagents (`attempt + 1`).
488    pub(crate) fn respawn_restored_subagents(&mut self) {
489        let handles: Vec<String> = self
490            .subagents
491            .values()
492            .filter(|s| !is_terminal_status(&s.status) && s.mode != "detached")
493            .map(|s| s.handle.clone())
494            .collect();
495        for handle in handles {
496            let Some(payload_v) = self.subagents.get(&handle).and_then(|s| s.payload.clone())
497            else {
498                continue;
499            };
500            let Ok(mut payload) = serde_json::from_value::<SpawnPayload>(payload_v) else {
501                self.log
502                    .warn("subagent.restore.bad_payload", json!({"handle": handle}));
503                if let Some(s) = self.subagents.get_mut(&handle) {
504                    s.status = "failed".into();
505                    s.error = Some("payload not restorable".into());
506                    s.dirty = true;
507                }
508                continue;
509            };
510            payload.intelligence = IntelConfig {
511                uri: self.intel_uri.clone(),
512                token: self.current_intel_bearer(),
513                model: Some(self.model.clone()),
514                headers: self.intel_headers.clone(),
515                aws_auth: self.intel_aws_auth(),
516                dialect: self.intel_dialect(),
517            };
518            let deadline = Duration::from_millis(payload.limits.deadline_ms.max(1000));
519            match self.children.spawn(
520                &payload,
521                ChildKind::Subagent {
522                    handle: handle.clone(),
523                },
524                deadline,
525            ) {
526                Ok(node) => {
527                    if let Some(s) = self.subagents.get_mut(&handle) {
528                        s.node = Some(node);
529                        s.attempt += 1;
530                        s.status = "running".into();
531                        s.dirty = true;
532                    }
533                    self.log.info(
534                        "subagent.respawn",
535                        json!({"handle": handle, "node": node.0}),
536                    );
537                }
538                Err(e) => {
539                    if let Some(s) = self.subagents.get_mut(&handle) {
540                        s.status = "failed".into();
541                        s.error = Some(format!("respawn: {e}"));
542                        s.dirty = true;
543                    }
544                }
545            }
546        }
547    }
548}
549
550fn plan_binding_subagent(handle: &str) -> crate::context::plan::Binding {
551    crate::context::plan::Binding::Subagent {
552        handle: handle.to_string(),
553    }
554}
555
556/// `"<burst>/<per>s"` → (burst, per_sec).
557pub fn parse_rate(s: &str) -> (u32, f64) {
558    let (b, p) = s.split_once('/').unwrap_or(("8", "2s"));
559    let burst = b.trim().parse::<u32>().unwrap_or(8).max(1);
560    let per = crate::config::parse_duration(p.trim())
561        .map(|d| d.as_secs_f64())
562        .unwrap_or(2.0)
563        .max(0.001);
564    (burst, burst as f64 / per)
565}
566
567/// The payload as stored (no credential: the intelligence token is re-supplied
568/// from the live settings on restore).
569fn secret_free_payload(p: &SpawnPayload) -> Value {
570    let mut clean = p.clone();
571    clean.intelligence.token = None;
572    let mut v = serde_json::to_value(&clean).unwrap_or(Value::Null);
573    // The record keeps surfacing the narrowed grant for audits/`subagent.status`
574    // — read back OFF THE PAYLOAD that actually carries it, so the record can
575    // never claim a confinement the child was not given. The payload itself
576    // carries it into a restore-time respawn.
577    if let Some(a) = p.allowed_tools() {
578        v["allowed_tools"] = json!(a);
579    }
580    v
581}
582
583fn distill(v: &Value) -> Value {
584    match v {
585        Value::String(s) if s.len() > DISTILL_CAP => Value::String(format!(
586            "{}… [truncated]",
587            &s[..{
588                let mut cut = DISTILL_CAP;
589                while !s.is_char_boundary(cut) {
590                    cut -= 1;
591                }
592                cut
593            }]
594        )),
595        Value::String(s) => {
596            serde_json::from_str::<Value>(s).unwrap_or_else(|_| Value::String(s.clone()))
597        }
598        other => other.clone(),
599    }
600}
601
602fn distill_text(v: &Value) -> String {
603    let s = match v {
604        Value::String(s) => s.clone(),
605        other => other.to_string(),
606    };
607    if s.chars().count() > 400 {
608        format!("{}…", s.chars().take(400).collect::<String>())
609    } else {
610        s
611    }
612}
613
614#[cfg(test)]
615mod tests {
616    use super::*;
617
618    #[test]
619    fn rates_and_distillation() {
620        assert_eq!(parse_rate("8/2s"), (8, 4.0));
621        assert_eq!(parse_rate("1/1s"), (1, 1.0));
622        assert_eq!(parse_rate("garbage").0, 8);
623        assert_eq!(distill(&json!("{\"a\":1}")), json!({"a": 1}));
624        assert!(
625            distill(&Value::String("x".repeat(9000)))
626                .as_str()
627                .unwrap()
628                .ends_with("[truncated]")
629        );
630        assert!(distill_text(&json!({"k": "v"})).contains("\"k\""));
631    }
632}