1use async_trait::async_trait;
4use serde_json::{Value, json};
5use std::sync::Arc;
6
7mod agent_mode;
8mod fork_mode;
9
10use crate::config::AgentConfig;
11use crate::error::AgentError;
12use crate::model::provider::ModelProvider;
13use crate::orchestration::subagent::SubagentRegistry;
14use crate::tools::api::{
15 PermissionDecision, Tool, ToolContext, ToolDefinition, ToolOutput, ToolRegistry,
16};
17
18pub struct SubagentTool {
20 provider: Arc<dyn ModelProvider + Send + Sync>,
21 tools: ToolRegistry,
22 config: AgentConfig,
23 registry: SubagentRegistry,
24}
25
26impl SubagentTool {
27 pub fn new(
28 provider: Arc<dyn ModelProvider + Send + Sync>,
29 tools: ToolRegistry,
30 config: AgentConfig,
31 ) -> Self {
32 Self { provider, tools, config, registry: SubagentRegistry::with_builtin_agents() }
33 }
34
35 pub fn with_registry(
36 provider: Arc<dyn ModelProvider + Send + Sync>,
37 tools: ToolRegistry,
38 config: AgentConfig,
39 registry: SubagentRegistry,
40 ) -> Self {
41 Self { provider, tools, config, registry }
42 }
43}
44
45#[async_trait]
46impl Tool for SubagentTool {
47 fn definition(&self) -> ToolDefinition {
48 ToolDefinition {
49 name: "subagent".into(),
50 description:
51 "Delegate to a specialized subagent with its own turn loop. Run in parallel or background."
52 .into(),
53 input_schema: json!({
54 "type": "object",
55 "properties": {
56 "description": {
57 "type": "string",
58 "description": "Short 3-5 word description of what the agent will do"
59 },
60 "prompt": { "type": "string" },
61 "subagent_type": {
62 "type": "string",
63 "description": "Specialized agent type to use; defaults to general-purpose"
64 },
65 "system_prompt": { "type": "string" },
66 "max_iterations": { "type": "integer" },
67 "model": {
68 "type": "string",
69 "enum": ["thinking", "execution", "recovery", "summarization", "inherit"]
70 },
71 "run_in_background": { "type": "boolean" },
72 "isolation": {
73 "type": "string",
74 "enum": ["none", "worktree"]
75 },
76 "mode": {
77 "type": "string",
78 "enum": ["agent", "fork"],
79 "description": "When 'fork', runs multiple concurrent lenses instead of a full agent session"
80 },
81 "forks": {
82 "type": "array",
83 "items": {
84 "type": "object",
85 "properties": {
86 "lens": { "type": "string" },
87 "system_prompt": { "type": "string" },
88 "task": { "type": "string" },
89 "output_schema": { "type": "object" },
90 "allowed_tools": {
91 "type": "array",
92 "items": { "type": "string" }
93 }
94 },
95 "required": ["lens", "system_prompt", "task"]
96 }
97 }
98 },
99 "required": ["prompt"]
100 }),
101 }
102 }
103
104 fn prompt_text(&self) -> Option<&'static str> {
105 Some(
106 "Use Subagent to delegate self-contained tasks or protect the main context window. \
107Provide a short description and a self-contained prompt with scope, files, constraints, and expected output. \
108Use subagent_type (general-purpose, Explore, Plan, Verification) matching the task's needs. \
109Launch multiple independent subagent calls in one message for parallel execution. \
110Use run_in_background for long work and isolation: worktree for write-heavy tasks. \
111Don't duplicate work already being done in the parent session.",
112 )
113 }
114
115 async fn validate(&self, arguments: &Value, _context: &ToolContext) -> Result<(), AgentError> {
116 arguments
117 .get("prompt")
118 .and_then(|value| value.as_str())
119 .ok_or_else(|| AgentError::Validation("missing string `prompt`".into()))?;
120
121 if arguments.get("run_in_background").and_then(|value| value.as_bool()).unwrap_or(false)
122 && self.config.task_manager.is_none()
123 {
124 return Err(AgentError::Validation(
125 "run_in_background requires AgentConfig.task_manager".into(),
126 ));
127 }
128
129 if let Some(agent_type) = arguments.get("subagent_type").and_then(|v| v.as_str())
130 && self.registry.get(agent_type).is_none()
131 {
132 let available = self
133 .registry
134 .definitions()
135 .into_iter()
136 .map(|agent| agent.name.as_str())
137 .collect::<Vec<_>>()
138 .join(", ");
139 return Err(AgentError::Validation(format!(
140 "unknown subagent_type `{agent_type}`. Available agents: {available}"
141 )));
142 }
143
144 let mode = arguments.get("mode").and_then(|v| v.as_str()).unwrap_or("agent");
145
146 if mode == "fork"
147 && matches!(
148 arguments.get("isolation").and_then(|value| value.as_str()),
149 Some("worktree")
150 )
151 {
152 return Err(AgentError::Validation(
153 "worktree isolation is only supported in agent mode".into(),
154 ));
155 }
156
157 if mode == "fork" {
158 let forks = arguments
159 .get("forks")
160 .and_then(|f| f.as_array())
161 .ok_or_else(|| AgentError::Validation("fork mode requires `forks` array".into()))?;
162 if forks.is_empty() {
163 return Err(AgentError::Validation(
164 "fork mode requires at least one fork entry".into(),
165 ));
166 }
167 for (i, item) in forks.iter().enumerate() {
168 if item.get("lens").and_then(|v| v.as_str()).is_none() {
169 return Err(AgentError::Validation(format!("fork[{}] missing `lens`", i)));
170 }
171 if item.get("system_prompt").and_then(|v| v.as_str()).is_none() {
172 return Err(AgentError::Validation(format!(
173 "fork[{}] missing `system_prompt`",
174 i
175 )));
176 }
177 if item.get("task").and_then(|v| v.as_str()).is_none() {
178 return Err(AgentError::Validation(format!("fork[{}] missing `task`", i)));
179 }
180 }
181 }
182
183 Ok(())
184 }
185
186 async fn check_permission(
187 &self,
188 _arguments: &Value,
189 _context: &ToolContext,
190 ) -> Result<PermissionDecision, AgentError> {
191 Ok(PermissionDecision::Allow)
199 }
200
201 async fn invoke(
202 &self,
203 arguments: Value,
204 context: ToolContext,
205 ) -> Result<ToolOutput, AgentError> {
206 let mode = arguments.get("mode").and_then(|v| v.as_str()).unwrap_or("agent");
207
208 match mode {
209 "fork" => {
210 let result = self.run_fork(&arguments, &context).await?;
211 Ok(result)
212 }
213 _ => self.run_agent(arguments, context).await,
214 }
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use crate::model::mock::MockProvider;
222 use crate::orchestration::subagent::{AgentDefinition, AgentSource};
223 use async_trait::async_trait;
224 use serde_json::json;
225 use std::sync::Arc;
226
227 struct NamedTool(&'static str);
228
229 #[async_trait]
230 impl Tool for NamedTool {
231 fn definition(&self) -> ToolDefinition {
232 ToolDefinition {
233 name: self.0.into(),
234 description: "test".into(),
235 input_schema: json!({"type": "object"}),
236 }
237 }
238
239 async fn invoke(
240 &self,
241 _arguments: Value,
242 _context: ToolContext,
243 ) -> Result<ToolOutput, AgentError> {
244 Ok(ToolOutput::text("ok"))
245 }
246 }
247
248 #[test]
249 fn subagent_tool_schema_exposes_agent_tool_fields() {
250 let tool = SubagentTool::new(
251 Arc::new(MockProvider::new(vec![])),
252 ToolRegistry::new(),
253 AgentConfig::default(),
254 );
255 let schema = tool.definition().input_schema;
256 let properties = schema["properties"].as_object().unwrap();
257 assert!(properties.contains_key("description"));
258 assert!(properties.contains_key("subagent_type"));
259 assert!(properties.contains_key("model"));
260 assert!(properties.contains_key("run_in_background"));
261 assert!(properties.contains_key("isolation"));
262 }
263
264 #[test]
265 fn subagent_prompt_text_names_supported_agent_types() {
266 let tool = SubagentTool::new(
267 Arc::new(MockProvider::new(vec![])),
268 ToolRegistry::new(),
269 AgentConfig::default(),
270 );
271 let prompt = tool.prompt_text().unwrap();
272 assert!(prompt.contains("subagent_type"));
273 assert!(prompt.contains("general-purpose"));
274 assert!(prompt.contains("Explore"));
275 assert!(prompt.contains("Plan"));
276 assert!(prompt.contains("Verification"));
277 }
278
279 #[tokio::test]
280 async fn validate_rejects_unknown_subagent_type_with_available_agents() {
281 let tool = SubagentTool::new(
282 Arc::new(MockProvider::new(vec![])),
283 ToolRegistry::new(),
284 AgentConfig::default(),
285 );
286
287 let err = tool
288 .validate(
289 &json!({
290 "prompt": "inspect",
291 "subagent_type": "Nope"
292 }),
293 &ToolContext::dummy(),
294 )
295 .await
296 .unwrap_err();
297
298 let message = err.to_string();
299 assert!(message.contains("unknown subagent_type `Nope`"));
300 assert!(message.contains("Explore"));
301 }
302
303 #[tokio::test]
304 async fn validate_rejects_background_without_task_manager_and_fork_worktree() {
305 let tool = SubagentTool::new(
306 Arc::new(MockProvider::new(vec![])),
307 ToolRegistry::new(),
308 AgentConfig::default(),
309 );
310
311 let background = tool
312 .validate(
313 &json!({
314 "prompt": "inspect",
315 "run_in_background": true
316 }),
317 &ToolContext::dummy(),
318 )
319 .await
320 .unwrap_err();
321 assert!(background.to_string().contains("requires AgentConfig.task_manager"));
322
323 let worktree = tool
324 .validate(
325 &json!({
326 "prompt": "inspect",
327 "mode": "fork",
328 "isolation": "worktree",
329 "forks": [{"lens": "a", "system_prompt": "a", "task": "a"}]
330 }),
331 &ToolContext::dummy(),
332 )
333 .await
334 .unwrap_err();
335 assert!(worktree.to_string().contains("only supported in agent mode"));
336 }
337
338 #[test]
339 fn subagent_prompt_text_describes_distribution_controls() {
340 let tool = SubagentTool::new(
341 Arc::new(MockProvider::new(vec![])),
342 ToolRegistry::new(),
343 AgentConfig::default(),
344 );
345 let prompt = tool.prompt_text().unwrap();
346 assert!(prompt.contains("self-contained prompt"));
347 assert!(prompt.contains("independent subagent calls"));
348 assert!(prompt.contains("run_in_background"));
349 assert!(prompt.contains("isolation: worktree"));
350 }
351
352 #[test]
353 fn subagent_prompt_text_names_supported_agent_types_and_mode() {
354 let tool = SubagentTool::new(
355 Arc::new(MockProvider::new(vec![])),
356 ToolRegistry::new(),
357 AgentConfig::default(),
358 );
359 let prompt = tool.prompt_text().unwrap();
360 assert!(prompt.contains("Subagent"));
361 assert!(prompt.contains("subagent_type"));
362 }
363
364 #[tokio::test]
365 async fn subagent_type_selects_agent_prompt_and_result_metadata() {
366 let provider =
367 Arc::new(MockProvider::new(vec![crate::model::provider::CompletionResponse {
368 message: crate::Message::assistant("explore result"),
369 stop_reason: crate::model::provider::StopReason::EndTurn,
370 usage: None,
371 model: None,
372 }]));
373 let tool = SubagentTool::new(provider.clone(), ToolRegistry::new(), AgentConfig::default());
374
375 let output = tool
376 .invoke(
377 json!({
378 "description": "Explore code",
379 "prompt": "Find the runtime loop",
380 "subagent_type": "Explore"
381 }),
382 ToolContext::dummy(),
383 )
384 .await
385 .unwrap();
386
387 let requests = provider.requests.lock().await;
388 let system_prompt = requests[0].system_prompt_text().unwrap_or_default();
389 assert!(system_prompt.contains("You are an explore agent"), "{system_prompt}");
390 drop(requests);
391
392 assert_eq!(output.content["agent_type"], "Explore");
393 assert_eq!(output.content["description"], "Explore code");
394 assert_eq!(output.content["status"], "completed");
395 assert_eq!(output.content["final_text"], "explore result");
396 assert!(output.content["agent_id"].as_str().unwrap().starts_with("agent_"));
397 }
398
399 #[tokio::test]
400 async fn subagent_initial_prompt_is_prepended_to_delegated_prompt() {
401 let provider =
402 Arc::new(MockProvider::new(vec![crate::model::provider::CompletionResponse {
403 message: crate::Message::assistant("done"),
404 stop_reason: crate::model::provider::StopReason::EndTurn,
405 usage: None,
406 model: None,
407 }]));
408 let mut registry = SubagentRegistry::new();
409 let mut agent = AgentDefinition::new(
410 "preflight",
411 "Preflight agent",
412 "You prepare before work.",
413 AgentSource::BuiltIn,
414 );
415 agent.initial_prompt = Some("Read CONTRIBUTING.md first.".into());
416 registry.register(agent);
417 let tool = SubagentTool::with_registry(
418 provider.clone(),
419 ToolRegistry::new(),
420 AgentConfig::default(),
421 registry,
422 );
423
424 tool.invoke(
425 json!({
426 "description": "Run preflight",
427 "prompt": "Inspect parser",
428 "subagent_type": "preflight"
429 }),
430 ToolContext::dummy(),
431 )
432 .await
433 .unwrap();
434
435 let requests = provider.requests.lock().await;
436 let user_text = requests[0]
437 .messages
438 .iter()
439 .find(|message| message.role == crate::Role::User)
440 .expect("request should include delegated user prompt")
441 .text_content();
442 assert!(user_text.starts_with("Read CONTRIBUTING.md first."));
443 assert!(user_text.contains("Inspect parser"));
444 }
445
446 #[tokio::test]
447 async fn subagent_declared_skills_are_injected_into_system_prompt() {
448 let provider =
449 Arc::new(MockProvider::new(vec![crate::model::provider::CompletionResponse {
450 message: crate::Message::assistant("done"),
451 stop_reason: crate::model::provider::StopReason::EndTurn,
452 usage: None,
453 model: None,
454 }]));
455 let mut registry = SubagentRegistry::new();
456 let mut agent = AgentDefinition::new(
457 "debugger",
458 "Debugging agent",
459 "You debug the delegated issue.",
460 AgentSource::BuiltIn,
461 );
462 agent.skills = vec!["debug".into(), "verify".into()];
463 registry.register(agent);
464 let tool = SubagentTool::with_registry(
465 provider.clone(),
466 ToolRegistry::new(),
467 AgentConfig::default(),
468 registry,
469 );
470
471 tool.invoke(
472 json!({
473 "description": "Debug issue",
474 "prompt": "Find the bug",
475 "subagent_type": "debugger"
476 }),
477 ToolContext::dummy(),
478 )
479 .await
480 .unwrap();
481
482 let requests = provider.requests.lock().await;
483 let system_prompt = requests[0].system_prompt_text().unwrap_or_default();
484 assert!(system_prompt.contains("# Subagent Skills"), "{system_prompt}");
485 assert!(system_prompt.contains("Skill tool"), "{system_prompt}");
486 assert!(system_prompt.contains("debug"), "{system_prompt}");
487 assert!(system_prompt.contains("verify"), "{system_prompt}");
488 }
489
490 #[tokio::test]
491 async fn subagent_system_prompt_contains_learning_contract() {
492 let provider =
493 Arc::new(MockProvider::new(vec![crate::model::provider::CompletionResponse {
494 message: crate::Message::assistant("done"),
495 stop_reason: crate::model::provider::StopReason::EndTurn,
496 usage: None,
497 model: None,
498 }]));
499 let tool = SubagentTool::new(provider.clone(), ToolRegistry::new(), AgentConfig::default());
500
501 tool.invoke(
502 json!({
503 "description": "Explore code",
504 "prompt": "Find the runtime loop",
505 "subagent_type": "Explore"
506 }),
507 ToolContext::dummy(),
508 )
509 .await
510 .unwrap();
511
512 let requests = provider.requests.lock().await;
513 let system_prompt = requests[0].system_prompt_text().unwrap_or_default();
514 assert!(system_prompt.contains("# Subagent Learning"), "{system_prompt}");
515 assert!(system_prompt.contains("memory"), "{system_prompt}");
516 assert!(system_prompt.contains("Reusable learning"), "{system_prompt}");
517 }
518
519 #[tokio::test]
520 async fn subagent_progress_is_attached_to_parent_tool_call_with_data() {
521 let provider = Arc::new(MockProvider::new(vec![
522 crate::model::provider::CompletionResponse {
523 message: crate::Message {
524 role: crate::Role::Assistant,
525 blocks: vec![crate::ContentBlock::ToolCall(crate::ToolCall {
526 id: "child-call".into(),
527 name: "Read".into(),
528 arguments: json!({ "file_path": "src/lib.rs" }),
529 })],
530 },
531 stop_reason: crate::model::provider::StopReason::ToolUse,
532 usage: None,
533 model: None,
534 },
535 crate::model::provider::CompletionResponse {
536 message: crate::Message::assistant("done"),
537 stop_reason: crate::model::provider::StopReason::EndTurn,
538 usage: None,
539 model: None,
540 },
541 ]));
542 let tool = SubagentTool::new(provider, ToolRegistry::new(), AgentConfig::default());
543 let (tx, mut rx) = tokio::sync::mpsc::unbounded_channel();
544 let mut context = ToolContext::dummy();
545 context.tool_call_id = Some("parent-call".into());
546 context.progress = Some(tx);
547
548 tool.invoke(
549 json!({
550 "description": "Explore code",
551 "prompt": "Find the runtime loop",
552 "subagent_type": "Explore"
553 }),
554 context,
555 )
556 .await
557 .unwrap();
558
559 let mut progress = Vec::new();
560 while let Ok(item) = rx.try_recv() {
561 progress.push(item);
562 }
563
564 assert!(progress.iter().any(|item| {
565 item.tool_call_id.as_deref() == Some("parent-call")
566 && item.data.as_ref().and_then(|data| data["kind"].as_str()) == Some("subagent")
567 && item.data.as_ref().and_then(|data| data["event"].as_str()) == Some("tool_call")
568 && item.data.as_ref().and_then(|data| data["name"].as_str()) == Some("Read")
569 }));
570 }
571
572 #[test]
573 fn filters_child_tools_with_agent_allowlist_and_denylist() {
574 let mut tools = ToolRegistry::new();
575 tools.register(NamedTool("Read"));
576 tools.register(NamedTool("Write"));
577 tools.register(NamedTool("Grep"));
578
579 let mut agent = AgentDefinition::new("limited", "limited", "prompt", AgentSource::BuiltIn);
580 agent.allowed_tools = vec!["Read".into(), "Write".into()];
581 agent.disallowed_tools = vec!["Write".into()];
582
583 let filtered = agent_mode::filter_tools_for_agent(&tools, &agent);
584 let names = filtered.definitions().into_iter().map(|def| def.name).collect::<Vec<_>>();
585
586 assert_eq!(names, vec!["Read"]);
587 assert!(filtered.get("Read").is_ok());
588 assert!(filtered.get("Write").is_err());
589 assert!(filtered.get("Grep").is_err());
590 }
591
592 #[test]
593 fn wildcard_allowlist_keeps_all_tools_before_denylist() {
594 let mut tools = ToolRegistry::new();
595 tools.register(NamedTool("Read"));
596 tools.register(NamedTool("Write"));
597
598 let mut agent = AgentDefinition::new("wild", "wild", "prompt", AgentSource::BuiltIn);
599 agent.allowed_tools = vec!["*".into()];
600 agent.disallowed_tools = vec!["Write".into()];
601
602 let filtered = agent_mode::filter_tools_for_agent(&tools, &agent);
603
604 assert!(filtered.get("Read").is_ok());
605 assert!(filtered.get("Write").is_err());
606 }
607
608 #[test]
609 fn declared_skills_keep_skill_tool_available_with_allowlist() {
610 let mut tools = ToolRegistry::new();
611 tools.register(NamedTool("Read"));
612 tools.register(NamedTool("Skill"));
613 tools.register(NamedTool("MemoryRead"));
614 tools.register(NamedTool("MemoryWrite"));
615 tools.register(NamedTool("MemoryGrep"));
616
617 let mut agent = AgentDefinition::new("skilled", "skilled", "prompt", AgentSource::BuiltIn);
618 agent.allowed_tools = vec!["Read".into()];
619 agent.skills = vec!["debug".into()];
620
621 let filtered = agent_mode::filter_tools_for_agent(&tools, &agent);
622
623 assert!(filtered.get("Read").is_ok());
624 assert!(filtered.get("Skill").is_ok());
625 assert!(filtered.get("MemoryRead").is_ok());
626 assert!(filtered.get("MemoryWrite").is_ok());
627 assert!(filtered.get("MemoryGrep").is_ok());
628 }
629}