agents_toolkit/builtin/
filesystem.rs

1//! Built-in filesystem tools for agent file manipulation
2//!
3//! These tools provide a mock filesystem interface that agents can use to
4//! read, write, and edit files stored in the agent state.
5
6use 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
13/// List files tool - shows all files in the agent's filesystem
14pub 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
28/// Read file tool - reads the contents of a file
29pub 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
117/// Write file tool - creates or overwrites a file
118pub 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        // Update mutable state if available
155        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        // Create state diff for persistence
163        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
173/// Edit file tool - performs string replacement in a file
174pub 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        // Update mutable state if available
269        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        // Create state diff
277        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
295/// Create all filesystem tools and return them as a vec
296pub 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                // Verify state was updated
388                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}