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() > 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
121/// Write file tool - creates or overwrites a file
122pub 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        // Update mutable state if available
159        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        // Create state diff for persistence
167        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
177/// Edit file tool - performs string replacement in a file
178pub 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        // Update mutable state if available
273        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        // Create state diff
281        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
299/// Create all filesystem tools and return them as a vec
300pub 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                // Verify state was updated
392                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}