1use 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#[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#[derive(Debug, Clone)]
44pub struct ToolCall {
45 pub name: String,
46 pub arguments: HashMap<String, String>,
47}
48
49pub struct AgentTools {
51 working_dir: PathBuf,
53 require_approval: bool,
55 event_sender: Option<perspt_core::events::channel::EventSender>,
57}
58
59impl AgentTools {
60 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 pub fn set_event_sender(&mut self, sender: perspt_core::events::channel::EventSender) {
71 self.event_sender = Some(sender);
72 }
73
74 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 "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 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 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 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 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 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 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 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 fn write_file(&self, call: &ToolCall) -> ToolResult {
278 self.apply_patch(call)
280 }
281
282 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 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 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 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 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 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 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 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
420pub 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 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#[derive(Debug, Clone)]
541pub struct ToolDefinition {
542 pub name: String,
543 pub description: String,
544 pub parameters: Vec<ToolParameter>,
545}
546
547#[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}