1use super::{
2 MAX_FILE_BYTES, MAX_SEARCH_RESULTS, Tool, ToolContext, ToolError, ToolResult,
3 resolve_workspace_path_with_allowed, walk_workspace_files, wildcard_match,
4 workspace_root_from_ctx,
5};
6use async_trait::async_trait;
7use roboticus_core::RiskLevel;
8use serde_json::Value;
9
10pub struct ReadFileTool;
11
12#[async_trait]
13impl Tool for ReadFileTool {
14 fn name(&self) -> &str {
15 "read_file"
16 }
17
18 fn description(&self) -> &str {
19 "Read a UTF-8 text file from the workspace"
20 }
21
22 fn risk_level(&self) -> RiskLevel {
23 RiskLevel::Caution
25 }
26
27 fn parameters_schema(&self) -> Value {
28 serde_json::json!({
29 "type": "object",
30 "properties": { "path": { "type": "string" } },
31 "required": ["path"]
32 })
33 }
34
35 async fn execute(
36 &self,
37 params: Value,
38 ctx: &ToolContext,
39 ) -> std::result::Result<ToolResult, ToolError> {
40 let rel = params
41 .get("path")
42 .and_then(|v| v.as_str())
43 .ok_or_else(|| ToolError {
44 message: "missing 'path' parameter".into(),
45 })?;
46 let root = workspace_root_from_ctx(ctx)?;
47 let path = resolve_workspace_path_with_allowed(&root, rel, false, &ctx.tool_allowed_paths)?;
48 let meta = std::fs::metadata(&path).map_err(|e| ToolError {
49 message: format!("failed to stat '{}': {e}", path.display()),
50 })?;
51 if meta.len() as usize > MAX_FILE_BYTES {
52 return Err(ToolError {
53 message: format!(
54 "file too large (>{MAX_FILE_BYTES} bytes): {}",
55 path.display()
56 ),
57 });
58 }
59 let content = std::fs::read_to_string(&path).map_err(|e| ToolError {
60 message: format!("failed to read '{}': {e}", path.display()),
61 })?;
62 Ok(ToolResult {
63 output: content,
64 metadata: Some(
65 serde_json::json!({ "path": path.display().to_string(), "bytes": meta.len() }),
66 ),
67 })
68 }
69}
70
71pub struct WriteFileTool;
72
73#[async_trait]
74impl Tool for WriteFileTool {
75 fn name(&self) -> &str {
76 "write_file"
77 }
78
79 fn description(&self) -> &str {
80 "Write text content to a workspace file"
81 }
82
83 fn risk_level(&self) -> RiskLevel {
84 RiskLevel::Caution
85 }
86
87 fn parameters_schema(&self) -> Value {
88 serde_json::json!({
89 "type": "object",
90 "properties": {
91 "path": { "type": "string" },
92 "content": { "type": "string" },
93 "append": { "type": "boolean", "default": false }
94 },
95 "required": ["path", "content"]
96 })
97 }
98
99 async fn execute(
100 &self,
101 params: Value,
102 ctx: &ToolContext,
103 ) -> std::result::Result<ToolResult, ToolError> {
104 let rel = params
105 .get("path")
106 .and_then(|v| v.as_str())
107 .ok_or_else(|| ToolError {
108 message: "missing 'path' parameter".into(),
109 })?;
110 let content = params
111 .get("content")
112 .and_then(|v| v.as_str())
113 .ok_or_else(|| ToolError {
114 message: "missing 'content' parameter".into(),
115 })?;
116 let append = params
117 .get("append")
118 .and_then(|v| v.as_bool())
119 .unwrap_or(false);
120 let root = workspace_root_from_ctx(ctx)?;
121 let path = resolve_workspace_path_with_allowed(&root, rel, true, &ctx.tool_allowed_paths)?;
122 if let Some(parent) = path.parent() {
123 std::fs::create_dir_all(parent).map_err(|e| ToolError {
124 message: format!("failed to create parent dirs '{}': {e}", parent.display()),
125 })?;
126 }
127 if append {
128 use std::io::Write;
129 let mut f = std::fs::OpenOptions::new()
130 .create(true)
131 .append(true)
132 .open(&path)
133 .map_err(|e| ToolError {
134 message: format!("failed to open '{}': {e}", path.display()),
135 })?;
136 f.write_all(content.as_bytes()).map_err(|e| ToolError {
137 message: format!("failed to append '{}': {e}", path.display()),
138 })?;
139 } else {
140 std::fs::write(&path, content).map_err(|e| ToolError {
141 message: format!("failed to write '{}': {e}", path.display()),
142 })?;
143 }
144 Ok(ToolResult {
145 output: "ok".into(),
146 metadata: Some(
147 serde_json::json!({ "path": path.display().to_string(), "append": append }),
148 ),
149 })
150 }
151}
152
153pub struct EditFileTool;
154
155#[async_trait]
156impl Tool for EditFileTool {
157 fn name(&self) -> &str {
158 "edit_file"
159 }
160
161 fn description(&self) -> &str {
162 "Replace text in an existing workspace file"
163 }
164
165 fn risk_level(&self) -> RiskLevel {
166 RiskLevel::Caution
167 }
168
169 fn parameters_schema(&self) -> Value {
170 serde_json::json!({
171 "type": "object",
172 "properties": {
173 "path": { "type": "string" },
174 "old_text": { "type": "string" },
175 "new_text": { "type": "string" },
176 "replace_all": { "type": "boolean", "default": false }
177 },
178 "required": ["path", "old_text", "new_text"]
179 })
180 }
181
182 async fn execute(
183 &self,
184 params: Value,
185 ctx: &ToolContext,
186 ) -> std::result::Result<ToolResult, ToolError> {
187 let rel = params
188 .get("path")
189 .and_then(|v| v.as_str())
190 .ok_or_else(|| ToolError {
191 message: "missing 'path' parameter".into(),
192 })?;
193 let old_text = params
194 .get("old_text")
195 .and_then(|v| v.as_str())
196 .ok_or_else(|| ToolError {
197 message: "missing 'old_text' parameter".into(),
198 })?;
199 let new_text = params
200 .get("new_text")
201 .and_then(|v| v.as_str())
202 .ok_or_else(|| ToolError {
203 message: "missing 'new_text' parameter".into(),
204 })?;
205 let replace_all = params
206 .get("replace_all")
207 .and_then(|v| v.as_bool())
208 .unwrap_or(false);
209 let root = workspace_root_from_ctx(ctx)?;
210 let path = resolve_workspace_path_with_allowed(&root, rel, false, &ctx.tool_allowed_paths)?;
211 let content = std::fs::read_to_string(&path).map_err(|e| ToolError {
212 message: format!("failed to read '{}': {e}", path.display()),
213 })?;
214 if !content.contains(old_text) {
215 return Err(ToolError {
216 message: "old_text not found in file".into(),
217 });
218 }
219 let updated = if replace_all {
220 content.replace(old_text, new_text)
221 } else {
222 content.replacen(old_text, new_text, 1)
223 };
224 std::fs::write(&path, updated).map_err(|e| ToolError {
225 message: format!("failed to write '{}': {e}", path.display()),
226 })?;
227 Ok(ToolResult {
228 output: "ok".into(),
229 metadata: Some(
230 serde_json::json!({ "path": path.display().to_string(), "replace_all": replace_all }),
231 ),
232 })
233 }
234}
235
236pub struct ListDirectoryTool;
237
238#[async_trait]
239impl Tool for ListDirectoryTool {
240 fn name(&self) -> &str {
241 "list_directory"
242 }
243
244 fn description(&self) -> &str {
245 "List files and folders in a WORKSPACE directory. Paths are resolved relative \
246 to the agent's workspace root. For paths outside the workspace (e.g., \
247 ~/Downloads, /tmp, user home directories), use the `bash` tool with `ls` instead."
248 }
249
250 fn risk_level(&self) -> RiskLevel {
251 RiskLevel::Caution
252 }
253
254 fn parameters_schema(&self) -> Value {
255 serde_json::json!({
256 "type": "object",
257 "properties": {
258 "path": { "type": "string", "default": "." }
259 }
260 })
261 }
262
263 async fn execute(
264 &self,
265 params: Value,
266 ctx: &ToolContext,
267 ) -> std::result::Result<ToolResult, ToolError> {
268 let rel = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
269 let root = workspace_root_from_ctx(ctx)?;
270 let path = resolve_workspace_path_with_allowed(&root, rel, false, &ctx.tool_allowed_paths)?;
271 let mut entries = Vec::new();
272 for entry in std::fs::read_dir(&path).map_err(|e| ToolError {
273 message: format!("failed to read directory '{}': {e}", path.display()),
274 })? {
275 let entry = entry.map_err(|e| ToolError {
276 message: format!("failed to read entry: {e}"),
277 })?;
278 let p = entry.path();
279 let kind = if p.is_dir() { "dir" } else { "file" };
280 let name = p
281 .file_name()
282 .and_then(|n| n.to_str())
283 .unwrap_or_default()
284 .to_string();
285 entries.push(serde_json::json!({ "name": name, "kind": kind }));
286 }
287 entries.sort_by(|a, b| {
288 a["name"]
289 .as_str()
290 .unwrap_or_default()
291 .cmp(b["name"].as_str().unwrap_or_default())
292 });
293 Ok(ToolResult {
294 output: serde_json::to_string_pretty(&entries).unwrap_or_else(|_| "[]".to_string()),
295 metadata: Some(
296 serde_json::json!({ "path": path.display().to_string(), "count": entries.len() }),
297 ),
298 })
299 }
300}
301
302pub struct GlobFilesTool;
303
304#[async_trait]
305impl Tool for GlobFilesTool {
306 fn name(&self) -> &str {
307 "glob_files"
308 }
309
310 fn description(&self) -> &str {
311 "Find files matching a wildcard pattern under the workspace"
312 }
313
314 fn risk_level(&self) -> RiskLevel {
315 RiskLevel::Caution
316 }
317
318 fn parameters_schema(&self) -> Value {
319 serde_json::json!({
320 "type": "object",
321 "properties": {
322 "pattern": { "type": "string" },
323 "path": { "type": "string", "default": "." },
324 "limit": { "type": "integer", "default": 50, "minimum": 1, "maximum": 500 }
325 },
326 "required": ["pattern"]
327 })
328 }
329
330 async fn execute(
331 &self,
332 params: Value,
333 ctx: &ToolContext,
334 ) -> std::result::Result<ToolResult, ToolError> {
335 let pattern = params
336 .get("pattern")
337 .and_then(|v| v.as_str())
338 .ok_or_else(|| ToolError {
339 message: "missing 'pattern' parameter".into(),
340 })?;
341 let rel = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
342 let limit = params
343 .get("limit")
344 .and_then(|v| v.as_u64())
345 .map(|n| n as usize)
346 .unwrap_or(50)
347 .min(500);
348 let root = workspace_root_from_ctx(ctx)?;
349 let base = resolve_workspace_path_with_allowed(&root, rel, false, &ctx.tool_allowed_paths)?;
350 let mut files = Vec::new();
351 let mut count = 0usize;
352 walk_workspace_files(&base, &mut files, &mut count)?;
353 let mut matches = Vec::new();
354 for p in files {
355 let rel = p.strip_prefix(&root).unwrap_or(&p);
356 let rel_norm = rel.to_string_lossy().replace('\\', "/");
357 if wildcard_match(pattern, &rel_norm) {
358 matches.push(rel_norm);
359 if matches.len() >= limit {
360 break;
361 }
362 }
363 }
364 Ok(ToolResult {
365 output: serde_json::to_string_pretty(&matches).unwrap_or_else(|_| "[]".to_string()),
366 metadata: Some(serde_json::json!({ "count": matches.len(), "pattern": pattern })),
367 })
368 }
369}
370
371pub struct SearchFilesTool;
372
373#[async_trait]
374impl Tool for SearchFilesTool {
375 fn name(&self) -> &str {
376 "search_files"
377 }
378
379 fn description(&self) -> &str {
380 "Search for text content across workspace files"
381 }
382
383 fn risk_level(&self) -> RiskLevel {
384 RiskLevel::Caution
385 }
386
387 fn parameters_schema(&self) -> Value {
388 serde_json::json!({
389 "type": "object",
390 "properties": {
391 "query": { "type": "string" },
392 "path": { "type": "string", "default": "." },
393 "limit": { "type": "integer", "default": 20, "minimum": 1, "maximum": 100 },
394 "case_sensitive": { "type": "boolean", "default": false }
395 },
396 "required": ["query"]
397 })
398 }
399
400 async fn execute(
401 &self,
402 params: Value,
403 ctx: &ToolContext,
404 ) -> std::result::Result<ToolResult, ToolError> {
405 let query = params
406 .get("query")
407 .and_then(|v| v.as_str())
408 .ok_or_else(|| ToolError {
409 message: "missing 'query' parameter".into(),
410 })?;
411 let rel = params.get("path").and_then(|v| v.as_str()).unwrap_or(".");
412 let limit = params
413 .get("limit")
414 .and_then(|v| v.as_u64())
415 .map(|n| n as usize)
416 .unwrap_or(20)
417 .min(MAX_SEARCH_RESULTS);
418 let case_sensitive = params
419 .get("case_sensitive")
420 .and_then(|v| v.as_bool())
421 .unwrap_or(false);
422 let root = workspace_root_from_ctx(ctx)?;
423 let base = resolve_workspace_path_with_allowed(&root, rel, false, &ctx.tool_allowed_paths)?;
424 let mut files = Vec::new();
425 let mut count = 0usize;
426 walk_workspace_files(&base, &mut files, &mut count)?;
427 let mut hits = Vec::new();
428 let mut unreadable_files = 0usize;
429 let mut skipped_large_files = 0usize;
430 let query_cmp = if case_sensitive {
431 query.to_string()
432 } else {
433 query.to_lowercase()
434 };
435 for p in files {
436 if hits.len() >= limit {
437 break;
438 }
439 let file_size = match std::fs::metadata(&p) {
440 Ok(meta) => meta.len(),
441 Err(_) => {
442 unreadable_files += 1;
443 continue;
444 }
445 };
446 if file_size > MAX_FILE_BYTES as u64 {
447 skipped_large_files += 1;
448 continue;
449 }
450 let content = match std::fs::read_to_string(&p) {
451 Ok(c) => c,
452 Err(_) => {
453 unreadable_files += 1;
454 continue;
455 }
456 };
457 for (idx, line) in content.lines().enumerate() {
458 let cmp = if case_sensitive {
459 line.to_string()
460 } else {
461 line.to_lowercase()
462 };
463 if cmp.contains(&query_cmp) {
464 let relp = p
465 .strip_prefix(&root)
466 .unwrap_or(&p)
467 .to_string_lossy()
468 .replace('\\', "/");
469 hits.push(serde_json::json!({
470 "path": relp,
471 "line": idx + 1,
472 "preview": line
473 }));
474 if hits.len() >= limit {
475 break;
476 }
477 }
478 }
479 }
480 Ok(ToolResult {
481 output: serde_json::to_string_pretty(&hits).unwrap_or_else(|_| "[]".to_string()),
482 metadata: Some(serde_json::json!({
483 "count": hits.len(),
484 "query": query,
485 "unreadable_files": unreadable_files,
486 "skipped_large_files": skipped_large_files
487 })),
488 })
489 }
490}