perspt_agent/
tools.rs

1//! Agent Tooling
2//!
3//! Tools available to agents for interacting with the workspace.
4//! Implements: read_file, search_code, apply_patch, run_command
5
6use std::collections::HashMap;
7use std::fs;
8use std::path::{Path, PathBuf};
9use std::process::{Command, Stdio};
10use tokio::io::{AsyncBufReadExt, BufReader};
11use tokio::process::Command as AsyncCommand;
12
13/// Tool result from agent execution
14#[derive(Debug, Clone)]
15pub struct ToolResult {
16    pub tool_name: String,
17    pub success: bool,
18    pub output: String,
19    pub error: Option<String>,
20}
21
22impl ToolResult {
23    pub fn success(tool_name: &str, output: String) -> Self {
24        Self {
25            tool_name: tool_name.to_string(),
26            success: true,
27            output,
28            error: None,
29        }
30    }
31
32    pub fn failure(tool_name: &str, error: String) -> Self {
33        Self {
34            tool_name: tool_name.to_string(),
35            success: false,
36            output: String::new(),
37            error: Some(error),
38        }
39    }
40}
41
42/// Tool call request from LLM
43#[derive(Debug, Clone)]
44pub struct ToolCall {
45    pub name: String,
46    pub arguments: HashMap<String, String>,
47}
48
49/// Agent tools for workspace interaction
50pub struct AgentTools {
51    /// Working directory (sandbox root)
52    working_dir: PathBuf,
53    /// Whether to require user approval for commands
54    require_approval: bool,
55    /// Event sender for streaming output
56    event_sender: Option<perspt_core::events::channel::EventSender>,
57}
58
59impl AgentTools {
60    /// Create new agent tools instance
61    pub fn new(working_dir: PathBuf, require_approval: bool) -> Self {
62        Self {
63            working_dir,
64            require_approval,
65            event_sender: None,
66        }
67    }
68
69    /// Set event sender for streaming output
70    pub fn set_event_sender(&mut self, sender: perspt_core::events::channel::EventSender) {
71        self.event_sender = Some(sender);
72    }
73
74    /// Execute a tool call
75    pub async fn execute(&self, call: &ToolCall) -> ToolResult {
76        match call.name.as_str() {
77            "read_file" => self.read_file(call),
78            "search_code" => self.search_code(call),
79            "apply_patch" => self.apply_patch(call),
80            "run_command" => self.run_command(call).await,
81            "list_files" => self.list_files(call),
82            "write_file" => self.write_file(call),
83            // Power Tools (OS-level)
84            "sed_replace" => self.sed_replace(call),
85            "awk_filter" => self.awk_filter(call),
86            "diff_files" => self.diff_files(call),
87            _ => ToolResult::failure(&call.name, format!("Unknown tool: {}", call.name)),
88        }
89    }
90
91    /// Read a file's contents
92    fn read_file(&self, call: &ToolCall) -> ToolResult {
93        let path = match call.arguments.get("path") {
94            Some(p) => self.resolve_path(p),
95            None => return ToolResult::failure("read_file", "Missing 'path' argument".to_string()),
96        };
97
98        match fs::read_to_string(&path) {
99            Ok(content) => ToolResult::success("read_file", content),
100            Err(e) => ToolResult::failure("read_file", format!("Failed to read {:?}: {}", path, e)),
101        }
102    }
103
104    /// Search for code patterns using grep
105    fn search_code(&self, call: &ToolCall) -> ToolResult {
106        let query = match call.arguments.get("query") {
107            Some(q) => q,
108            None => {
109                return ToolResult::failure("search_code", "Missing 'query' argument".to_string())
110            }
111        };
112
113        let path = call
114            .arguments
115            .get("path")
116            .map(|p| self.resolve_path(p))
117            .unwrap_or_else(|| self.working_dir.clone());
118
119        // Use ripgrep if available, fallback to grep
120        let output = Command::new("rg")
121            .args(["--json", "-n", query])
122            .current_dir(&path)
123            .output()
124            .or_else(|_| {
125                Command::new("grep")
126                    .args(["-rn", query, "."])
127                    .current_dir(&path)
128                    .output()
129            });
130
131        match output {
132            Ok(out) => {
133                let stdout = String::from_utf8_lossy(&out.stdout).to_string();
134                ToolResult::success("search_code", stdout)
135            }
136            Err(e) => ToolResult::failure("search_code", format!("Search failed: {}", e)),
137        }
138    }
139
140    /// Apply a patch to a file
141    fn apply_patch(&self, call: &ToolCall) -> ToolResult {
142        let path = match call.arguments.get("path") {
143            Some(p) => self.resolve_path(p),
144            None => {
145                return ToolResult::failure("apply_patch", "Missing 'path' argument".to_string())
146            }
147        };
148
149        let content = match call.arguments.get("content") {
150            Some(c) => c,
151            None => {
152                return ToolResult::failure("apply_patch", "Missing 'content' argument".to_string())
153            }
154        };
155
156        // Create parent directories if needed
157        if let Some(parent) = path.parent() {
158            if let Err(e) = fs::create_dir_all(parent) {
159                return ToolResult::failure(
160                    "apply_patch",
161                    format!("Failed to create directories: {}", e),
162                );
163            }
164        }
165
166        match fs::write(&path, content) {
167            Ok(_) => ToolResult::success("apply_patch", format!("Successfully wrote {:?}", path)),
168            Err(e) => {
169                ToolResult::failure("apply_patch", format!("Failed to write {:?}: {}", path, e))
170            }
171        }
172    }
173
174    /// Run a shell command (requires approval unless auto-approve is set)
175    async fn run_command(&self, call: &ToolCall) -> ToolResult {
176        let cmd_str = match call.arguments.get("command") {
177            Some(c) => c,
178            None => {
179                return ToolResult::failure("run_command", "Missing 'command' argument".to_string())
180            }
181        };
182
183        if self.require_approval {
184            log::info!("Command requires approval: {}", cmd_str);
185        }
186
187        let mut child = match AsyncCommand::new("sh")
188            .args(["-c", cmd_str])
189            .current_dir(&self.working_dir)
190            .stdout(Stdio::piped())
191            .stderr(Stdio::piped())
192            .spawn()
193        {
194            Ok(child) => child,
195            Err(e) => return ToolResult::failure("run_command", format!("Failed to spawn: {}", e)),
196        };
197
198        let stdout = child.stdout.take().expect("Failed to open stdout");
199        let stderr = child.stderr.take().expect("Failed to open stderr");
200        let sender = self.event_sender.clone();
201
202        let stdout_handle = tokio::spawn(async move {
203            let mut reader = BufReader::new(stdout).lines();
204            let mut output = String::new();
205            while let Ok(Some(line)) = reader.next_line().await {
206                if let Some(ref s) = sender {
207                    let _ = s.send(perspt_core::AgentEvent::Log(line.clone()));
208                }
209                output.push_str(&line);
210                output.push('\n');
211            }
212            output
213        });
214
215        let sender_err = self.event_sender.clone();
216        let stderr_handle = tokio::spawn(async move {
217            let mut reader = BufReader::new(stderr).lines();
218            let mut output = String::new();
219            while let Ok(Some(line)) = reader.next_line().await {
220                if let Some(ref s) = sender_err {
221                    let _ = s.send(perspt_core::AgentEvent::Log(format!("ERR: {}", line)));
222                }
223                output.push_str(&line);
224                output.push('\n');
225            }
226            output
227        });
228
229        let status = match child.wait().await {
230            Ok(s) => s,
231            Err(e) => return ToolResult::failure("run_command", format!("Failed to wait: {}", e)),
232        };
233
234        let stdout_str = stdout_handle.await.unwrap_or_default();
235        let stderr_str = stderr_handle.await.unwrap_or_default();
236
237        if status.success() {
238            ToolResult::success("run_command", stdout_str)
239        } else {
240            ToolResult::failure(
241                "run_command",
242                format!("Exit code: {:?}\n{}", status.code(), stderr_str),
243            )
244        }
245    }
246
247    /// List files in a directory
248    fn list_files(&self, call: &ToolCall) -> ToolResult {
249        let path = call
250            .arguments
251            .get("path")
252            .map(|p| self.resolve_path(p))
253            .unwrap_or_else(|| self.working_dir.clone());
254
255        match fs::read_dir(&path) {
256            Ok(entries) => {
257                let files: Vec<String> = entries
258                    .filter_map(|e| e.ok())
259                    .map(|e| {
260                        let name = e.file_name().to_string_lossy().to_string();
261                        if e.file_type().map(|t| t.is_dir()).unwrap_or(false) {
262                            format!("{}/", name)
263                        } else {
264                            name
265                        }
266                    })
267                    .collect();
268                ToolResult::success("list_files", files.join("\n"))
269            }
270            Err(e) => {
271                ToolResult::failure("list_files", format!("Failed to list {:?}: {}", path, e))
272            }
273        }
274    }
275
276    /// Write content to a file
277    fn write_file(&self, call: &ToolCall) -> ToolResult {
278        // Alias for apply_patch with different semantics
279        self.apply_patch(call)
280    }
281
282    /// Resolve a path relative to working directory
283    fn resolve_path(&self, path: &str) -> PathBuf {
284        let p = Path::new(path);
285        if p.is_absolute() {
286            p.to_path_buf()
287        } else {
288            self.working_dir.join(p)
289        }
290    }
291
292    // =========================================================================
293    // Power Tools (OS-level operations)
294    // =========================================================================
295
296    /// Replace text in a file using sed-like pattern matching
297    fn sed_replace(&self, call: &ToolCall) -> ToolResult {
298        let path = match call.arguments.get("path") {
299            Some(p) => self.resolve_path(p),
300            None => {
301                return ToolResult::failure("sed_replace", "Missing 'path' argument".to_string())
302            }
303        };
304
305        let pattern = match call.arguments.get("pattern") {
306            Some(p) => p,
307            None => {
308                return ToolResult::failure("sed_replace", "Missing 'pattern' argument".to_string())
309            }
310        };
311
312        let replacement = match call.arguments.get("replacement") {
313            Some(r) => r,
314            None => {
315                return ToolResult::failure(
316                    "sed_replace",
317                    "Missing 'replacement' argument".to_string(),
318                )
319            }
320        };
321
322        // Read file, perform replacement, write back
323        match fs::read_to_string(&path) {
324            Ok(content) => {
325                let new_content = content.replace(pattern, replacement);
326                match fs::write(&path, &new_content) {
327                    Ok(_) => ToolResult::success(
328                        "sed_replace",
329                        format!(
330                            "Replaced '{}' with '{}' in {:?}",
331                            pattern, replacement, path
332                        ),
333                    ),
334                    Err(e) => ToolResult::failure("sed_replace", format!("Failed to write: {}", e)),
335                }
336            }
337            Err(e) => {
338                ToolResult::failure("sed_replace", format!("Failed to read {:?}: {}", path, e))
339            }
340        }
341    }
342
343    /// Filter file content using awk-like field selection
344    fn awk_filter(&self, call: &ToolCall) -> ToolResult {
345        let path = match call.arguments.get("path") {
346            Some(p) => self.resolve_path(p),
347            None => {
348                return ToolResult::failure("awk_filter", "Missing 'path' argument".to_string())
349            }
350        };
351
352        let filter = match call.arguments.get("filter") {
353            Some(f) => f,
354            None => {
355                return ToolResult::failure("awk_filter", "Missing 'filter' argument".to_string())
356            }
357        };
358
359        // Use awk command for filtering
360        let output = Command::new("awk").arg(filter).arg(&path).output();
361
362        match output {
363            Ok(out) => {
364                if out.status.success() {
365                    ToolResult::success(
366                        "awk_filter",
367                        String::from_utf8_lossy(&out.stdout).to_string(),
368                    )
369                } else {
370                    ToolResult::failure(
371                        "awk_filter",
372                        String::from_utf8_lossy(&out.stderr).to_string(),
373                    )
374                }
375            }
376            Err(e) => ToolResult::failure("awk_filter", format!("Failed to run awk: {}", e)),
377        }
378    }
379
380    /// Show differences between two files
381    fn diff_files(&self, call: &ToolCall) -> ToolResult {
382        let file1 = match call.arguments.get("file1") {
383            Some(p) => self.resolve_path(p),
384            None => {
385                return ToolResult::failure("diff_files", "Missing 'file1' argument".to_string())
386            }
387        };
388
389        let file2 = match call.arguments.get("file2") {
390            Some(p) => self.resolve_path(p),
391            None => {
392                return ToolResult::failure("diff_files", "Missing 'file2' argument".to_string())
393            }
394        };
395
396        // Use diff command
397        let output = Command::new("diff")
398            .args([
399                "--unified",
400                &file1.to_string_lossy(),
401                &file2.to_string_lossy(),
402            ])
403            .output();
404
405        match output {
406            Ok(out) => {
407                // diff exits with 0 if files are same, 1 if different, 2 if error
408                let stdout = String::from_utf8_lossy(&out.stdout).to_string();
409                if stdout.is_empty() {
410                    ToolResult::success("diff_files", "Files are identical".to_string())
411                } else {
412                    ToolResult::success("diff_files", stdout)
413                }
414            }
415            Err(e) => ToolResult::failure("diff_files", format!("Failed to run diff: {}", e)),
416        }
417    }
418}
419
420/// Get tool definitions for LLM function calling
421pub fn get_tool_definitions() -> Vec<ToolDefinition> {
422    vec![
423        ToolDefinition {
424            name: "read_file".to_string(),
425            description: "Read the contents of a file".to_string(),
426            parameters: vec![ToolParameter {
427                name: "path".to_string(),
428                description: "Path to the file to read".to_string(),
429                required: true,
430            }],
431        },
432        ToolDefinition {
433            name: "search_code".to_string(),
434            description: "Search for code patterns in the workspace using grep/ripgrep".to_string(),
435            parameters: vec![
436                ToolParameter {
437                    name: "query".to_string(),
438                    description: "Search pattern (regex supported)".to_string(),
439                    required: true,
440                },
441                ToolParameter {
442                    name: "path".to_string(),
443                    description: "Directory to search in (default: working directory)".to_string(),
444                    required: false,
445                },
446            ],
447        },
448        ToolDefinition {
449            name: "apply_patch".to_string(),
450            description: "Write or replace file contents".to_string(),
451            parameters: vec![
452                ToolParameter {
453                    name: "path".to_string(),
454                    description: "Path to the file to write".to_string(),
455                    required: true,
456                },
457                ToolParameter {
458                    name: "content".to_string(),
459                    description: "New file contents".to_string(),
460                    required: true,
461                },
462            ],
463        },
464        ToolDefinition {
465            name: "run_command".to_string(),
466            description: "Execute a shell command in the working directory".to_string(),
467            parameters: vec![ToolParameter {
468                name: "command".to_string(),
469                description: "Shell command to execute".to_string(),
470                required: true,
471            }],
472        },
473        ToolDefinition {
474            name: "list_files".to_string(),
475            description: "List files in a directory".to_string(),
476            parameters: vec![ToolParameter {
477                name: "path".to_string(),
478                description: "Directory path (default: working directory)".to_string(),
479                required: false,
480            }],
481        },
482        // Power Tools
483        ToolDefinition {
484            name: "sed_replace".to_string(),
485            description: "Replace text in a file using sed-like pattern matching".to_string(),
486            parameters: vec![
487                ToolParameter {
488                    name: "path".to_string(),
489                    description: "Path to the file".to_string(),
490                    required: true,
491                },
492                ToolParameter {
493                    name: "pattern".to_string(),
494                    description: "Search pattern".to_string(),
495                    required: true,
496                },
497                ToolParameter {
498                    name: "replacement".to_string(),
499                    description: "Replacement text".to_string(),
500                    required: true,
501                },
502            ],
503        },
504        ToolDefinition {
505            name: "awk_filter".to_string(),
506            description: "Filter file content using awk-like field selection".to_string(),
507            parameters: vec![
508                ToolParameter {
509                    name: "path".to_string(),
510                    description: "Path to the file".to_string(),
511                    required: true,
512                },
513                ToolParameter {
514                    name: "filter".to_string(),
515                    description: "Awk filter expression (e.g., '$1 == \"error\"')".to_string(),
516                    required: true,
517                },
518            ],
519        },
520        ToolDefinition {
521            name: "diff_files".to_string(),
522            description: "Show differences between two files".to_string(),
523            parameters: vec![
524                ToolParameter {
525                    name: "file1".to_string(),
526                    description: "First file path".to_string(),
527                    required: true,
528                },
529                ToolParameter {
530                    name: "file2".to_string(),
531                    description: "Second file path".to_string(),
532                    required: true,
533                },
534            ],
535        },
536    ]
537}
538
539/// Tool definition for LLM function calling
540#[derive(Debug, Clone)]
541pub struct ToolDefinition {
542    pub name: String,
543    pub description: String,
544    pub parameters: Vec<ToolParameter>,
545}
546
547/// Tool parameter definition
548#[derive(Debug, Clone)]
549pub struct ToolParameter {
550    pub name: String,
551    pub description: String,
552    pub required: bool,
553}
554
555#[cfg(test)]
556mod tests {
557    use super::*;
558    use std::env::temp_dir;
559
560    #[tokio::test]
561    async fn test_read_file() {
562        let dir = temp_dir();
563        let test_file = dir.join("test_read.txt");
564        fs::write(&test_file, "Hello, World!").unwrap();
565
566        let tools = AgentTools::new(dir.clone(), false);
567        let call = ToolCall {
568            name: "read_file".to_string(),
569            arguments: [("path".to_string(), test_file.to_string_lossy().to_string())]
570                .into_iter()
571                .collect(),
572        };
573
574        let result = tools.execute(&call).await;
575        assert!(result.success);
576        assert_eq!(result.output, "Hello, World!");
577    }
578
579    #[tokio::test]
580    async fn test_list_files() {
581        let dir = temp_dir();
582        let tools = AgentTools::new(dir.clone(), false);
583        let call = ToolCall {
584            name: "list_files".to_string(),
585            arguments: HashMap::new(),
586        };
587
588        let result = tools.execute(&call).await;
589        assert!(result.success);
590    }
591}