1use 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
47pub const SPAWN_SUBTASK_TOOL: &str = "spawn_subtask";
50
51const 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
56const DEFAULT_SUBAGENT_MAX_TURNS: u32 = 10;
58
59#[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#[derive(Debug, Clone, Serialize, Deserialize)]
71pub struct SpawnSubtaskResult {
72 pub task: String,
73 pub final_answer: String,
74 pub subtasks: Vec<SubtaskRecord>,
75}
76
77pub 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 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 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 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
154pub 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
193struct 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 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 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 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 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 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 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 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 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 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 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 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 rt.register_tool_schema(spawn_subtask_schema(&granted))
522 .await;
523 let _ = rt; 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 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}