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() > 2000 {
108 content.truncate(2000);
109 }
110 formatted.push_str(&format!("{:6}\t{}\n", line_number, content));
111 }
112
113 Ok(ToolResult::text(&ctx, formatted.trim_end().to_string()))
114 }
115}
116
117pub struct WriteFileTool;
119
120#[derive(Deserialize)]
121struct WriteFileArgs {
122 #[serde(rename = "file_path")]
123 path: String,
124 content: String,
125}
126
127#[async_trait]
128impl Tool for WriteFileTool {
129 fn schema(&self) -> ToolSchema {
130 let mut properties = HashMap::new();
131 properties.insert(
132 "file_path".to_string(),
133 ToolParameterSchema::string("Path to the file to write"),
134 );
135 properties.insert(
136 "content".to_string(),
137 ToolParameterSchema::string("Content to write to the file"),
138 );
139
140 ToolSchema::new(
141 "write_file",
142 "Write content to a file (creates new or overwrites existing)",
143 ToolParameterSchema::object(
144 "Write file parameters",
145 properties,
146 vec!["file_path".to_string(), "content".to_string()],
147 ),
148 )
149 }
150
151 async fn execute(&self, args: Value, ctx: ToolContext) -> anyhow::Result<ToolResult> {
152 let args: WriteFileArgs = serde_json::from_value(args)?;
153
154 if let Some(state_handle) = &ctx.state_handle {
156 let mut state = state_handle
157 .write()
158 .expect("filesystem write lock poisoned");
159 state.files.insert(args.path.clone(), args.content.clone());
160 }
161
162 let mut diff = StateDiff::default();
164 let mut files = BTreeMap::new();
165 files.insert(args.path.clone(), args.content);
166 diff.files = Some(files);
167
168 let message = ctx.text_response(format!("Updated file {}", args.path));
169 Ok(ToolResult::with_state(message, diff))
170 }
171}
172
173pub struct EditFileTool;
175
176#[derive(Deserialize)]
177struct EditFileArgs {
178 #[serde(rename = "file_path")]
179 path: String,
180 #[serde(rename = "old_string")]
181 old: String,
182 #[serde(rename = "new_string")]
183 new: String,
184 #[serde(default)]
185 replace_all: bool,
186}
187
188#[async_trait]
189impl Tool for EditFileTool {
190 fn schema(&self) -> ToolSchema {
191 let mut properties = HashMap::new();
192 properties.insert(
193 "file_path".to_string(),
194 ToolParameterSchema::string("Path to the file to edit"),
195 );
196 properties.insert(
197 "old_string".to_string(),
198 ToolParameterSchema::string("String to find and replace"),
199 );
200 properties.insert(
201 "new_string".to_string(),
202 ToolParameterSchema::string("Replacement string"),
203 );
204 properties.insert(
205 "replace_all".to_string(),
206 ToolParameterSchema::boolean(
207 "Replace all occurrences (default: false, requires unique match)",
208 ),
209 );
210
211 ToolSchema::new(
212 "edit_file",
213 "Edit a file by replacing old_string with new_string",
214 ToolParameterSchema::object(
215 "Edit file parameters",
216 properties,
217 vec![
218 "file_path".to_string(),
219 "old_string".to_string(),
220 "new_string".to_string(),
221 ],
222 ),
223 )
224 }
225
226 async fn execute(&self, args: Value, ctx: ToolContext) -> anyhow::Result<ToolResult> {
227 let args: EditFileArgs = serde_json::from_value(args)?;
228
229 let Some(existing) = ctx.state.files.get(&args.path).cloned() else {
230 return Ok(ToolResult::text(
231 &ctx,
232 format!("Error: File '{}' not found", args.path),
233 ));
234 };
235
236 if !existing.contains(&args.old) {
237 return Ok(ToolResult::text(
238 &ctx,
239 format!("Error: String not found in file: '{}'", args.old),
240 ));
241 }
242
243 if !args.replace_all {
244 let occurrences = existing.matches(&args.old).count();
245 if occurrences > 1 {
246 return Ok(ToolResult::text(
247 &ctx,
248 format!(
249 "Error: String '{}' appears {} times in file. Use replace_all=true to replace all instances, or provide a more specific string with surrounding context.",
250 args.old, occurrences
251 ),
252 ));
253 }
254 }
255
256 let updated = if args.replace_all {
257 existing.replace(&args.old, &args.new)
258 } else {
259 existing.replacen(&args.old, &args.new, 1)
260 };
261
262 let replacement_count = if args.replace_all {
263 existing.matches(&args.old).count()
264 } else {
265 1
266 };
267
268 if let Some(state_handle) = &ctx.state_handle {
270 let mut state = state_handle
271 .write()
272 .expect("filesystem write lock poisoned");
273 state.files.insert(args.path.clone(), updated.clone());
274 }
275
276 let mut diff = StateDiff::default();
278 let mut files = BTreeMap::new();
279 files.insert(args.path.clone(), updated);
280 diff.files = Some(files);
281
282 let message = if args.replace_all {
283 ctx.text_response(format!(
284 "Successfully replaced {} instance(s) of the string in '{}'",
285 replacement_count, args.path
286 ))
287 } else {
288 ctx.text_response(format!("Successfully replaced string in '{}'", args.path))
289 };
290
291 Ok(ToolResult::with_state(message, diff))
292 }
293}
294
295pub fn create_filesystem_tools() -> Vec<ToolBox> {
297 vec![
298 std::sync::Arc::new(LsTool),
299 std::sync::Arc::new(ReadFileTool),
300 std::sync::Arc::new(WriteFileTool),
301 std::sync::Arc::new(EditFileTool),
302 ]
303}
304
305#[cfg(test)]
306mod tests {
307 use super::*;
308 use agents_core::state::AgentStateSnapshot;
309 use serde_json::json;
310 use std::sync::{Arc, RwLock};
311
312 #[tokio::test]
313 async fn ls_tool_lists_files() {
314 let mut state = AgentStateSnapshot::default();
315 state
316 .files
317 .insert("test.txt".to_string(), "content".to_string());
318 let ctx = ToolContext::new(Arc::new(state));
319
320 let tool = LsTool;
321 let result = tool.execute(json!({}), ctx).await.unwrap();
322
323 match result {
324 ToolResult::Message(msg) => {
325 let files: Vec<String> =
326 serde_json::from_value(msg.content.as_json().unwrap().clone()).unwrap();
327 assert_eq!(files, vec!["test.txt"]);
328 }
329 _ => panic!("Expected message result"),
330 }
331 }
332
333 #[tokio::test]
334 async fn read_file_tool_reads_content() {
335 let mut state = AgentStateSnapshot::default();
336 state.files.insert(
337 "main.rs".to_string(),
338 "fn main() {}\nlet x = 1;".to_string(),
339 );
340 let ctx = ToolContext::new(Arc::new(state));
341
342 let tool = ReadFileTool;
343 let result = tool
344 .execute(
345 json!({"file_path": "main.rs", "offset": 0, "limit": 10}),
346 ctx,
347 )
348 .await
349 .unwrap();
350
351 match result {
352 ToolResult::Message(msg) => {
353 let text = msg.content.as_text().unwrap();
354 assert!(text.contains("fn main"));
355 }
356 _ => panic!("Expected message result"),
357 }
358 }
359
360 #[tokio::test]
361 async fn write_file_tool_creates_file() {
362 let state = Arc::new(AgentStateSnapshot::default());
363 let state_handle = Arc::new(RwLock::new(AgentStateSnapshot::default()));
364 let ctx = ToolContext::with_mutable_state(state, state_handle.clone());
365
366 let tool = WriteFileTool;
367 let result = tool
368 .execute(
369 json!({"file_path": "new.txt", "content": "hello world"}),
370 ctx,
371 )
372 .await
373 .unwrap();
374
375 match result {
376 ToolResult::WithStateUpdate {
377 message,
378 state_diff,
379 } => {
380 assert!(message
381 .content
382 .as_text()
383 .unwrap()
384 .contains("Updated file new.txt"));
385 assert!(state_diff.files.unwrap().contains_key("new.txt"));
386
387 let final_state = state_handle.read().unwrap();
389 assert_eq!(final_state.files.get("new.txt").unwrap(), "hello world");
390 }
391 _ => panic!("Expected state update result"),
392 }
393 }
394
395 #[tokio::test]
396 async fn edit_file_tool_replaces_string() {
397 let mut state = AgentStateSnapshot::default();
398 state
399 .files
400 .insert("test.txt".to_string(), "hello world".to_string());
401 let state = Arc::new(state);
402 let state_handle = Arc::new(RwLock::new((*state).clone()));
403 let ctx = ToolContext::with_mutable_state(state, state_handle.clone());
404
405 let tool = EditFileTool;
406 let result = tool
407 .execute(
408 json!({
409 "file_path": "test.txt",
410 "old_string": "world",
411 "new_string": "rust"
412 }),
413 ctx,
414 )
415 .await
416 .unwrap();
417
418 match result {
419 ToolResult::WithStateUpdate { state_diff, .. } => {
420 let files = state_diff.files.unwrap();
421 assert_eq!(files.get("test.txt").unwrap(), "hello rust");
422 }
423 _ => panic!("Expected state update result"),
424 }
425 }
426}