Skip to main content

car_multi/patterns/
spawn_subtask.rs

1//! SpawnSubtask — an agent dynamically spawns isolated, tool-constrained
2//! sub-agents mid-run via a `spawn_subtask` tool.
3//!
4//! Unlike [`Delegator`](crate::Delegator), which routes to a fixed set of named
5//! specialists, `spawn_subtask` lets the main agent invent an ephemeral
6//! sub-agent on the fly and hand it a *subset of its own tools*:
7//!
8//! ```text
9//! main agent calls spawn_subtask(task="scrape the page", tools=["fetch"])
10//!   → SpawnSubtaskExecutor intercepts
11//!   → validates {fetch} ⊆ parent tools  (privilege de-escalation)
12//!   → spawns an ephemeral agent limited to {fetch}
13//!   → returns the sub-agent's answer as the tool result
14//! ```
15//!
16//! ## Tool-subset is enforced twice — once provably
17//!
18//! 1. **Schema (provable):** the `spawn_subtask` tool is registered with a JSON
19//!    Schema whose `tools` items are an `enum` of the parent's tool names. The
20//!    runtime's validator (`car-validator`, via `jsonschema`) rejects any
21//!    proposed call that lists a tool outside that set *before* execution. The
22//!    subset constraint is therefore a verified precondition, not a convention.
23//! 2. **Executor (defense in depth):** the executor re-checks the subset at call
24//!    time, so a caller that dispatches the tool without schema validation still
25//!    cannot escalate.
26//!
27//! ## Parallelism
28//!
29//! Multiple `spawn_subtask` calls a model emits in one proposal run concurrently
30//! through the engine's DAG executor (`futures::join_all` over independent
31//! actions) — no manual batching needed. Each spawn gets its own runtime over
32//! the shared infra, so they don't contend on a single mutable tool.
33
34use crate::error::MultiError;
35use crate::mailbox::Mailbox;
36use crate::runner::AgentRunner;
37use crate::shared::SharedInfra;
38use crate::types::AgentSpec;
39use car_engine::ToolExecutor;
40use serde::{Deserialize, Serialize};
41use serde_json::Value;
42use std::collections::HashSet;
43use std::sync::Arc;
44use tokio::sync::Mutex;
45use tracing::instrument;
46
47/// Reserved name of the meta-tool itself. Never granted to a sub-agent, so
48/// delegation is structurally bounded to one level.
49pub const SPAWN_SUBTASK_TOOL: &str = "spawn_subtask";
50
51/// Default system prompt for an ephemeral sub-agent when none is configured.
52const DEFAULT_SUBAGENT_PROMPT: &str =
53    "You are a focused sub-agent. Complete the single task you are given using \
54     only the tools provided, then return a concise result.";
55
56/// Default turn cap for ephemeral sub-agents.
57const DEFAULT_SUBAGENT_MAX_TURNS: u32 = 10;
58
59/// Record of one `spawn_subtask` invocation.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct SubtaskRecord {
62    pub name: String,
63    pub task: String,
64    pub tools: Vec<String>,
65    pub result: String,
66    pub success: bool,
67}
68
69/// Result of running an agent that can spawn sub-agents.
70#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SpawnSubtaskResult {
72    pub task: String,
73    pub final_answer: String,
74    pub subtasks: Vec<SubtaskRecord>,
75}
76
77/// Coordinator: runs a main agent with the `spawn_subtask` tool enabled.
78pub struct SpawnSubtask {
79    pub main: AgentSpec,
80    subagent_prompt: String,
81    subagent_max_turns: u32,
82}
83
84impl SpawnSubtask {
85    pub fn new(main: AgentSpec) -> Self {
86        Self {
87            main,
88            subagent_prompt: DEFAULT_SUBAGENT_PROMPT.to_string(),
89            subagent_max_turns: DEFAULT_SUBAGENT_MAX_TURNS,
90        }
91    }
92
93    #[instrument(name = "multi.spawn_subtask", skip_all)]
94    pub async fn run(
95        &self,
96        task: &str,
97        runner: &Arc<dyn AgentRunner>,
98        infra: &SharedInfra,
99    ) -> Result<SpawnSubtaskResult, MultiError> {
100        let records = Arc::new(Mutex::new(Vec::<SubtaskRecord>::new()));
101
102        // `spawn_subtask` is a reserved meta-tool: it is never part of the granted
103        // set, so (a) a sub-agent can never receive it — delegation is bounded to
104        // one level, no unbounded recursion — and (b) the parent-tool registration
105        // can't clobber the constrained schema by re-registering the same name.
106        let granted: Vec<String> = self
107            .main
108            .tools
109            .iter()
110            .filter(|t| t.as_str() != SPAWN_SUBTASK_TOOL)
111            .cloned()
112            .collect();
113
114        let rt = infra.make_runtime();
115        for tool in &granted {
116            rt.register_tool(tool).await;
117        }
118        // Register the constrained, subset-enforcing schema last so it is what
119        // ends up registered for the meta-tool (not a bare `{}`).
120        rt.register_tool_schema(spawn_subtask_schema(&granted))
121            .await;
122
123        let executor = Arc::new(SpawnSubtaskExecutor {
124            parent_tools: granted.into_iter().collect(),
125            subagent_prompt: self.subagent_prompt.clone(),
126            subagent_max_turns: self.subagent_max_turns,
127            runner: Arc::clone(runner),
128            infra_state: Arc::clone(&infra.state),
129            infra_log: Arc::clone(&infra.log),
130            infra_policies: Arc::clone(&infra.policies),
131            budget: Arc::clone(&infra.budget),
132            records: Arc::clone(&records),
133        });
134        rt.set_executor(executor).await;
135
136        // The main agent is the entry agent the caller explicitly invoked; it is
137        // intentionally NOT budget-gated. The budget caps the sub-agents it
138        // spawns, gated in SpawnSubtaskExecutor below.
139        let mailbox = Mailbox::default();
140        let output = runner
141            .run(&self.main, task, &rt, &mailbox)
142            .await
143            .map_err(|e| MultiError::AgentFailed(self.main.name.clone(), e.to_string()))?;
144
145        let subtasks = records.lock().await.clone();
146        Ok(SpawnSubtaskResult {
147            task: task.to_string(),
148            final_answer: output.answer,
149            subtasks,
150        })
151    }
152}
153
154/// Build the `spawn_subtask` tool schema whose `tools` parameter is constrained
155/// to an `enum` of `parent_tools` — the verifiable subset precondition.
156///
157/// If `parent_tools` is empty the `enum` is empty, so the validator rejects any
158/// non-empty `tools` request: an agent with no tools can only spawn a (useless)
159/// tool-less sub-agent. That's a degenerate but safe configuration.
160pub fn spawn_subtask_schema(parent_tools: &[String]) -> car_ir::ToolSchema {
161    car_ir::ToolSchema {
162        name: "spawn_subtask".to_string(),
163        source: car_ir::ToolSourceKind::Builtin,
164        description: "Spawn an isolated sub-agent to handle one focused subtask. \
165            The sub-agent may only use a subset of the tools you yourself have."
166            .to_string(),
167        parameters: serde_json::json!({
168            "type": "object",
169            "properties": {
170                "task": {
171                    "type": "string",
172                    "description": "The single, self-contained task for the sub-agent."
173                },
174                "tools": {
175                    "type": "array",
176                    "items": { "type": "string", "enum": parent_tools },
177                    "description": "Tools to grant the sub-agent. Must be a subset of your own tools."
178                },
179                "name": {
180                    "type": "string",
181                    "description": "Short label for the sub-agent (for logs)."
182                }
183            },
184            "required": ["task", "tools"]
185        }),
186        returns: None,
187        idempotent: false,
188        cache_ttl_secs: None,
189        rate_limit: None,
190    }
191}
192
193/// ToolExecutor that intercepts `spawn_subtask` and spawns a constrained
194/// ephemeral sub-agent.
195struct SpawnSubtaskExecutor {
196    parent_tools: HashSet<String>,
197    subagent_prompt: String,
198    subagent_max_turns: u32,
199    runner: Arc<dyn AgentRunner>,
200    infra_state: Arc<car_state::StateStore>,
201    infra_log: Arc<tokio::sync::Mutex<car_eventlog::EventLog>>,
202    infra_policies: Arc<tokio::sync::RwLock<car_policy::PolicyEngine>>,
203    budget: Arc<crate::budget::CoordinationBudget>,
204    records: Arc<Mutex<Vec<SubtaskRecord>>>,
205}
206
207#[async_trait::async_trait]
208impl ToolExecutor for SpawnSubtaskExecutor {
209    async fn execute(&self, tool: &str, params: &Value) -> Result<Value, String> {
210        if tool != "spawn_subtask" {
211            return Err(format!("unknown tool: {}", tool));
212        }
213
214        let task = params
215            .get("task")
216            .and_then(|v| v.as_str())
217            .ok_or("spawn_subtask requires 'task' parameter")?;
218        // Collect requested tools, de-duplicated (preserving first occurrence) so
219        // a repeated name doesn't propagate into the spawned spec or the record.
220        let mut seen = HashSet::new();
221        let requested: Vec<String> = params
222            .get("tools")
223            .and_then(|v| v.as_array())
224            .map(|arr| {
225                arr.iter()
226                    .filter_map(|v| v.as_str().map(String::from))
227                    .filter(|t| seen.insert(t.clone()))
228                    .collect()
229            })
230            .unwrap_or_default();
231        let name = params
232            .get("name")
233            .and_then(|v| v.as_str())
234            .unwrap_or("subtask")
235            .to_string();
236
237        // Defense-in-depth: reject privilege escalation even if the call reached
238        // us without schema validation. The schema enum is the primary guard.
239        let escalations: Vec<String> = requested
240            .iter()
241            .filter(|t| !self.parent_tools.contains(*t))
242            .cloned()
243            .collect();
244        if !escalations.is_empty() {
245            return Err(format!(
246                "privilege escalation rejected: sub-agent tools {:?} are not a subset of the parent's tools",
247                escalations
248            ));
249        }
250
251        let spec = AgentSpec {
252            name: name.clone(),
253            system_prompt: self.subagent_prompt.clone(),
254            tools: requested.clone(),
255            max_turns: self.subagent_max_turns,
256            metadata: std::collections::HashMap::new(),
257            cache_control: false,
258        };
259
260        // Budget gate before spawning the sub-agent. On denial, surface the
261        // reason as the tool result so the parent agent can stop fanning out.
262        if let Err(e) = self.budget.try_begin_agent() {
263            let msg = e.to_string();
264            self.records.lock().await.push(SubtaskRecord {
265                name,
266                task: task.to_string(),
267                tools: requested,
268                result: msg.clone(),
269                success: false,
270            });
271            return Ok(Value::String(msg));
272        }
273
274        let infra = SharedInfra {
275            state: Arc::clone(&self.infra_state),
276            log: Arc::clone(&self.infra_log),
277            policies: Arc::clone(&self.infra_policies),
278            budget: Arc::clone(&self.budget),
279            // Sub-agent spawns don't use the isolated parallel commit barrier,
280            // so there is nothing for the concurrency gate to act on here.
281            concurrency: None,
282            gate_audit_scope: None,
283        };
284        let rt = infra.make_runtime();
285        for tool_name in &requested {
286            rt.register_tool(tool_name).await;
287        }
288
289        let mailbox = Mailbox::default();
290        match self.runner.run(&spec, task, &rt, &mailbox).await {
291            Ok(output) => {
292                self.budget.record_output(&output);
293                self.records.lock().await.push(SubtaskRecord {
294                    name,
295                    task: task.to_string(),
296                    tools: requested,
297                    result: output.answer.clone(),
298                    success: true,
299                });
300                Ok(Value::String(output.answer))
301            }
302            Err(e) => {
303                let msg = format!("sub-agent '{}' failed: {}", name, e);
304                self.records.lock().await.push(SubtaskRecord {
305                    name,
306                    task: task.to_string(),
307                    tools: requested,
308                    result: msg.clone(),
309                    success: false,
310                });
311                Ok(Value::String(msg))
312            }
313        }
314    }
315}
316
317#[cfg(test)]
318mod tests {
319    use super::*;
320    use crate::types::{AgentOutput, AgentSpec};
321    use car_engine::Runtime;
322
323    /// Echoes the spec name + task; records nothing about tools.
324    struct SimpleRunner;
325
326    #[async_trait::async_trait]
327    impl AgentRunner for SimpleRunner {
328        async fn run(
329            &self,
330            spec: &AgentSpec,
331            task: &str,
332            _runtime: &Runtime,
333            _mailbox: &Mailbox,
334        ) -> Result<AgentOutput, MultiError> {
335            Ok(AgentOutput {
336                name: spec.name.clone(),
337                answer: format!("{} handled: {}", spec.name, &task[..task.len().min(40)]),
338                turns: 1,
339                tool_calls: 0,
340                duration_ms: 1.0,
341                error: None,
342                outcome: None,
343                tokens: None,
344                tools_used: Vec::new(),
345            })
346        }
347    }
348
349    fn test_executor() -> SpawnSubtaskExecutor {
350        let infra = SharedInfra::new();
351        SpawnSubtaskExecutor {
352            parent_tools: ["fetch", "search"].iter().map(|s| s.to_string()).collect(),
353            subagent_prompt: DEFAULT_SUBAGENT_PROMPT.to_string(),
354            subagent_max_turns: 5,
355            runner: Arc::new(SimpleRunner),
356            infra_state: infra.state,
357            infra_log: infra.log,
358            infra_policies: infra.policies,
359            budget: infra.budget,
360            records: Arc::new(Mutex::new(Vec::new())),
361        }
362    }
363
364    #[test]
365    fn schema_enum_lists_parent_tools_only() {
366        let schema = spawn_subtask_schema(&["fetch".into(), "search".into()]);
367        let enum_vals = schema.parameters["properties"]["tools"]["items"]["enum"]
368            .as_array()
369            .unwrap();
370        assert_eq!(enum_vals.len(), 2);
371        assert!(enum_vals.iter().any(|v| v == "fetch"));
372        assert!(enum_vals.iter().any(|v| v == "search"));
373    }
374
375    #[tokio::test]
376    async fn subset_call_spawns_subagent() {
377        let exec = test_executor();
378        let out = exec
379            .execute(
380                "spawn_subtask",
381                &serde_json::json!({ "task": "grab the page", "tools": ["fetch"], "name": "scraper" }),
382            )
383            .await
384            .unwrap();
385        assert!(out.as_str().unwrap().contains("scraper handled"));
386        let records = exec.records.lock().await;
387        assert_eq!(records.len(), 1);
388        assert!(records[0].success);
389        assert_eq!(records[0].tools, vec!["fetch".to_string()]);
390    }
391
392    #[tokio::test]
393    async fn escalation_is_rejected() {
394        let exec = test_executor();
395        let err = exec
396            .execute(
397                "spawn_subtask",
398                &serde_json::json!({ "task": "do admin", "tools": ["fetch", "delete_everything"] }),
399            )
400            .await
401            .unwrap_err();
402        assert!(err.contains("privilege escalation"));
403        assert!(err.contains("delete_everything"));
404        // Nothing was spawned.
405        assert!(exec.records.lock().await.is_empty());
406    }
407
408    #[tokio::test]
409    async fn unknown_tool_is_rejected() {
410        let exec = test_executor();
411        let err = exec
412            .execute("not_spawn", &serde_json::json!({}))
413            .await
414            .unwrap_err();
415        assert!(err.contains("unknown tool"));
416    }
417
418    /// Build a single-`spawn_subtask` tool-call proposal.
419    fn spawn_proposal(params: Value) -> car_ir::ActionProposal {
420        let parameters = params
421            .as_object()
422            .unwrap()
423            .iter()
424            .map(|(k, v)| (k.clone(), v.clone()))
425            .collect();
426        car_ir::ActionProposal {
427            id: "p1".into(),
428            source: "test".into(),
429            actions: vec![{
430                let mut a = car_ir::Action::new(car_ir::ActionType::ToolCall);
431                a.id = "a1".into();
432                a.tool = Some("spawn_subtask".into());
433                a.parameters = parameters;
434                a.expected_effects = std::collections::HashMap::new();
435                a.max_retries = 0;
436                a.failure_behavior = car_ir::FailureBehavior::Skip;
437                a.metadata = std::collections::HashMap::new();
438                a
439            }],
440            timestamp: chrono::Utc::now(),
441            context: std::collections::HashMap::new(),
442        }
443    }
444
445    /// A runner whose `lead` agent spawns one sub-agent through `runtime.execute`
446    /// (exercising the registered schema + validator), then answers.
447    struct SpawningRunner;
448    #[async_trait::async_trait]
449    impl AgentRunner for SpawningRunner {
450        async fn run(
451            &self,
452            spec: &AgentSpec,
453            task: &str,
454            runtime: &Runtime,
455            _mailbox: &Mailbox,
456        ) -> Result<AgentOutput, MultiError> {
457            if spec.name == "lead" {
458                let proposal = spawn_proposal(serde_json::json!({
459                    "task": "subtask work", "tools": ["fetch"], "name": "helper"
460                }));
461                let _ = runtime.execute(&proposal).await;
462            }
463            Ok(AgentOutput {
464                name: spec.name.clone(),
465                answer: format!("{} done: {}", spec.name, &task[..task.len().min(20)]),
466                turns: 1,
467                tool_calls: 0,
468                duration_ms: 1.0,
469                error: None,
470                outcome: None,
471                tokens: None,
472                tools_used: Vec::new(),
473            })
474        }
475    }
476
477    #[tokio::test]
478    async fn end_to_end_run_records_subtasks() {
479        let main = AgentSpec::new("lead", "lead").with_tools(vec!["fetch".into(), "search".into()]);
480        let runner: Arc<dyn AgentRunner> = Arc::new(SpawningRunner);
481        let infra = SharedInfra::new();
482        let result = SpawnSubtask::new(main)
483            .run("build it", &runner, &infra)
484            .await
485            .unwrap();
486
487        assert!(result.final_answer.contains("lead done"));
488        assert_eq!(result.subtasks.len(), 1);
489        assert_eq!(result.subtasks[0].name, "helper");
490        assert!(result.subtasks[0].success);
491    }
492
493    #[tokio::test]
494    async fn reserved_meta_tool_is_not_granted_to_subagents() {
495        // A parent that (mistakenly) lists "spawn_subtask" among its own tools
496        // must NOT have it leak into the granted set / schema enum — otherwise a
497        // sub-agent could spawn (unbounded recursion) and the schema could be
498        // clobbered by the bare-name registration.
499        let main = AgentSpec::new("lead", "lead")
500            .with_tools(vec!["fetch".into(), SPAWN_SUBTASK_TOOL.into()]);
501        let infra = SharedInfra::new();
502        let rt = infra.make_runtime();
503
504        // Re-run the granted-set derivation the coordinator uses.
505        let granted: Vec<String> = main
506            .tools
507            .iter()
508            .filter(|t| t.as_str() != SPAWN_SUBTASK_TOOL)
509            .cloned()
510            .collect();
511        assert_eq!(granted, vec!["fetch".to_string()]);
512
513        // The schema enum must not contain the meta-tool.
514        let schema = spawn_subtask_schema(&granted);
515        let enum_vals = schema.parameters["properties"]["tools"]["items"]["enum"]
516            .as_array()
517            .unwrap();
518        assert!(!enum_vals.iter().any(|v| v == SPAWN_SUBTASK_TOOL));
519
520        // And the executor guard rejects a request for it.
521        rt.register_tool_schema(spawn_subtask_schema(&granted))
522            .await;
523        let _ = rt; // schema wiring covered elsewhere; guard check below
524        let exec = SpawnSubtaskExecutor {
525            parent_tools: granted.into_iter().collect(),
526            subagent_prompt: DEFAULT_SUBAGENT_PROMPT.to_string(),
527            subagent_max_turns: 5,
528            runner: Arc::new(SimpleRunner),
529            infra_state: infra.state,
530            infra_log: infra.log,
531            infra_policies: infra.policies,
532            budget: infra.budget,
533            records: Arc::new(Mutex::new(Vec::new())),
534        };
535        let err = exec
536            .execute(
537                "spawn_subtask",
538                &serde_json::json!({ "task": "recurse", "tools": [SPAWN_SUBTASK_TOOL] }),
539            )
540            .await
541            .unwrap_err();
542        assert!(err.contains("privilege escalation"));
543    }
544
545    #[tokio::test]
546    async fn validator_rejects_out_of_subset_tool_via_schema_enum() {
547        // The schema enum is the provable subset guard: a proposal listing a tool
548        // outside the parent's set must be rejected by the validator before the
549        // executor spawns anything.
550        let records: Arc<Mutex<Vec<SubtaskRecord>>> = Arc::new(Mutex::new(Vec::new()));
551        let infra = SharedInfra::new();
552        let rt = infra.make_runtime();
553        rt.register_tool_schema(spawn_subtask_schema(&["fetch".into(), "search".into()]))
554            .await;
555        let exec = Arc::new(SpawnSubtaskExecutor {
556            parent_tools: ["fetch", "search"].iter().map(|s| s.to_string()).collect(),
557            subagent_prompt: DEFAULT_SUBAGENT_PROMPT.to_string(),
558            subagent_max_turns: 5,
559            runner: Arc::new(SimpleRunner),
560            infra_state: Arc::clone(&infra.state),
561            infra_log: Arc::clone(&infra.log),
562            infra_policies: Arc::clone(&infra.policies),
563            budget: Arc::clone(&infra.budget),
564            records: Arc::clone(&records),
565        });
566        rt.set_executor(exec).await;
567
568        let proposal = spawn_proposal(serde_json::json!({
569            "task": "escalate", "tools": ["delete_everything"]
570        }));
571        let result = rt.execute(&proposal).await;
572
573        assert!(
574            !result.all_succeeded(),
575            "out-of-subset tool must not succeed"
576        );
577        assert!(
578            records.lock().await.is_empty(),
579            "executor must not spawn when the validator rejects the call"
580        );
581    }
582}