1use agents_core::command::StateDiff;
7use agents_core::tools::{Tool, ToolBox, ToolContext, ToolParameterSchema, ToolResult, ToolSchema};
8use async_trait::async_trait;
9use serde::Deserialize;
10use serde_json::Value;
11use std::collections::{BTreeMap, HashMap};
12
13pub struct LsTool;
15
16#[async_trait]
17impl Tool for LsTool {
18 fn schema(&self) -> ToolSchema {
19 ToolSchema::no_params("ls", "List all files in the filesystem")
20 }
21
22 async fn execute(&self, _args: Value, ctx: ToolContext) -> anyhow::Result<ToolResult> {
23 let files: Vec<String> = ctx.state.files.keys().cloned().collect();
24 Ok(ToolResult::json(&ctx, serde_json::json!(files)))
25 }
26}
27
28pub struct ReadFileTool;
30
31#[derive(Deserialize)]
32struct ReadFileArgs {
33 #[serde(rename = "file_path")]
34 path: String,
35 #[serde(default)]
36 offset: usize,
37 #[serde(default = "default_limit")]
38 limit: usize,
39}
40
41const fn default_limit() -> usize {
42 2000
43}
44
45#[async_trait]
46impl Tool for ReadFileTool {
47 fn schema(&self) -> ToolSchema {
48 let mut properties = HashMap::new();
49 properties.insert(
50 "file_path".to_string(),
51 ToolParameterSchema::string("Path to the file to read"),
52 );
53 properties.insert(
54 "offset".to_string(),
55 ToolParameterSchema::integer("Line number to start reading from (default: 0)"),
56 );
57 properties.insert(
58 "limit".to_string(),
59 ToolParameterSchema::integer("Maximum number of lines to read (default: 2000)"),
60 );
61
62 ToolSchema::new(
63 "read_file",
64 "Read the contents of a file with optional line offset and limit",
65 ToolParameterSchema::object(
66 "Read file parameters",
67 properties,
68 vec!["file_path".to_string()],
69 ),
70 )
71 }
72
73 async fn execute(&self, args: Value, ctx: ToolContext) -> anyhow::Result<ToolResult> {
74 let args: ReadFileArgs = serde_json::from_value(args)?;
75
76 let Some(contents) = ctx.state.files.get(&args.path) else {
77 return Ok(ToolResult::text(
78 &ctx,
79 format!("Error: File '{}' not found", args.path),
80 ));
81 };
82
83 if contents.trim().is_empty() {
84 return Ok(ToolResult::text(
85 &ctx,
86 "System reminder: File exists but has empty contents",
87 ));
88 }
89
90 let lines: Vec<&str> = contents.lines().collect();
91 if args.offset >= lines.len() {
92 return Ok(ToolResult::text(
93 &ctx,
94 format!(
95 "Error: Line offset {} exceeds file length ({} lines)",
96 args.offset,
97 lines.len()
98 ),
99 ));
100 }
101
102 let end = (args.offset + args.limit).min(lines.len());
103 let mut formatted = String::new();
104 for (idx, line) in lines[args.offset..end].iter().enumerate() {
105 let line_number = args.offset + idx + 1;
106 let mut content = line.to_string();
107 if content.len() > args.limit {
108 let mut truncate_at = args.limit;
109 while !content.is_char_boundary(truncate_at) {
110 truncate_at -= 1;
111 }
112 content.truncate(truncate_at);
113 }
114 formatted.push_str(&format!("{:6}\t{}\n", line_number, content));
115 }
116
117 Ok(ToolResult::text(&ctx, formatted.trim_end().to_string()))
118 }
119}
120
121pub struct WriteFileTool;
123
124#[derive(Deserialize)]
125struct WriteFileArgs {
126 #[serde(rename = "file_path")]
127 path: String,
128 content: String,
129}
130
131#[async_trait]
132impl Tool for WriteFileTool {
133 fn schema(&self) -> ToolSchema {
134 let mut properties = HashMap::new();
135 properties.insert(
136 "file_path".to_string(),
137 ToolParameterSchema::string("Path to the file to write"),
138 );
139 properties.insert(
140 "content".to_string(),
141 ToolParameterSchema::string("Content to write to the file"),
142 );
143
144 ToolSchema::new(
145 "write_file",
146 "Write content to a file (creates new or overwrites existing)",
147 ToolParameterSchema::object(
148 "Write file parameters",
149 properties,
150 vec!["file_path".to_string(), "content".to_string()],
151 ),
152 )
153 }
154
155 async fn execute(&self, args: Value, ctx: ToolContext) -> anyhow::Result<ToolResult> {
156 let args: WriteFileArgs = serde_json::from_value(args)?;
157
158 if let Some(state_handle) = &ctx.state_handle {
160 let mut state = state_handle
161 .write()
162 .expect("filesystem write lock poisoned");
163 state.files.insert(args.path.clone(), args.content.clone());
164 }
165
166 let mut diff = StateDiff::default();
168 let mut files = BTreeMap::new();
169 files.insert(args.path.clone(), args.content);
170 diff.files = Some(files);
171
172 let message = ctx.text_response(format!("Updated file {}", args.path));
173 Ok(ToolResult::with_state(message, diff))
174 }
175}
176
177pub struct EditFileTool;
179
180#[derive(Deserialize)]
181struct EditFileArgs {
182 #[serde(rename = "file_path")]
183 path: String,
184 #[serde(rename = "old_string")]
185 old: String,
186 #[serde(rename = "new_string")]
187 new: String,
188 #[serde(default)]
189 replace_all: bool,
190}
191
192#[async_trait]
193impl Tool for EditFileTool {
194 fn schema(&self) -> ToolSchema {
195 let mut properties = HashMap::new();
196 properties.insert(
197 "file_path".to_string(),
198 ToolParameterSchema::string("Path to the file to edit"),
199 );
200 properties.insert(
201 "old_string".to_string(),
202 ToolParameterSchema::string("String to find and replace"),
203 );
204 properties.insert(
205 "new_string".to_string(),
206 ToolParameterSchema::string("Replacement string"),
207 );
208 properties.insert(
209 "replace_all".to_string(),
210 ToolParameterSchema::boolean(
211 "Replace all occurrences (default: false, requires unique match)",
212 ),
213 );
214
215 ToolSchema::new(
216 "edit_file",
217 "Edit a file by replacing old_string with new_string",
218 ToolParameterSchema::object(
219 "Edit file parameters",
220 properties,
221 vec![
222 "file_path".to_string(),
223 "old_string".to_string(),
224 "new_string".to_string(),
225 ],
226 ),
227 )
228 }
229
230 async fn execute(&self, args: Value, ctx: ToolContext) -> anyhow::Result<ToolResult> {
231 let args: EditFileArgs = serde_json::from_value(args)?;
232
233 let Some(existing) = ctx.state.files.get(&args.path).cloned() else {
234 return Ok(ToolResult::text(
235 &ctx,
236 format!("Error: File '{}' not found", args.path),
237 ));
238 };
239
240 if !existing.contains(&args.old) {
241 return Ok(ToolResult::text(
242 &ctx,
243 format!("Error: String not found in file: '{}'", args.old),
244 ));
245 }
246
247 if !args.replace_all {
248 let occurrences = existing.matches(&args.old).count();
249 if occurrences > 1 {
250 return Ok(ToolResult::text(
251 &ctx,
252 format!(
253 "Error: String '{}' appears {} times in file. Use replace_all=true to replace all instances, or provide a more specific string with surrounding context.",
254 args.old, occurrences
255 ),
256 ));
257 }
258 }
259
260 let updated = if args.replace_all {
261 existing.replace(&args.old, &args.new)
262 } else {
263 existing.replacen(&args.old, &args.new, 1)
264 };
265
266 let replacement_count = if args.replace_all {
267 existing.matches(&args.old).count()
268 } else {
269 1
270 };
271
272 if let Some(state_handle) = &ctx.state_handle {
274 let mut state = state_handle
275 .write()
276 .expect("filesystem write lock poisoned");
277 state.files.insert(args.path.clone(), updated.clone());
278 }
279
280 let mut diff = StateDiff::default();
282 let mut files = BTreeMap::new();
283 files.insert(args.path.clone(), updated);
284 diff.files = Some(files);
285
286 let message = if args.replace_all {
287 ctx.text_response(format!(
288 "Successfully replaced {} instance(s) of the string in '{}'",
289 replacement_count, args.path
290 ))
291 } else {
292 ctx.text_response(format!("Successfully replaced string in '{}'", args.path))
293 };
294
295 Ok(ToolResult::with_state(message, diff))
296 }
297}
298
299pub fn create_filesystem_tools() -> Vec<ToolBox> {
301 vec![
302 std::sync::Arc::new(LsTool),
303 std::sync::Arc::new(ReadFileTool),
304 std::sync::Arc::new(WriteFileTool),
305 std::sync::Arc::new(EditFileTool),
306 ]
307}
308
309#[cfg(test)]
310mod tests {
311 use super::*;
312 use agents_core::state::AgentStateSnapshot;
313 use serde_json::json;
314 use std::sync::{Arc, RwLock};
315
316 #[tokio::test]
317 async fn ls_tool_lists_files() {
318 let mut state = AgentStateSnapshot::default();
319 state
320 .files
321 .insert("test.txt".to_string(), "content".to_string());
322 let ctx = ToolContext::new(Arc::new(state));
323
324 let tool = LsTool;
325 let result = tool.execute(json!({}), ctx).await.unwrap();
326
327 match result {
328 ToolResult::Message(msg) => {
329 let files: Vec<String> =
330 serde_json::from_value(msg.content.as_json().unwrap().clone()).unwrap();
331 assert_eq!(files, vec!["test.txt"]);
332 }
333 _ => panic!("Expected message result"),
334 }
335 }
336
337 #[tokio::test]
338 async fn read_file_tool_reads_content() {
339 let mut state = AgentStateSnapshot::default();
340 state.files.insert(
341 "main.rs".to_string(),
342 "fn main() {}\nlet x = 1;".to_string(),
343 );
344 let ctx = ToolContext::new(Arc::new(state));
345
346 let tool = ReadFileTool;
347 let result = tool
348 .execute(
349 json!({"file_path": "main.rs", "offset": 0, "limit": 10}),
350 ctx,
351 )
352 .await
353 .unwrap();
354
355 match result {
356 ToolResult::Message(msg) => {
357 let text = msg.content.as_text().unwrap();
358 assert!(text.contains("fn main"));
359 }
360 _ => panic!("Expected message result"),
361 }
362 }
363
364 #[tokio::test]
365 async fn write_file_tool_creates_file() {
366 let state = Arc::new(AgentStateSnapshot::default());
367 let state_handle = Arc::new(RwLock::new(AgentStateSnapshot::default()));
368 let ctx = ToolContext::with_mutable_state(state, state_handle.clone());
369
370 let tool = WriteFileTool;
371 let result = tool
372 .execute(
373 json!({"file_path": "new.txt", "content": "hello world"}),
374 ctx,
375 )
376 .await
377 .unwrap();
378
379 match result {
380 ToolResult::WithStateUpdate {
381 message,
382 state_diff,
383 } => {
384 assert!(message
385 .content
386 .as_text()
387 .unwrap()
388 .contains("Updated file new.txt"));
389 assert!(state_diff.files.unwrap().contains_key("new.txt"));
390
391 let final_state = state_handle.read().unwrap();
393 assert_eq!(final_state.files.get("new.txt").unwrap(), "hello world");
394 }
395 _ => panic!("Expected state update result"),
396 }
397 }
398
399 #[tokio::test]
400 async fn edit_file_tool_replaces_string() {
401 let mut state = AgentStateSnapshot::default();
402 state
403 .files
404 .insert("test.txt".to_string(), "hello world".to_string());
405 let state = Arc::new(state);
406 let state_handle = Arc::new(RwLock::new((*state).clone()));
407 let ctx = ToolContext::with_mutable_state(state, state_handle.clone());
408
409 let tool = EditFileTool;
410 let result = tool
411 .execute(
412 json!({
413 "file_path": "test.txt",
414 "old_string": "world",
415 "new_string": "rust"
416 }),
417 ctx,
418 )
419 .await
420 .unwrap();
421
422 match result {
423 ToolResult::WithStateUpdate { state_diff, .. } => {
424 let files = state_diff.files.unwrap();
425 assert_eq!(files.get("test.txt").unwrap(), "hello rust");
426 }
427 _ => panic!("Expected state update result"),
428 }
429 }
430}