Skip to main content

agent_sdk/tools/
fs_tools.rs

1use std::path::PathBuf;
2
3use async_trait::async_trait;
4use serde_json::json;
5
6use crate::error::{SdkError, SdkResult};
7use crate::traits::tool::{Tool, ToolDefinition};
8
9const DEFAULT_MAX_LINES: usize = 500;
10
11pub struct ReadFileTool {
12    pub source_root: PathBuf,
13    pub work_dir: PathBuf,
14}
15
16#[async_trait]
17impl Tool for ReadFileTool {
18    fn definition(&self) -> ToolDefinition {
19        ToolDefinition {
20            name: "read_file".to_string(),
21            description: "Read the contents of a file. The path is relative to the repository root. For large files, use offset/max_lines to read in chunks.".to_string(),
22            parameters: json!({
23                "type": "object",
24                "properties": {
25                    "path": { "type": "string", "description": "Relative path to the file" },
26                    "offset": { "type": "integer", "description": "Line number to start reading from (0-based, default: 0)" },
27                    "max_lines": { "type": "integer", "description": "Maximum number of lines to return (default: 500)" }
28                },
29                "required": ["path"]
30            }),
31        }
32    }
33
34    async fn execute(&self, arguments: serde_json::Value) -> SdkResult<serde_json::Value> {
35        let path = arguments["path"]
36            .as_str()
37            .ok_or_else(|| SdkError::ToolExecution {
38                tool_name: "read_file".to_string(),
39                message: "Missing 'path' argument".to_string(),
40            })?;
41
42        let offset = arguments["offset"].as_u64().unwrap_or(0) as usize;
43        let max_lines = arguments["max_lines"].as_u64().unwrap_or(DEFAULT_MAX_LINES as u64) as usize;
44
45        let full_path = self.source_root.join(path);
46        let full_path = if full_path.exists() {
47            full_path
48        } else {
49            let work_path = self.work_dir.join(path);
50            if work_path.exists() {
51                work_path
52            } else {
53                return Ok(json!({ "error": format!("File not found: {}", path) }));
54            }
55        };
56
57        let canonical = full_path.canonicalize().map_err(|e| SdkError::ToolExecution {
58            tool_name: "read_file".to_string(),
59            message: format!("Cannot resolve path: {}", e),
60        })?;
61
62        let source_canonical = self.source_root.canonicalize().unwrap_or_else(|_| self.source_root.clone());
63        let work_canonical = self.work_dir.canonicalize().unwrap_or_else(|_| self.work_dir.clone());
64
65        if !canonical.starts_with(&source_canonical) && !canonical.starts_with(&work_canonical) {
66            return Ok(json!({ "error": "Path escapes allowed directories" }));
67        }
68
69        match tokio::fs::read_to_string(&canonical).await {
70            Ok(content) => {
71                let all_lines: Vec<&str> = content.lines().collect();
72                let total_lines = all_lines.len();
73                let start = offset.min(total_lines);
74                let end = (start + max_lines).min(total_lines);
75                let slice = &all_lines[start..end];
76                let truncated = end < total_lines;
77                let result_content = slice.join("\n");
78
79                let mut result = json!({
80                    "content": result_content,
81                    "lines": total_lines,
82                    "path": path,
83                    "offset": start,
84                    "lines_returned": slice.len(),
85                });
86
87                if truncated {
88                    result["truncated"] = json!(true);
89                    result["next_offset"] = json!(end);
90                    result["note"] = json!(format!(
91                        "File has {} lines, showing lines {}-{}. Use offset={} to read more.",
92                        total_lines, start + 1, end, end
93                    ));
94                }
95
96                Ok(result)
97            }
98            Err(e) => Ok(json!({ "error": format!("Failed to read file: {}", e) })),
99        }
100    }
101}
102
103pub struct WriteFileTool {
104    pub work_dir: PathBuf,
105}
106
107#[async_trait]
108impl Tool for WriteFileTool {
109    fn definition(&self) -> ToolDefinition {
110        ToolDefinition {
111            name: "write_file".to_string(),
112            description: "Write content to a file in the output directory. Creates parent directories as needed.".to_string(),
113            parameters: json!({
114                "type": "object",
115                "properties": {
116                    "path": { "type": "string", "description": "Relative path for the output file" },
117                    "content": { "type": "string", "description": "The full file content to write" }
118                },
119                "required": ["path", "content"]
120            }),
121        }
122    }
123
124    async fn execute(&self, arguments: serde_json::Value) -> SdkResult<serde_json::Value> {
125        let path = arguments["path"]
126            .as_str()
127            .ok_or_else(|| SdkError::ToolExecution {
128                tool_name: "write_file".to_string(),
129                message: "Missing 'path' argument".to_string(),
130            })?;
131
132        let content = arguments["content"]
133            .as_str()
134            .ok_or_else(|| SdkError::ToolExecution {
135                tool_name: "write_file".to_string(),
136                message: "Missing 'content' argument".to_string(),
137            })?;
138
139        let full_path = self.work_dir.join(path);
140
141        if let Some(parent) = full_path.parent() {
142            tokio::fs::create_dir_all(parent).await.map_err(|e| SdkError::ToolExecution {
143                tool_name: "write_file".to_string(),
144                message: format!("Failed to create directories: {}", e),
145            })?;
146        }
147
148        tokio::fs::write(&full_path, content).await.map_err(|e| SdkError::ToolExecution {
149            tool_name: "write_file".to_string(),
150            message: format!("Failed to write file: {}", e),
151        })?;
152
153        let lines = content.lines().count();
154        Ok(json!({
155            "path": path,
156            "lines_written": lines,
157            "bytes_written": content.len()
158        }))
159    }
160}
161
162pub struct ListDirectoryTool {
163    pub source_root: PathBuf,
164    pub work_dir: PathBuf,
165}
166
167#[async_trait]
168impl Tool for ListDirectoryTool {
169    fn definition(&self) -> ToolDefinition {
170        ToolDefinition {
171            name: "list_directory".to_string(),
172            description: "List files and subdirectories in a directory. Path is relative to repository root.".to_string(),
173            parameters: json!({
174                "type": "object",
175                "properties": {
176                    "path": { "type": "string", "description": "Relative directory path (use '.' for root)" }
177                },
178                "required": ["path"]
179            }),
180        }
181    }
182
183    async fn execute(&self, arguments: serde_json::Value) -> SdkResult<serde_json::Value> {
184        let path = arguments["path"].as_str().unwrap_or(".");
185
186        let full_path = self.source_root.join(path);
187        if !full_path.is_dir() {
188            return Ok(json!({ "error": format!("Not a directory: {}", path) }));
189        }
190
191        let mut entries = Vec::new();
192        let mut dir = tokio::fs::read_dir(&full_path).await.map_err(|e| SdkError::ToolExecution {
193            tool_name: "list_directory".to_string(),
194            message: format!("Failed to read directory: {}", e),
195        })?;
196
197        while let Some(entry) = dir.next_entry().await.map_err(|e| SdkError::ToolExecution {
198            tool_name: "list_directory".to_string(),
199            message: format!("Failed to read entry: {}", e),
200        })? {
201            let name = entry.file_name().to_string_lossy().to_string();
202            let ft = entry.file_type().await.ok();
203            let kind = if ft.as_ref().is_some_and(|f| f.is_dir()) { "directory" } else { "file" };
204            entries.push(json!({ "name": name, "type": kind }));
205        }
206
207        entries.sort_by(|a, b| {
208            let a_name = a["name"].as_str().unwrap_or("");
209            let b_name = b["name"].as_str().unwrap_or("");
210            a_name.cmp(b_name)
211        });
212
213        Ok(json!({ "path": path, "entries": entries, "count": entries.len() }))
214    }
215}