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 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 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 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 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 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 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 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 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 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 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 if let Some(a) = &allow {
408 payload.narrow_tools(a);
409 }
410 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 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, ¶ms);
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 o.insert("params".into(), Value::Object(params));
539 return Ok((eff, Some((tname, "instance".into()))));
540 }
541 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 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 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 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 self.note_root(format!(
625 "subagent {handle} finished a turn: {}",
626 distill_text(&outcome.result)
627 ));
628 }
629
630 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 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 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, ¬e);
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 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 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 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
796pub 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
807fn 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
837fn 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 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}