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            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        // Create a sub-agent at depth=2 with max_depth=2.
242        // It should refuse to spawn deeper.
243        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        // Parent passes tools: ["read_file"]; sub-agent must NOT have apply_patch.
261        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        // Execute with only read_file allowed
275        let _ = sub
276            .execute(json!({"prompt": "read something", "tools": ["read_file"]}))
277            .await
278            .unwrap();
279
280        // We can't easily inspect the sub-agent's registry from here,
281        // but we can verify the sub-agent ran successfully with just read_file.
282        // The real test is that apply_patch is NOT in the default set.
283        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        // Sub-agent with max_steps=5 should hit BudgetExceeded when
292        // MockProvider keeps asking for tool calls.
293        let tmp = tempfile::tempdir().unwrap();
294        // Create a file so read_file succeeds
295        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        // Test that a SubAgent at depth=0 can spawn a child (depth=1)
353        // which can spawn a grandchild (depth=2) — but the grandchild
354        // is at the limit (max_depth=2) so it cannot spawn deeper.
355        //
356        // Scripted completions consumed in order:
357        //   1. Child agent's first call → sub_agent tool call
358        //   2. Grandchild agent's first call → sub_agent tool call (denied by depth)
359        //   3. Grandchild agent's second call → "grandchild done"
360        //   4. Child agent's second call → "child done"
361        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            // 1. Child agent: calls sub_agent
366            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            // 2. Grandchild agent: tries to spawn deeper (denied)
378            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            // 3. Grandchild agent: finishes after seeing depth error
390            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            // 4. Child agent: finishes
398            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        // Parent at depth=0, max_depth=2
410        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        // The parent should complete successfully with child's result
418        assert!(result.contains("NoMoreToolCalls"), "result: {result}");
419        assert!(result.contains("child done"), "result: {result}");
420        // The grandchild's result is embedded in the child's transcript
421        // (as a tool result), not in the final message. The depth limit
422        // was enforced: the grandchild could not spawn deeper.
423        // This test verifies the nesting works without panicking.
424    }
425}