Skip to main content

agent_code_lib/tools/
file_edit.rs

1//! FileEdit tool: targeted search-and-replace editing.
2//!
3//! Performs exact string replacement within a file. The `old_string`
4//! must match uniquely (unless `replace_all` is set) to prevent
5//! ambiguous edits.
6//!
7//! Before writing, the tool checks whether the file's modification time
8//! has changed since it was last read (via the session file cache). If
9//! the file is stale the edit is rejected so the model re-reads first.
10//!
11//! After a successful edit the tool returns a unified diff of the
12//! changes so the model (and user) can see exactly what happened.
13
14use async_trait::async_trait;
15use serde_json::json;
16use similar::TextDiff;
17use std::path::{Path, PathBuf};
18use std::time::SystemTime;
19
20use super::{Tool, ToolContext, ToolResult};
21use crate::error::ToolError;
22
23pub struct FileEditTool;
24
25/// Check whether the file on disk was modified after the cache recorded it.
26///
27/// Returns `Ok(())` when:
28///   - the cache holds an entry and the on-disk mtime still matches, or
29///   - there is no cache / no entry (we cannot prove staleness).
30///
31/// Returns an error message when the mtimes diverge.
32async fn check_staleness(path: &Path, ctx: &ToolContext) -> Result<(), String> {
33    let cache = match ctx.file_cache.as_ref() {
34        Some(c) => c,
35        None => return Ok(()),
36    };
37
38    let cached_mtime: SystemTime = {
39        let guard = cache.lock().await;
40        match guard.last_read_mtime(path) {
41            Some(t) => t,
42            None => return Ok(()), // never read through cache — nothing to compare
43        }
44    };
45
46    let disk_mtime = tokio::fs::metadata(path)
47        .await
48        .ok()
49        .and_then(|m| m.modified().ok());
50
51    if let Some(disk) = disk_mtime
52        && disk != cached_mtime
53    {
54        return Err(format!(
55            "File was modified since last read. \
56             Please re-read {} before editing.",
57            path.display()
58        ));
59    }
60
61    Ok(())
62}
63
64/// Produce a compact unified diff between `old` and `new` text.
65///
66/// The output uses `---`/`+++` headers with the file path and includes
67/// only the changed hunks with a few lines of context.
68fn unified_diff(file_path: &str, old: &str, new: &str) -> String {
69    let diff = TextDiff::from_lines(old, new);
70    let mut out = String::new();
71
72    // Header
73    out.push_str(&format!("--- {file_path}\n"));
74    out.push_str(&format!("+++ {file_path}\n"));
75
76    for hunk in diff.unified_diff().context_radius(3).iter_hunks() {
77        out.push_str(&format!("{hunk}"));
78    }
79
80    // If the diff is empty (shouldn't happen because we already verified
81    // old_string != new_string), fall back to a note.
82    if out.lines().count() <= 2 {
83        out.push_str("(no visible diff)\n");
84    }
85
86    out
87}
88
89#[async_trait]
90impl Tool for FileEditTool {
91    fn name(&self) -> &'static str {
92        "FileEdit"
93    }
94
95    fn description(&self) -> &'static str {
96        "Performs exact string replacements in files. The old_string must \
97         match uniquely unless replace_all is true."
98    }
99
100    fn input_schema(&self) -> serde_json::Value {
101        json!({
102            "type": "object",
103            "required": ["file_path", "old_string", "new_string"],
104            "properties": {
105                "file_path": {
106                    "type": "string",
107                    "description": "Absolute path to the file to modify"
108                },
109                "old_string": {
110                    "type": "string",
111                    "description": "The text to replace"
112                },
113                "new_string": {
114                    "type": "string",
115                    "description": "The replacement text (must differ from old_string)"
116                },
117                "replace_all": {
118                    "type": "boolean",
119                    "description": "Replace all occurrences (default: false)",
120                    "default": false
121                }
122            }
123        })
124    }
125
126    fn is_read_only(&self) -> bool {
127        false
128    }
129
130    fn get_path(&self, input: &serde_json::Value) -> Option<PathBuf> {
131        input
132            .get("file_path")
133            .and_then(|v| v.as_str())
134            .map(PathBuf::from)
135    }
136
137    async fn call(
138        &self,
139        input: serde_json::Value,
140        ctx: &ToolContext,
141    ) -> Result<ToolResult, ToolError> {
142        let file_path = input
143            .get("file_path")
144            .and_then(|v| v.as_str())
145            .ok_or_else(|| ToolError::InvalidInput("'file_path' is required".into()))?;
146
147        let old_string = input
148            .get("old_string")
149            .and_then(|v| v.as_str())
150            .ok_or_else(|| ToolError::InvalidInput("'old_string' is required".into()))?;
151
152        let new_string = input
153            .get("new_string")
154            .and_then(|v| v.as_str())
155            .ok_or_else(|| ToolError::InvalidInput("'new_string' is required".into()))?;
156
157        let replace_all = input
158            .get("replace_all")
159            .and_then(|v| v.as_bool())
160            .unwrap_or(false);
161
162        if old_string == new_string {
163            return Err(ToolError::InvalidInput(
164                "old_string and new_string must be different".into(),
165            ));
166        }
167
168        let path = Path::new(file_path);
169
170        // Check file size before reading (reject files > 1MB).
171        const MAX_EDIT_SIZE: u64 = 1_048_576;
172        if let Ok(meta) = tokio::fs::metadata(file_path).await
173            && meta.len() > MAX_EDIT_SIZE
174        {
175            return Err(ToolError::InvalidInput(format!(
176                "File too large for editing ({} bytes, max {}). \
177                 Consider using Bash with sed/awk for large files.",
178                meta.len(),
179                MAX_EDIT_SIZE
180            )));
181        }
182
183        // Staleness check: reject if the file changed since the model last
184        // read it, so the model works with up-to-date content.
185        if let Err(msg) = check_staleness(path, ctx).await {
186            return Err(ToolError::ExecutionFailed(msg));
187        }
188
189        let content = tokio::fs::read_to_string(file_path)
190            .await
191            .map_err(|e| ToolError::ExecutionFailed(format!("Failed to read {file_path}: {e}")))?;
192
193        let occurrences = content.matches(old_string).count();
194
195        if occurrences == 0 {
196            return Err(ToolError::InvalidInput(format!(
197                "old_string not found in {file_path}"
198            )));
199        }
200
201        if occurrences > 1 && !replace_all {
202            return Err(ToolError::InvalidInput(format!(
203                "old_string has {occurrences} occurrences in {file_path}. \
204                 Use replace_all=true to replace all, or provide a more \
205                 specific old_string."
206            )));
207        }
208
209        let new_content = if replace_all {
210            content.replace(old_string, new_string)
211        } else {
212            content.replacen(old_string, new_string, 1)
213        };
214
215        tokio::fs::write(file_path, &new_content)
216            .await
217            .map_err(|e| ToolError::ExecutionFailed(format!("Failed to write {file_path}: {e}")))?;
218
219        // Invalidate the cache entry so the next read picks up our write.
220        if let Some(cache) = ctx.file_cache.as_ref() {
221            let mut guard = cache.lock().await;
222            guard.invalidate(path);
223        }
224
225        // Build a unified diff so the model/user sees exactly what changed.
226        let replaced = if replace_all { occurrences } else { 1 };
227        let diff = unified_diff(file_path, &content, &new_content);
228        Ok(ToolResult::success(format!(
229            "Replaced {replaced} occurrence(s) in {file_path}\n\n{diff}"
230        )))
231    }
232}