1use 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
21const DISTILL_CAP: usize = 8_000;
23
24impl Runtime {
25 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 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 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 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 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 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 if let Some(a) = &allow {
295 payload.narrow_tools(a);
296 }
297 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 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 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 self.note_root(format!(
387 "subagent {handle} finished a turn: {}",
388 distill_text(&outcome.result)
389 ));
390 }
391
392 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 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 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, ¬e);
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 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 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 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
556pub 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
567fn 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 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}