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 }]);
223
224 let tmp = tempfile::tempdir().unwrap();
225 let all_tools = full_tool_registry(tmp.path());
226
227 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
228
229 let result = sub
230 .execute(json!({"prompt": "What is the meaning of life?"}))
231 .await
232 .unwrap();
233
234 assert!(result.contains("NoMoreToolCalls"));
235 assert!(result.contains("The answer is 42."));
236 }
237
238 #[tokio::test]
239 async fn sub_agent_depth_limit_enforced() {
240 let provider = mock_provider(vec![]);
243 let tmp = tempfile::tempdir().unwrap();
244 let all_tools = full_tool_registry(tmp.path());
245
246 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 2, None);
247
248 let result = sub
249 .execute(json!({"prompt": "do something"}))
250 .await
251 .unwrap();
252
253 assert!(result.contains("depth limit reached"));
254 assert!(result.contains("max_depth=2"));
255 }
256
257 #[tokio::test]
258 async fn sub_agent_tool_subset_respected() {
259 let provider = mock_provider(vec![Completion {
261 content: "done".to_string(),
262 tool_calls: vec![],
263 finish_reason: Some("stop".into()),
264 usage: None,
265 }]);
266
267 let tmp = tempfile::tempdir().unwrap();
268 let all_tools = full_tool_registry(tmp.path());
269
270 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
271
272 let _ = sub
274 .execute(json!({"prompt": "read something", "tools": ["read_file"]}))
275 .await
276 .unwrap();
277
278 let defaults = SubAgent::default_tool_names();
282 assert!(!defaults.contains(&"apply_patch".to_string()));
283 assert!(!defaults.contains(&"write_file".to_string()));
284 assert!(defaults.contains(&"read_file".to_string()));
285 }
286
287 #[tokio::test]
288 async fn sub_agent_max_steps_capped() {
289 let tmp = tempfile::tempdir().unwrap();
292 std::fs::write(tmp.path().join("test.txt"), b"hello").unwrap();
294
295 let mut script = Vec::new();
296 for _ in 0..10 {
297 script.push(Completion {
298 content: "".to_string(),
299 tool_calls: vec![ToolCall {
300 id: "c1".into(),
301 name: "read_file".into(),
302 arguments: json!({"path": "test.txt"}),
303 }],
304 finish_reason: Some("tool_calls".into()),
305 usage: None,
306 });
307 }
308
309 let provider = mock_provider(script);
310 let all_tools = full_tool_registry(tmp.path());
311
312 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
313
314 let result = sub
315 .execute(json!({"prompt": "loop", "max_steps": 5}))
316 .await
317 .unwrap();
318
319 assert!(result.contains("BudgetExceeded"));
320 }
321
322 #[tokio::test]
323 async fn sub_agent_default_tools_are_read_only() {
324 let defaults = SubAgent::default_tool_names();
325 assert!(defaults.contains(&"read_file".to_string()));
326 assert!(defaults.contains(&"list_dir".to_string()));
327 assert!(defaults.contains(&"search_files".to_string()));
328 assert!(defaults.contains(&"web_fetch".to_string()));
329 assert_eq!(defaults.len(), 4);
330 }
331
332 #[tokio::test]
333 async fn sub_agent_missing_prompt_returns_error() {
334 let provider = mock_provider(vec![]);
335 let tmp = tempfile::tempdir().unwrap();
336 let all_tools = full_tool_registry(tmp.path());
337
338 let sub = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
339
340 let result = sub.execute(json!({})).await;
341 assert!(result.is_err());
342 let err = result.unwrap_err();
343 let msg = err.to_string();
344 assert!(msg.contains("missing required parameter: prompt"));
345 }
346
347 #[tokio::test]
348 async fn sub_agent_nested_depth_works() {
349 let tmp = tempfile::tempdir().unwrap();
359 std::fs::write(tmp.path().join("test.txt"), b"hello").unwrap();
360
361 let provider = mock_provider(vec![
362 Completion {
364 content: "".to_string(),
365 tool_calls: vec![ToolCall {
366 id: "c1".into(),
367 name: "sub_agent".into(),
368 arguments: json!({"prompt": "grandchild task"}),
369 }],
370 finish_reason: Some("tool_calls".into()),
371 usage: None,
372 },
373 Completion {
375 content: "".to_string(),
376 tool_calls: vec![ToolCall {
377 id: "c2".into(),
378 name: "sub_agent".into(),
379 arguments: json!({"prompt": "great-grandchild task"}),
380 }],
381 finish_reason: Some("tool_calls".into()),
382 usage: None,
383 },
384 Completion {
386 content: "grandchild done".to_string(),
387 tool_calls: vec![],
388 finish_reason: Some("stop".into()),
389 usage: None,
390 },
391 Completion {
393 content: "child done".to_string(),
394 tool_calls: vec![],
395 finish_reason: Some("stop".into()),
396 usage: None,
397 },
398 ]);
399
400 let all_tools = full_tool_registry(tmp.path());
401
402 let parent = SubAgent::new(tmp.path(), provider, all_tools, 2, 0, None);
404
405 let result = parent
406 .execute(json!({"prompt": "parent task", "tools": ["sub_agent", "read_file"]}))
407 .await
408 .unwrap();
409
410 assert!(result.contains("NoMoreToolCalls"), "result: {result}");
412 assert!(result.contains("child done"), "result: {result}");
413 }
418}