1use async_trait::async_trait;
22use serde_json::{json, Value};
23use std::sync::Arc;
24
25use crate::agent::{FinishReason, PlanningMode};
26use crate::error::{Error, Result};
27use crate::kernel::{AgentKernel, TurnContext};
28use crate::llm::{LlmProvider, ToolSpec};
29use crate::message::Message;
30use crate::permissions::PermissionMode;
31use crate::tools::PermissionHook;
32use crate::tools::{Tool, ToolRegistry};
33
34#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum AgentType {
41 Explore,
45 GeneralPurpose,
48}
49
50impl AgentType {
51 pub fn parse(s: &str) -> Option<Self> {
54 match s {
55 "explore" => Some(Self::Explore),
56 "general_purpose" => Some(Self::GeneralPurpose),
57 _ => None,
58 }
59 }
60
61 pub fn is_read_only(self) -> bool {
63 matches!(self, Self::Explore)
64 }
65
66 pub fn system_prompt_hint(self) -> &'static str {
68 match self {
69 Self::Explore => {
70 "You are an exploration sub-agent. Use only read tools to gather \
71 information. Do NOT write or modify files. Be thorough and concise."
72 }
73 Self::GeneralPurpose => {
74 "You are a focused sub-agent. Complete the given task using the \
75 available tools. Be concise."
76 }
77 }
78 }
79
80 pub fn allowed_tool_names(self) -> Option<Vec<String>> {
82 match self {
83 Self::Explore => Some(vec![
84 "read_file".to_string(),
85 "list_dir".to_string(),
86 "search_files".to_string(),
87 "recall".to_string(),
88 "web_fetch".to_string(),
89 "sub_agent".to_string(),
90 ]),
91 Self::GeneralPurpose => None,
92 }
93 }
94}
95
96pub struct SubAgent {
106 workspace: std::path::PathBuf,
107 provider: Arc<dyn LlmProvider>,
108 all_tools: ToolRegistry,
109 max_depth: usize,
110 current_depth: usize,
111 permission_hook: Option<Arc<dyn PermissionHook>>,
112}
113
114impl SubAgent {
115 pub fn new(
116 workspace: impl Into<std::path::PathBuf>,
117 provider: Arc<dyn LlmProvider>,
118 all_tools: ToolRegistry,
119 max_depth: usize,
120 current_depth: usize,
121 permission_hook: Option<Arc<dyn PermissionHook>>,
122 ) -> Self {
123 Self {
124 workspace: workspace.into(),
125 provider,
126 all_tools,
127 max_depth,
128 current_depth,
129 permission_hook,
130 }
131 }
132
133 fn build_sub_registry(&self, tool_names: &[String]) -> ToolRegistry {
135 let mut reg = self.all_tools.with_same_transport();
136 for name in tool_names {
137 if let Some(tool) = self.all_tools.get(name) {
138 reg = reg.register(tool);
139 }
140 }
141 reg
142 }
143
144 fn default_tool_names() -> Vec<String> {
146 vec![
147 "read_file".to_string(),
148 "list_dir".to_string(),
149 "search_files".to_string(),
150 "web_fetch".to_string(),
151 ]
152 }
153}
154
155#[async_trait]
156impl Tool for SubAgent {
157 fn spec(&self) -> ToolSpec {
158 ToolSpec {
159 name: "sub_agent".into(),
160 description: "Spawn a fresh agent with its own transcript to complete a focused sub-task. Returns the sub-agent's final response.".into(),
161 parameters: json!({
162 "type": "object",
163 "properties": {
164 "prompt": {
165 "type": "string",
166 "description": "The goal / prompt for the sub-agent"
167 },
168 "subagent_type": {
169 "type": "string",
170 "enum": ["explore", "general_purpose"],
171 "description": "Agent personality. 'explore': read-only tools only, can run in parallel with other explore agents. 'general_purpose' (default): full tool access, runs sequentially."
172 },
173 "max_steps": {
174 "type": "integer",
175 "description": "Maximum steps for the sub-agent (default 30, capped at parent's remaining budget)",
176 "default": 30
177 },
178 "tools": {
179 "type": "array",
180 "items": { "type": "string" },
181 "description": "Optional list of tool names to make available to the sub-agent. Ignored when subagent_type is 'explore' (which enforces its own read-only tool set). Default: read_file, list_dir, search_files, web_fetch"
182 }
183 },
184 "required": ["prompt"]
185 }),
186 }
187 }
188
189 fn is_readonly_for_args(&self, arguments: &serde_json::Value) -> bool {
192 arguments
193 .get("subagent_type")
194 .and_then(|v| v.as_str())
195 .and_then(AgentType::parse)
196 .map(AgentType::is_read_only)
197 .unwrap_or(false)
198 }
199
200 async fn execute(&self, arguments: Value) -> Result<String> {
201 let prompt = arguments["prompt"]
202 .as_str()
203 .ok_or_else(|| Error::BadToolArgs {
204 name: "sub_agent".into(),
205 message: "missing required parameter: prompt".to_string(),
206 })?;
207
208 let agent_type = arguments
210 .get("subagent_type")
211 .and_then(|v| v.as_str())
212 .and_then(AgentType::parse)
213 .unwrap_or(AgentType::GeneralPurpose);
214
215 let max_steps = arguments["max_steps"].as_i64().unwrap_or(30).clamp(1, 100) as usize;
216
217 let tool_names: Vec<String> = if let Some(forced) = agent_type.allowed_tool_names() {
220 forced
221 } else {
222 arguments["tools"]
223 .as_array()
224 .map(|arr| {
225 arr.iter()
226 .filter_map(|v| v.as_str().map(String::from))
227 .collect()
228 })
229 .unwrap_or_else(Self::default_tool_names)
230 };
231
232 if self.current_depth >= self.max_depth {
234 return Ok(format!(
235 "ERROR: sub-agent depth limit reached (max_depth={}). Cannot spawn deeper sub-agent.",
236 self.max_depth
237 ));
238 }
239
240 let mut sub_registry = self.build_sub_registry(&tool_names);
242
243 let child_sub = SubAgent::new(
246 &self.workspace,
247 self.provider.clone(),
248 self.all_tools.clone(),
249 self.max_depth,
250 self.current_depth + 1,
251 self.permission_hook.clone(),
252 );
253 sub_registry = sub_registry.register(Arc::new(child_sub));
254
255 let kernel = AgentKernel::builder()
257 .llm(self.provider.clone())
258 .tools(sub_registry)
259 .max_steps(max_steps)
260 .build()
261 .map_err(|e| Error::Tool {
262 name: "sub_agent".into(),
263 message: format!("failed to build sub-agent kernel: {e}"),
264 })?;
265
266 let ctx = TurnContext {
267 messages: vec![
268 Message::system(agent_type.system_prompt_hint().to_string()),
269 Message::user(prompt.to_string()),
270 ],
271 step_events_tx: None,
272 plan_confirmed: false,
273 plan_buffer: None,
274 tool_specs: kernel.tools().specs(),
275 streaming: false,
276 permission_hook: self.permission_hook.clone(),
277 planning_mode: PlanningMode::default(),
278 exploring_plan_mode: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)),
279 permission_mode: PermissionMode::Default,
280 mailbox: None,
281 };
282
283 let outcome = kernel.run(ctx).await.map_err(|e| Error::Tool {
284 name: "sub_agent".into(),
285 message: format!("sub-agent failed: {e}"),
286 })?;
287
288 let finish_label = match &outcome.finish_reason {
289 FinishReason::NoMoreToolCalls => "NoMoreToolCalls",
290 FinishReason::BudgetExceeded => "BudgetExceeded",
291 FinishReason::ProviderStop(r) => r,
292 FinishReason::Stuck { .. } => "Stuck",
293 FinishReason::TranscriptLimit { .. } => "TranscriptLimit",
294 FinishReason::PlanPending => "PlanPending",
295 FinishReason::Cancelled => "Cancelled",
296 FinishReason::PermissionDenialLimit => "PermissionDenialLimit",
297 };
298
299 let final_text = outcome
300 .final_text
301 .unwrap_or_else(|| "(no final message)".to_string());
302
303 Ok(format!(
304 "[sub-agent finished: {finish_label}]\n{final_text}"
305 ))
306 }
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use crate::llm::{Completion, MockProvider, ToolCall};
313 use crate::tools::{
314 ApplyPatch, ListDir, LocalTransport, ReadFile, SearchFiles, ToolTransport, WriteFile,
315 };
316
317 fn mock_provider(script: Vec<Completion>) -> Arc<dyn LlmProvider> {
319 Arc::new(MockProvider::new(script))
320 }
321
322 fn full_tool_registry(workspace: &std::path::Path) -> ToolRegistry {
324 let transport: Arc<dyn ToolTransport> = Arc::new(LocalTransport);
325 ToolRegistry::new(transport)
326 .register(Arc::new(ReadFile::new(workspace)))
327 .register(Arc::new(ListDir::new(workspace)))
328 .register(Arc::new(SearchFiles::new(workspace)))
329 .register(Arc::new(WriteFile::new(workspace)))
330 .register(Arc::new(ApplyPatch::new(workspace)))
331 }
332
333 #[tokio::test]
334 async fn sub_agent_basic_dispatch() {
335 let provider = mock_provider(vec![Completion {
337 content: "The answer is 42.".to_string(),
338 tool_calls: vec![],
339 finish_reason: Some("stop".into()),
340 usage: None,
341 reasoning_content: None,
342 }]);
343
344 let tmp = tempfile::tempdir().unwrap();
345 let all_tools = full_tool_registry(tmp.path());
346
347 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
348
349 let result = sub
350 .execute(json!({"prompt": "What is the meaning of life?"}))
351 .await
352 .unwrap();
353
354 assert!(result.contains("NoMoreToolCalls"));
355 assert!(result.contains("The answer is 42."));
356 }
357
358 #[tokio::test]
359 async fn sub_agent_depth_limit_enforced() {
360 let provider = mock_provider(vec![]);
363 let tmp = tempfile::tempdir().unwrap();
364 let all_tools = full_tool_registry(tmp.path());
365
366 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 2, None);
367
368 let result = sub
369 .execute(json!({"prompt": "do something"}))
370 .await
371 .unwrap();
372
373 assert!(result.contains("depth limit reached"));
374 assert!(result.contains("max_depth=2"));
375 }
376
377 #[tokio::test]
378 async fn sub_agent_tool_subset_respected() {
379 let provider = mock_provider(vec![Completion {
381 content: "done".to_string(),
382 tool_calls: vec![],
383 finish_reason: Some("stop".into()),
384 usage: None,
385 reasoning_content: None,
386 }]);
387
388 let tmp = tempfile::tempdir().unwrap();
389 let all_tools = full_tool_registry(tmp.path());
390
391 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
392
393 let _ = sub
395 .execute(json!({"prompt": "read something", "tools": ["read_file"]}))
396 .await
397 .unwrap();
398
399 let defaults = SubAgent::default_tool_names();
403 assert!(!defaults.contains(&"apply_patch".to_string()));
404 assert!(!defaults.contains(&"write_file".to_string()));
405 assert!(defaults.contains(&"read_file".to_string()));
406 }
407
408 #[tokio::test]
409 async fn sub_agent_max_steps_capped() {
410 let tmp = tempfile::tempdir().unwrap();
413 std::fs::write(tmp.path().join("test.txt"), b"hello").unwrap();
415
416 let mut script = Vec::new();
417 for _ in 0..10 {
418 script.push(Completion {
419 content: "".to_string(),
420 tool_calls: vec![ToolCall {
421 id: "c1".into(),
422 name: "read_file".into(),
423 arguments: json!({"path": "test.txt"}),
424 }],
425 finish_reason: Some("tool_calls".into()),
426 usage: None,
427 reasoning_content: None,
428 });
429 }
430
431 let provider = mock_provider(script);
432 let all_tools = full_tool_registry(tmp.path());
433
434 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
435
436 let result = sub
437 .execute(json!({"prompt": "loop", "max_steps": 5}))
438 .await
439 .unwrap();
440
441 assert!(result.contains("BudgetExceeded"));
442 }
443
444 #[tokio::test]
445 async fn sub_agent_default_tools_are_read_only() {
446 let defaults = SubAgent::default_tool_names();
447 assert!(defaults.contains(&"read_file".to_string()));
448 assert!(defaults.contains(&"list_dir".to_string()));
449 assert!(defaults.contains(&"search_files".to_string()));
450 assert!(defaults.contains(&"web_fetch".to_string()));
451 assert_eq!(defaults.len(), 4);
452 }
453
454 #[tokio::test]
455 async fn sub_agent_missing_prompt_returns_error() {
456 let provider = mock_provider(vec![]);
457 let tmp = tempfile::tempdir().unwrap();
458 let all_tools = full_tool_registry(tmp.path());
459
460 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
461
462 let result = sub.execute(json!({})).await;
463 assert!(result.is_err());
464 let err = result.unwrap_err();
465 let msg = err.to_string();
466 assert!(msg.contains("missing required parameter: prompt"));
467 }
468
469 #[tokio::test]
470 async fn sub_agent_nested_depth_works() {
471 let tmp = tempfile::tempdir().unwrap();
481 std::fs::write(tmp.path().join("test.txt"), b"hello").unwrap();
482
483 let provider = mock_provider(vec![
484 Completion {
486 content: "".to_string(),
487 tool_calls: vec![ToolCall {
488 id: "c1".into(),
489 name: "sub_agent".into(),
490 arguments: json!({"prompt": "grandchild task"}),
491 }],
492 finish_reason: Some("tool_calls".into()),
493 usage: None,
494 reasoning_content: None,
495 },
496 Completion {
498 content: "".to_string(),
499 tool_calls: vec![ToolCall {
500 id: "c2".into(),
501 name: "sub_agent".into(),
502 arguments: json!({"prompt": "great-grandchild task"}),
503 }],
504 finish_reason: Some("tool_calls".into()),
505 usage: None,
506 reasoning_content: None,
507 },
508 Completion {
510 content: "grandchild done".to_string(),
511 tool_calls: vec![],
512 finish_reason: Some("stop".into()),
513 usage: None,
514 reasoning_content: None,
515 },
516 Completion {
518 content: "child done".to_string(),
519 tool_calls: vec![],
520 finish_reason: Some("stop".into()),
521 usage: None,
522 reasoning_content: None,
523 },
524 ]);
525
526 let all_tools = full_tool_registry(tmp.path());
527
528 let parent = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
530
531 let result = parent
532 .execute(json!({"prompt": "parent task", "tools": ["sub_agent", "read_file"]}))
533 .await
534 .unwrap();
535
536 assert!(result.contains("NoMoreToolCalls"), "result: {result}");
538 assert!(result.contains("child done"), "result: {result}");
539 }
544
545 #[test]
548 fn explore_agent_type_is_read_only() {
549 assert!(AgentType::Explore.is_read_only());
550 assert!(!AgentType::GeneralPurpose.is_read_only());
551 }
552
553 #[test]
554 fn agent_type_from_str_roundtrip() {
555 assert_eq!(AgentType::parse("explore"), Some(AgentType::Explore));
556 assert_eq!(
557 AgentType::parse("general_purpose"),
558 Some(AgentType::GeneralPurpose)
559 );
560 assert_eq!(AgentType::parse("unknown"), None);
561 assert_eq!(AgentType::parse(""), None);
562 }
563
564 #[test]
565 fn explore_agent_has_restricted_tool_list() {
566 let names = AgentType::Explore.allowed_tool_names().unwrap();
567 assert!(names.contains(&"read_file".to_string()));
569 assert!(names.contains(&"list_dir".to_string()));
570 assert!(names.contains(&"search_files".to_string()));
571 assert!(!names.contains(&"write_file".to_string()));
573 assert!(!names.contains(&"apply_patch".to_string()));
574 assert!(!names.contains(&"run_shell".to_string()));
575 }
576
577 #[test]
578 fn general_purpose_agent_has_no_forced_tool_list() {
579 assert!(AgentType::GeneralPurpose.allowed_tool_names().is_none());
580 }
581
582 #[test]
583 fn is_readonly_for_args_explore_returns_true() {
584 let tmp = tempfile::tempdir().unwrap();
585 let all_tools = full_tool_registry(tmp.path());
586 let provider = mock_provider(vec![]);
587 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
588
589 assert!(sub.is_readonly_for_args(&json!({"subagent_type": "explore"})));
590 }
591
592 #[test]
593 fn is_readonly_for_args_general_purpose_returns_false() {
594 let tmp = tempfile::tempdir().unwrap();
595 let all_tools = full_tool_registry(tmp.path());
596 let provider = mock_provider(vec![]);
597 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
598
599 assert!(!sub.is_readonly_for_args(&json!({"subagent_type": "general_purpose"})));
600 }
601
602 #[test]
603 fn is_readonly_for_args_missing_type_returns_false() {
604 let tmp = tempfile::tempdir().unwrap();
605 let all_tools = full_tool_registry(tmp.path());
606 let provider = mock_provider(vec![]);
607 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
608
609 assert!(!sub.is_readonly_for_args(&json!({"prompt": "hello"})));
610 }
611
612 #[test]
613 fn is_readonly_for_args_unknown_type_returns_false() {
614 let tmp = tempfile::tempdir().unwrap();
615 let all_tools = full_tool_registry(tmp.path());
616 let provider = mock_provider(vec![]);
617 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
618
619 assert!(!sub.is_readonly_for_args(&json!({"subagent_type": "super_agent"})));
620 }
621
622 #[tokio::test]
623 async fn explore_agent_dispatch_succeeds() {
624 let provider = mock_provider(vec![Completion {
625 content: "Exploration complete.".to_string(),
626 tool_calls: vec![],
627 finish_reason: Some("stop".into()),
628 usage: None,
629 reasoning_content: None,
630 }]);
631
632 let tmp = tempfile::tempdir().unwrap();
633 let all_tools = full_tool_registry(tmp.path());
634 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
635
636 let result = sub
637 .execute(json!({
638 "prompt": "Explore the workspace",
639 "subagent_type": "explore"
640 }))
641 .await
642 .unwrap();
643
644 assert!(result.contains("NoMoreToolCalls"));
645 assert!(result.contains("Exploration complete."));
646 }
647}