Skip to main content

agent_code_lib/tools/
agent.rs

1//! Agent tool: spawn subagents for parallel task execution.
2//!
3//! Launches a new agent with its own query loop, isolated context,
4//! and optionally a separate working directory. The subagent runs
5//! the same tool set and LLM client but with its own conversation
6//! history and permission scope.
7//!
8//! # Isolation modes
9//!
10//! - Default: shares the parent's working directory
11//! - `worktree`: creates a temporary git worktree for isolated file changes
12
13use async_trait::async_trait;
14use serde_json::json;
15use std::path::PathBuf;
16
17use super::{Tool, ToolContext, ToolResult};
18use crate::error::ToolError;
19
20pub struct AgentTool;
21
22#[async_trait]
23impl Tool for AgentTool {
24    fn name(&self) -> &'static str {
25        "Agent"
26    }
27
28    fn description(&self) -> &'static str {
29        "Launch a subagent to handle a complex task autonomously. The agent \
30         runs with its own conversation context and can execute tools in parallel \
31         with the main session."
32    }
33
34    fn prompt(&self) -> String {
35        "Launch a subagent for complex, multi-step tasks. Each agent gets its own \
36         conversation context and tool access. Use for:\n\
37         - Parallel research or code exploration\n\
38         - Tasks that would clutter the main conversation\n\
39         - Independent subtasks that don't depend on each other\n\n\
40         Provide a clear, complete prompt so the agent can work autonomously."
41            .to_string()
42    }
43
44    fn input_schema(&self) -> serde_json::Value {
45        json!({
46            "type": "object",
47            "required": ["description", "prompt"],
48            "properties": {
49                "description": {
50                    "type": "string",
51                    "description": "Short (3-5 word) description of the task"
52                },
53                "prompt": {
54                    "type": "string",
55                    "description": "The complete task for the agent to perform"
56                },
57                "model": {
58                    "type": "string",
59                    "enum": ["sonnet", "opus", "haiku"],
60                    "description": "Optional model override for this agent"
61                },
62                "isolation": {
63                    "type": "string",
64                    "enum": ["worktree"],
65                    "description": "Run in an isolated git worktree"
66                },
67                "run_in_background": {
68                    "type": "boolean",
69                    "description": "Run the agent in the background"
70                }
71            }
72        })
73    }
74
75    fn is_read_only(&self) -> bool {
76        false
77    }
78
79    fn is_concurrency_safe(&self) -> bool {
80        false
81    }
82
83    fn max_result_size_chars(&self) -> usize {
84        200_000
85    }
86
87    async fn call(
88        &self,
89        input: serde_json::Value,
90        ctx: &ToolContext,
91    ) -> Result<ToolResult, ToolError> {
92        let description = input
93            .get("description")
94            .and_then(|v| v.as_str())
95            .ok_or_else(|| ToolError::InvalidInput("'description' is required".into()))?;
96
97        let prompt = input
98            .get("prompt")
99            .and_then(|v| v.as_str())
100            .ok_or_else(|| ToolError::InvalidInput("'prompt' is required".into()))?;
101
102        let isolation = input.get("isolation").and_then(|v| v.as_str());
103
104        // Determine working directory (worktree isolation if requested).
105        let agent_cwd = if isolation == Some("worktree") {
106            match create_worktree(&ctx.cwd).await {
107                Ok(path) => path,
108                Err(e) => {
109                    return Ok(ToolResult::error(format!("Failed to create worktree: {e}")));
110                }
111            }
112        } else {
113            ctx.cwd.clone()
114        };
115
116        // Spawn the subagent as a subprocess (agent --prompt).
117        // This gives full isolation — separate process, separate context.
118        let agent_binary = std::env::current_exe()
119            .map(|p| p.display().to_string())
120            .unwrap_or_else(|_| "agent".to_string());
121
122        let mut cmd = tokio::process::Command::new(&agent_binary);
123        cmd.arg("--prompt")
124            .arg(prompt)
125            .current_dir(&agent_cwd)
126            .stdout(std::process::Stdio::piped())
127            .stderr(std::process::Stdio::piped());
128
129        // Pass through environment so the subagent uses the same provider.
130        for var in &[
131            "AGENT_CODE_API_KEY",
132            "ANTHROPIC_API_KEY",
133            "OPENAI_API_KEY",
134            "XAI_API_KEY",
135            "GOOGLE_API_KEY",
136            "DEEPSEEK_API_KEY",
137            "GROQ_API_KEY",
138            "MISTRAL_API_KEY",
139            "TOGETHER_API_KEY",
140            "AGENT_CODE_API_BASE_URL",
141            "AGENT_CODE_MODEL",
142        ] {
143            if let Ok(val) = std::env::var(var) {
144                cmd.env(var, val);
145            }
146        }
147
148        let timeout = std::time::Duration::from_secs(300); // 5 minute timeout.
149
150        let result = tokio::select! {
151            r = cmd.output() => {
152                match r {
153                    Ok(output) => {
154                        let stdout = String::from_utf8_lossy(&output.stdout).to_string();
155                        let stderr = String::from_utf8_lossy(&output.stderr).to_string();
156
157                        let mut content = format!("Agent ({description}) completed.\n\n");
158                        if !stdout.is_empty() {
159                            content.push_str(&stdout);
160                        }
161                        if !stderr.is_empty() && !output.status.success() {
162                            content.push_str(&format!("\nAgent errors:\n{stderr}"));
163                        }
164
165                        // Clean up worktree if it was created.
166                        if isolation == Some("worktree") {
167                            let _ = cleanup_worktree(&agent_cwd).await;
168                        }
169
170                        Ok(ToolResult {
171                            content,
172                            is_error: !output.status.success(),
173                        })
174                    }
175                    Err(e) => Err(ToolError::ExecutionFailed(format!(
176                        "Failed to spawn agent: {e}"
177                    ))),
178                }
179            }
180            _ = tokio::time::sleep(timeout) => {
181                Err(ToolError::Timeout(300_000))
182            }
183            _ = ctx.cancel.cancelled() => {
184                Err(ToolError::Cancelled)
185            }
186        };
187
188        result
189    }
190}
191
192/// Create a temporary git worktree for isolated execution.
193async fn create_worktree(base_cwd: &PathBuf) -> Result<PathBuf, String> {
194    let branch_name = format!(
195        "agent-{}",
196        uuid::Uuid::new_v4()
197            .to_string()
198            .split('-')
199            .next()
200            .unwrap_or("tmp")
201    );
202    let worktree_path = std::env::temp_dir().join(format!("agent-wt-{branch_name}"));
203
204    let output = tokio::process::Command::new("git")
205        .args(["worktree", "add", "-b", &branch_name])
206        .arg(&worktree_path)
207        .current_dir(base_cwd)
208        .output()
209        .await
210        .map_err(|e| format!("git worktree add failed: {e}"))?;
211
212    if !output.status.success() {
213        let stderr = String::from_utf8_lossy(&output.stderr);
214        return Err(format!("git worktree add failed: {stderr}"));
215    }
216
217    Ok(worktree_path)
218}
219
220/// Clean up a temporary worktree.
221async fn cleanup_worktree(worktree_path: &PathBuf) -> Result<(), String> {
222    // Check if any changes were made.
223    let status = tokio::process::Command::new("git")
224        .args(["status", "--porcelain"])
225        .current_dir(worktree_path)
226        .output()
227        .await
228        .map_err(|e| format!("git status failed: {e}"))?;
229
230    let has_changes = !String::from_utf8_lossy(&status.stdout).trim().is_empty();
231
232    if !has_changes {
233        // No changes — remove the worktree.
234        let _ = tokio::process::Command::new("git")
235            .args(["worktree", "remove", "--force"])
236            .arg(worktree_path)
237            .output()
238            .await;
239    }
240    // If there are changes, leave the worktree for the user to inspect.
241
242    Ok(())
243}