1use async_trait::async_trait;
13use serde_json::{json, Value};
14use std::sync::Arc;
15
16use crate::agent::{Agent, FinishReason, PermissionHook};
17use crate::error::{Error, Result};
18use crate::llm::{LlmProvider, ToolSpec};
19use crate::tools::{Tool, ToolRegistry};
20
21pub struct SubAgent {
31 workspace: std::path::PathBuf,
32 provider: Arc<dyn LlmProvider>,
33 all_tools: ToolRegistry,
34 max_depth: usize,
35 current_depth: usize,
36 permission_hook: Option<PermissionHook>,
37}
38
39impl SubAgent {
40 pub fn new(
41 workspace: impl Into<std::path::PathBuf>,
42 provider: Arc<dyn LlmProvider>,
43 all_tools: ToolRegistry,
44 max_depth: usize,
45 current_depth: usize,
46 permission_hook: Option<PermissionHook>,
47 ) -> Self {
48 Self {
49 workspace: workspace.into(),
50 provider,
51 all_tools,
52 max_depth,
53 current_depth,
54 permission_hook,
55 }
56 }
57
58 fn build_sub_registry(&self, tool_names: &[String]) -> ToolRegistry {
60 let mut reg = self.all_tools.with_same_transport();
61 for name in tool_names {
62 if let Some(tool) = self.all_tools.get(name) {
63 reg = reg.register(tool);
64 }
65 }
66 reg
67 }
68
69 fn default_tool_names() -> Vec<String> {
71 vec![
72 "read_file".to_string(),
73 "list_dir".to_string(),
74 "search_files".to_string(),
75 "web_fetch".to_string(),
76 ]
77 }
78}
79
80#[async_trait]
81impl Tool for SubAgent {
82 fn spec(&self) -> ToolSpec {
83 ToolSpec {
84 name: "sub_agent".into(),
85 description: "Spawn a fresh agent with its own transcript to complete a focused sub-task. Returns the sub-agent's final response.".into(),
86 parameters: json!({
87 "type": "object",
88 "properties": {
89 "prompt": {
90 "type": "string",
91 "description": "The goal / prompt for the sub-agent"
92 },
93 "max_steps": {
94 "type": "integer",
95 "description": "Maximum steps for the sub-agent (default 30, capped at parent's remaining budget)",
96 "default": 30
97 },
98 "tools": {
99 "type": "array",
100 "items": { "type": "string" },
101 "description": "Optional list of tool names to make available to the sub-agent. Default: read_file, list_dir, search_files, web_fetch"
102 }
103 },
104 "required": ["prompt"]
105 }),
106 }
107 }
108
109 async fn execute(&self, arguments: Value) -> Result<String> {
110 let prompt = arguments["prompt"]
111 .as_str()
112 .ok_or_else(|| Error::BadToolArgs {
113 name: "sub_agent".into(),
114 message: "missing required parameter: prompt".to_string(),
115 })?;
116
117 let max_steps = arguments["max_steps"].as_i64().unwrap_or(30).clamp(1, 100) as usize;
118
119 let tool_names: Vec<String> = arguments["tools"]
120 .as_array()
121 .map(|arr| {
122 arr.iter()
123 .filter_map(|v| v.as_str().map(String::from))
124 .collect()
125 })
126 .unwrap_or_else(Self::default_tool_names);
127
128 if self.current_depth >= self.max_depth {
130 return Ok(format!(
131 "ERROR: sub-agent depth limit reached (max_depth={}). Cannot spawn deeper sub-agent.",
132 self.max_depth
133 ));
134 }
135
136 let mut sub_registry = self.build_sub_registry(&tool_names);
138
139 let child_sub = SubAgent::new(
142 &self.workspace,
143 self.provider.clone(),
144 self.all_tools.clone(),
145 self.max_depth,
146 self.current_depth + 1,
147 self.permission_hook.clone(),
148 );
149 sub_registry = sub_registry.register(Arc::new(child_sub));
150
151 let builder = Agent::builder()
153 .llm(self.provider.clone())
154 .tools(sub_registry)
155 .system_prompt("You are a focused sub-agent. Complete the given task using the available tools. Be concise.")
156 .max_steps(max_steps);
157
158 let builder = builder.permission_hook_opt(self.permission_hook.clone());
160
161 let mut agent = builder.build().map_err(|e| Error::Tool {
162 name: "sub_agent".into(),
163 message: format!("failed to build sub-agent: {e}"),
164 })?;
165
166 let outcome = agent.run(prompt).await.map_err(|e| Error::Tool {
167 name: "sub_agent".into(),
168 message: format!("sub-agent failed: {e}"),
169 })?;
170
171 let finish_label = match &outcome.finish {
172 FinishReason::NoMoreToolCalls => "NoMoreToolCalls",
173 FinishReason::BudgetExceeded => "BudgetExceeded",
174 FinishReason::ProviderStop(r) => r,
175 FinishReason::Stuck { .. } => "Stuck",
176 FinishReason::TranscriptLimit { .. } => "TranscriptLimit",
177 FinishReason::PlanPending => "PlanPending",
178 };
179
180 let final_text = outcome
181 .final_message
182 .unwrap_or_else(|| "(no final message)".to_string());
183
184 Ok(format!(
185 "[sub-agent finished: {finish_label}]\n{final_text}"
186 ))
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193 use crate::llm::{Completion, MockProvider, ToolCall};
194 use crate::tools::{
195 ApplyPatch, ListDir, LocalTransport, ReadFile, SearchFiles, ToolTransport, WriteFile,
196 };
197
198 fn mock_provider(script: Vec<Completion>) -> Arc<dyn LlmProvider> {
200 Arc::new(MockProvider::new(script))
201 }
202
203 fn full_tool_registry(workspace: &std::path::Path) -> ToolRegistry {
205 let transport: Arc<dyn ToolTransport> = Arc::new(LocalTransport);
206 ToolRegistry::new(transport)
207 .register(Arc::new(ReadFile::new(workspace)))
208 .register(Arc::new(ListDir::new(workspace)))
209 .register(Arc::new(SearchFiles::new(workspace)))
210 .register(Arc::new(WriteFile::new(workspace)))
211 .register(Arc::new(ApplyPatch::new(workspace)))
212 }
213
214 #[tokio::test]
215 async fn sub_agent_basic_dispatch() {
216 let provider = mock_provider(vec![Completion {
218 content: "The answer is 42.".to_string(),
219 tool_calls: vec![],
220 finish_reason: Some("stop".into()),
221 usage: None,
222 reasoning_content: None,
223 }]);
224
225 let tmp = tempfile::tempdir().unwrap();
226 let all_tools = full_tool_registry(tmp.path());
227
228 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
229
230 let result = sub
231 .execute(json!({"prompt": "What is the meaning of life?"}))
232 .await
233 .unwrap();
234
235 assert!(result.contains("NoMoreToolCalls"));
236 assert!(result.contains("The answer is 42."));
237 }
238
239 #[tokio::test]
240 async fn sub_agent_depth_limit_enforced() {
241 let provider = mock_provider(vec![]);
244 let tmp = tempfile::tempdir().unwrap();
245 let all_tools = full_tool_registry(tmp.path());
246
247 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 2, None);
248
249 let result = sub
250 .execute(json!({"prompt": "do something"}))
251 .await
252 .unwrap();
253
254 assert!(result.contains("depth limit reached"));
255 assert!(result.contains("max_depth=2"));
256 }
257
258 #[tokio::test]
259 async fn sub_agent_tool_subset_respected() {
260 let provider = mock_provider(vec![Completion {
262 content: "done".to_string(),
263 tool_calls: vec![],
264 finish_reason: Some("stop".into()),
265 usage: None,
266 reasoning_content: None,
267 }]);
268
269 let tmp = tempfile::tempdir().unwrap();
270 let all_tools = full_tool_registry(tmp.path());
271
272 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
273
274 let _ = sub
276 .execute(json!({"prompt": "read something", "tools": ["read_file"]}))
277 .await
278 .unwrap();
279
280 let defaults = SubAgent::default_tool_names();
284 assert!(!defaults.contains(&"apply_patch".to_string()));
285 assert!(!defaults.contains(&"write_file".to_string()));
286 assert!(defaults.contains(&"read_file".to_string()));
287 }
288
289 #[tokio::test]
290 async fn sub_agent_max_steps_capped() {
291 let tmp = tempfile::tempdir().unwrap();
294 std::fs::write(tmp.path().join("test.txt"), b"hello").unwrap();
296
297 let mut script = Vec::new();
298 for _ in 0..10 {
299 script.push(Completion {
300 content: "".to_string(),
301 tool_calls: vec![ToolCall {
302 id: "c1".into(),
303 name: "read_file".into(),
304 arguments: json!({"path": "test.txt"}),
305 }],
306 finish_reason: Some("tool_calls".into()),
307 usage: None,
308 reasoning_content: None,
309 });
310 }
311
312 let provider = mock_provider(script);
313 let all_tools = full_tool_registry(tmp.path());
314
315 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
316
317 let result = sub
318 .execute(json!({"prompt": "loop", "max_steps": 5}))
319 .await
320 .unwrap();
321
322 assert!(result.contains("BudgetExceeded"));
323 }
324
325 #[tokio::test]
326 async fn sub_agent_default_tools_are_read_only() {
327 let defaults = SubAgent::default_tool_names();
328 assert!(defaults.contains(&"read_file".to_string()));
329 assert!(defaults.contains(&"list_dir".to_string()));
330 assert!(defaults.contains(&"search_files".to_string()));
331 assert!(defaults.contains(&"web_fetch".to_string()));
332 assert_eq!(defaults.len(), 4);
333 }
334
335 #[tokio::test]
336 async fn sub_agent_missing_prompt_returns_error() {
337 let provider = mock_provider(vec![]);
338 let tmp = tempfile::tempdir().unwrap();
339 let all_tools = full_tool_registry(tmp.path());
340
341 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
342
343 let result = sub.execute(json!({})).await;
344 assert!(result.is_err());
345 let err = result.unwrap_err();
346 let msg = err.to_string();
347 assert!(msg.contains("missing required parameter: prompt"));
348 }
349
350 #[tokio::test]
351 async fn sub_agent_nested_depth_works() {
352 let tmp = tempfile::tempdir().unwrap();
362 std::fs::write(tmp.path().join("test.txt"), b"hello").unwrap();
363
364 let provider = mock_provider(vec![
365 Completion {
367 content: "".to_string(),
368 tool_calls: vec![ToolCall {
369 id: "c1".into(),
370 name: "sub_agent".into(),
371 arguments: json!({"prompt": "grandchild task"}),
372 }],
373 finish_reason: Some("tool_calls".into()),
374 usage: None,
375 reasoning_content: None,
376 },
377 Completion {
379 content: "".to_string(),
380 tool_calls: vec![ToolCall {
381 id: "c2".into(),
382 name: "sub_agent".into(),
383 arguments: json!({"prompt": "great-grandchild task"}),
384 }],
385 finish_reason: Some("tool_calls".into()),
386 usage: None,
387 reasoning_content: None,
388 },
389 Completion {
391 content: "grandchild done".to_string(),
392 tool_calls: vec![],
393 finish_reason: Some("stop".into()),
394 usage: None,
395 reasoning_content: None,
396 },
397 Completion {
399 content: "child done".to_string(),
400 tool_calls: vec![],
401 finish_reason: Some("stop".into()),
402 usage: None,
403 reasoning_content: None,
404 },
405 ]);
406
407 let all_tools = full_tool_registry(tmp.path());
408
409 let parent = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
411
412 let result = parent
413 .execute(json!({"prompt": "parent task", "tools": ["sub_agent", "read_file"]}))
414 .await
415 .unwrap();
416
417 assert!(result.contains("NoMoreToolCalls"), "result: {result}");
419 assert!(result.contains("child done"), "result: {result}");
420 }
425}