Skip to main content

recursive/tools/
sub_agent.rs

1//! Sub-agent tool: spawn a fresh agent loop with a restricted tool subset.
2//!
3//! The parent agent can delegate focused sub-tasks to a child agent that
4//! starts with an empty transcript. This prevents the parent's context
5//! window from growing with intermediate exploration steps.
6//!
7//! Recursive safety: a depth limit (env `RECURSIVE_SUBAGENT_MAX_DEPTH`,
8//! default 2) prevents unbounded nesting. Each nested invocation increments
9//! a counter; when the limit is reached, the tool returns an error string
10//! instead of spawning.
11
12use 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
21/// The sub-agent tool.
22///
23/// Constructed with:
24/// - `workspace`: path for sandboxed tools
25/// - `provider`: the LLM provider (shared Arc from parent)
26/// - `all_tools`: the full tool registry from which the sub-agent can draw
27/// - `max_depth`: absolute depth limit (env-configured)
28/// - `current_depth`: how deep we already are (passed from parent)
29/// - `permission_hook`: optional permission hook inherited from the parent agent
30pub 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    /// Build a restricted tool registry containing only the named tools.
59    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    /// Default tool set when no `tools` arg is given: read-only tools.
70    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        // Depth limit check
129        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        // Build the sub-agent's tool registry
137        let mut sub_registry = self.build_sub_registry(&tool_names);
138
139        // Register a fresh SubAgent with incremented depth so the child
140        // can also spawn sub-agents (up to the limit).
141        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        // Build and run the sub-agent
152        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        // Inherit the parent's permission hook, if any
159        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    /// Helper: create a MockProvider with the given scripted completions.
199    fn mock_provider(script: Vec<Completion>) -> Arc<dyn LlmProvider> {
200        Arc::new(MockProvider::new(script))
201    }
202
203    /// Helper: build a full tool registry with read-only + write tools.
204    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        // Sub-agent gets one completion with no tool calls → NoMoreToolCalls
217        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        // Create a sub-agent at depth=2 with max_depth=2.
241        // It should refuse to spawn deeper.
242        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        // Parent passes tools: ["read_file"]; sub-agent must NOT have apply_patch.
260        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        // Execute with only read_file allowed
273        let _ = sub
274            .execute(json!({"prompt": "read something", "tools": ["read_file"]}))
275            .await
276            .unwrap();
277
278        // We can't easily inspect the sub-agent's registry from here,
279        // but we can verify the sub-agent ran successfully with just read_file.
280        // The real test is that apply_patch is NOT in the default set.
281        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        // Sub-agent with max_steps=5 should hit BudgetExceeded when
290        // MockProvider keeps asking for tool calls.
291        let tmp = tempfile::tempdir().unwrap();
292        // Create a file so read_file succeeds
293        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        // Test that a SubAgent at depth=0 can spawn a child (depth=1)
350        // which can spawn a grandchild (depth=2) — but the grandchild
351        // is at the limit (max_depth=2) so it cannot spawn deeper.
352        //
353        // Scripted completions consumed in order:
354        //   1. Child agent's first call → sub_agent tool call
355        //   2. Grandchild agent's first call → sub_agent tool call (denied by depth)
356        //   3. Grandchild agent's second call → "grandchild done"
357        //   4. Child agent's second call → "child done"
358        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            // 1. Child agent: calls sub_agent
363            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            // 2. Grandchild agent: tries to spawn deeper (denied)
374            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            // 3. Grandchild agent: finishes after seeing depth error
385            Completion {
386                content: "grandchild done".to_string(),
387                tool_calls: vec![],
388                finish_reason: Some("stop".into()),
389                usage: None,
390            },
391            // 4. Child agent: finishes
392            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        // Parent at depth=0, max_depth=2
403        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        // The parent should complete successfully with child's result
411        assert!(result.contains("NoMoreToolCalls"), "result: {result}");
412        assert!(result.contains("child done"), "result: {result}");
413        // The grandchild's result is embedded in the child's transcript
414        // (as a tool result), not in the final message. The depth limit
415        // was enforced: the grandchild could not spawn deeper.
416        // This test verifies the nesting works without panicking.
417    }
418}