agent_sdk/tools/
fs_tools.rs1use 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}