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//!
12//! # Agent types (`subagent_type`)
13//!
14//! Inspired by fake-cc's `AgentTool`, this tool accepts an optional
15//! `subagent_type` parameter that selects a named agent personality:
16//!
17//! - `"explore"`: read-only tools only; declared [`ReadOnly`](crate::tools::ToolSideEffect::ReadOnly)
18//!   so the dispatch layer can run multiple explore sub-agents **in parallel**.
19//! - `"general_purpose"` (default): full tool access; declared `External`.
20
21use 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// ---------------------------------------------------------------------------
35// AgentType — named sub-agent personality
36// ---------------------------------------------------------------------------
37
38/// Named sub-agent personality, aligned with fake-cc's `subagent_type`.
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum AgentType {
41    /// Read-only exploration agent. Restricts tool access to read-only tools.
42    /// Declared `ReadOnly` so the parallel-dispatch layer can run multiple
43    /// explore sub-agents concurrently.
44    Explore,
45    /// General-purpose agent with access to the full (parent-provided) tool
46    /// registry. Declared `External` (the conservative default).
47    GeneralPurpose,
48}
49
50impl AgentType {
51    /// Parse from the `subagent_type` JSON string.
52    /// Returns `None` for unknown values (caller falls back to `GeneralPurpose`).
53    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    /// `true` for agent types that only read, never write.
62    pub fn is_read_only(self) -> bool {
63        matches!(self, Self::Explore)
64    }
65
66    /// System prompt fragment to prepend for this agent type.
67    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    /// Restricted tool names for this agent type, or `None` for "use caller-supplied list".
81    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
96/// The sub-agent tool.
97///
98/// Constructed with:
99/// - `workspace`: path for sandboxed tools
100/// - `provider`: the LLM provider (shared Arc from parent)
101/// - `all_tools`: the full tool registry from which the sub-agent can draw
102/// - `max_depth`: absolute depth limit (env-configured)
103/// - `current_depth`: how deep we already are (passed from parent)
104/// - `permission_hook`: optional permission hook inherited from the parent agent
105pub 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    /// Build a restricted tool registry containing only the named tools.
134    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    /// Default tool set when no `tools` arg is given: read-only tools.
145    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    /// Returns `true` when `subagent_type` is `"explore"`, making the dispatch
190    /// layer treat this call as read-only and eligible for parallel execution.
191    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        // Resolve agent type (defaults to GeneralPurpose if absent or unknown).
209        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        // Tool list: explore type enforces its own read-only set;
218        // general_purpose respects the caller-supplied list or the default.
219        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        // Depth limit check
233        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        // Build the sub-agent's tool registry
241        let mut sub_registry = self.build_sub_registry(&tool_names);
242
243        // Register a fresh SubAgent with incremented depth so the child
244        // can also spawn sub-agents (up to the limit).
245        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        // Build and run the sub-agent using AgentKernel (stateless, single-turn)
256        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    /// Helper: create a MockProvider with the given scripted completions.
318    fn mock_provider(script: Vec<Completion>) -> Arc<dyn LlmProvider> {
319        Arc::new(MockProvider::new(script))
320    }
321
322    /// Helper: build a full tool registry with read-only + write tools.
323    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        // Sub-agent gets one completion with no tool calls → NoMoreToolCalls
336        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        // Create a sub-agent at depth=2 with max_depth=2.
361        // It should refuse to spawn deeper.
362        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        // Parent passes tools: ["read_file"]; sub-agent must NOT have apply_patch.
380        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        // Execute with only read_file allowed
394        let _ = sub
395            .execute(json!({"prompt": "read something", "tools": ["read_file"]}))
396            .await
397            .unwrap();
398
399        // We can't easily inspect the sub-agent's registry from here,
400        // but we can verify the sub-agent ran successfully with just read_file.
401        // The real test is that apply_patch is NOT in the default set.
402        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        // Sub-agent with max_steps=5 should hit BudgetExceeded when
411        // MockProvider keeps asking for tool calls.
412        let tmp = tempfile::tempdir().unwrap();
413        // Create a file so read_file succeeds
414        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        // Test that a SubAgent at depth=0 can spawn a child (depth=1)
472        // which can spawn a grandchild (depth=2) — but the grandchild
473        // is at the limit (max_depth=2) so it cannot spawn deeper.
474        //
475        // Scripted completions consumed in order:
476        //   1. Child agent's first call → sub_agent tool call
477        //   2. Grandchild agent's first call → sub_agent tool call (denied by depth)
478        //   3. Grandchild agent's second call → "grandchild done"
479        //   4. Child agent's second call → "child done"
480        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            // 1. Child agent: calls sub_agent
485            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            // 2. Grandchild agent: tries to spawn deeper (denied)
497            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            // 3. Grandchild agent: finishes after seeing depth error
509            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            // 4. Child agent: finishes
517            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        // Parent at depth=0, max_depth=2
529        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        // The parent should complete successfully with child's result
537        assert!(result.contains("NoMoreToolCalls"), "result: {result}");
538        assert!(result.contains("child done"), "result: {result}");
539        // The grandchild's result is embedded in the child's transcript
540        // (as a tool result), not in the final message. The depth limit
541        // was enforced: the grandchild could not spawn deeper.
542        // This test verifies the nesting works without panicking.
543    }
544
545    // ── AgentType & is_readonly_for_args tests (Goal 175) ─────────────────
546
547    #[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        // Must contain read tools
568        assert!(names.contains(&"read_file".to_string()));
569        assert!(names.contains(&"list_dir".to_string()));
570        assert!(names.contains(&"search_files".to_string()));
571        // Must NOT contain write tools
572        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}