Skip to main content

agent_engine/tools/
edit.rs

1use super::{resolve_path_in, Tool, ToolContext};
2use crate::{Result, RuntimeError};
3use serde_json::{json, Value};
4
5pub struct EditTool;
6
7#[async_trait::async_trait]
8impl Tool for EditTool {
9    fn effect(&self) -> crate::tools::catalog::ToolEffect {
10        crate::tools::catalog::ToolEffect::IdempotentWrite
11    }
12
13    fn concurrency_key(
14        &self,
15        validated_input: &serde_json::Value,
16    ) -> Option<super::ConcurrencyKey> {
17        self.concurrency_key_in(validated_input, None)
18    }
19
20    fn concurrency_key_in(
21        &self,
22        validated_input: &serde_json::Value,
23        cwd: Option<&std::path::Path>,
24    ) -> Option<super::ConcurrencyKey> {
25        Some(
26            match super::util::canonical_path_key_in(validated_input["path"].as_str()?, cwd) {
27                Some(key) => super::ConcurrencyKey::Key(key),
28                None => super::ConcurrencyKey::Serialize,
29            },
30        )
31    }
32
33    fn origin(&self) -> crate::tools::ToolOrigin {
34        crate::tools::ToolOrigin::Builtin
35    }
36
37    fn name(&self) -> &str {
38        "edit"
39    }
40
41    fn description(&self) -> &str {
42        "Make a surgical edit to a file by replacing an exact string match. The old_string must appear exactly once in the file. Provide enough surrounding context to make the match unique."
43    }
44
45    fn parameters(&self) -> Value {
46        json!({
47            "type": "object",
48            "properties": {
49                "path": {
50                    "type": "string",
51                    "description": "Path to the file to edit"
52                },
53                "old_string": {
54                    "type": "string",
55                    "description": "The exact text to find and replace. Must match exactly once in the file."
56                },
57                "new_string": {
58                    "type": "string",
59                    "description": "The replacement text"
60                }
61            },
62            "required": ["path", "old_string", "new_string"]
63        })
64    }
65
66    async fn execute(&self, params: Value, ctx: ToolContext) -> Result<String> {
67        let raw_path = params["path"]
68            .as_str()
69            .ok_or_else(|| RuntimeError::Tool("Missing path parameter".to_string()))?;
70        let old_string = params["old_string"]
71            .as_str()
72            .ok_or_else(|| RuntimeError::Tool("Missing old_string parameter".to_string()))?;
73        let new_string = params["new_string"]
74            .as_str()
75            .ok_or_else(|| RuntimeError::Tool("Missing new_string parameter".to_string()))?;
76        if let Some(orchestration) = &ctx.capabilities.orchestration {
77            if matches!(
78                orchestration.check_foreground_write(raw_path),
79                agent_core::orchestration::ScopeDecision::ReconciliationRequired { .. }
80            ) {
81                return Err(RuntimeError::Tool(
82                    "foreground edit overlaps an active worker scope".into(),
83                ));
84            }
85        }
86
87        let path = resolve_path_in(raw_path, ctx.capabilities.cwd.as_deref());
88
89        let content = tokio::fs::read_to_string(&path).await.map_err(|e| {
90            RuntimeError::Tool(format!("Failed to read file '{}': {}", path.display(), e))
91        })?;
92
93        let count = content.matches(old_string).count();
94
95        if count == 0 {
96            return Err(RuntimeError::Tool(format!(
97                "old_string not found in '{}'. Make sure it matches exactly, including whitespace and indentation.",
98                path.display()
99            )));
100        }
101
102        if count > 1 {
103            return Err(RuntimeError::Tool(format!(
104                "old_string found {} times in '{}'. It must be unique — include more surrounding context.",
105                count, path.display()
106            )));
107        }
108
109        let new_content = content.replacen(old_string, new_string, 1);
110
111        // Preserve original file permissions (executable bits, etc.)
112        let original_perms = tokio::fs::metadata(&path)
113            .await
114            .map(|m| m.permissions())
115            .ok();
116
117        let tmp_path = super::util::unique_tmp_path(&path);
118        tokio::fs::write(&tmp_path, &new_content)
119            .await
120            .map_err(|e| RuntimeError::Tool(format!("Failed to write file: {}", e)))?;
121
122        // Restore original permissions on the temp file before rename
123        if let Some(perms) = original_perms {
124            let _ = tokio::fs::set_permissions(&tmp_path, perms).await;
125        }
126
127        tokio::fs::rename(&tmp_path, &path).await.map_err(|e| {
128            let tmp = tmp_path.clone();
129            tokio::spawn(async move {
130                let _ = tokio::fs::remove_file(tmp).await;
131            });
132            RuntimeError::Tool(format!("Failed to finalize edit: {}", e))
133        })?;
134
135        let old_lines: Vec<&str> = old_string.lines().collect();
136        let new_lines: Vec<&str> = new_string.lines().collect();
137        Ok(format!(
138            "Edited {} — replaced {} line(s) with {} line(s)",
139            path.display(),
140            old_lines.len(),
141            new_lines.len()
142        ))
143    }
144}
145#[cfg(test)]
146mod tests {
147    use super::super::test_helpers::create_tool_context;
148    use super::*;
149    use crate::tools::Tool;
150    use serde_json::json;
151
152    #[test]
153    fn test_edit_tool_schema() {
154        let tool = EditTool;
155        assert_eq!(tool.name(), "edit");
156        assert!(!tool.description().is_empty());
157
158        let params = tool.parameters();
159        assert_eq!(params["type"], "object");
160        assert!(params["properties"].is_object());
161        assert!(params["required"].is_array());
162    }
163
164    #[tokio::test]
165    async fn test_edit_tool_execution() {
166        let temp_dir = std::env::temp_dir();
167        let test_file = temp_dir.join("edit_tool_test.txt");
168
169        // Create file with known content
170        let initial_content = "Hello world\nThis is a test\nEnd of file";
171        std::fs::write(&test_file, initial_content).unwrap();
172
173        let tool = EditTool;
174
175        // Test successful replacement
176        let ctx = create_tool_context();
177        let params = json!({
178            "path": test_file.to_string_lossy(),
179            "old_string": "This is a test",
180            "new_string": "This is modified"
181        });
182
183        let result = tool.execute(params, ctx).await.unwrap();
184        assert!(result.contains("Edited"));
185        assert!(result.contains("replaced 1 line(s) with 1 line(s)"));
186
187        let modified_content = std::fs::read_to_string(&test_file).unwrap();
188        assert!(modified_content.contains("This is modified"));
189        assert!(!modified_content.contains("This is a test"));
190
191        // Test old_string not found
192        let ctx = create_tool_context();
193        let params = json!({
194            "path": test_file.to_string_lossy(),
195            "old_string": "nonexistent string",
196            "new_string": "replacement"
197        });
198
199        let result = tool.execute(params, ctx).await;
200        assert!(result.is_err());
201        assert!(result
202            .unwrap_err()
203            .to_string()
204            .contains("old_string not found"));
205
206        // Test old_string found multiple times
207        std::fs::write(&test_file, "test\ntest\nother").unwrap();
208        let ctx = create_tool_context();
209        let params = json!({
210            "path": test_file.to_string_lossy(),
211            "old_string": "test",
212            "new_string": "replacement"
213        });
214
215        let result = tool.execute(params, ctx).await;
216        assert!(result.is_err());
217        let error_msg = result.unwrap_err().to_string();
218        assert!(error_msg.contains("found 2 times"));
219        assert!(error_msg.contains("must be unique"));
220
221        // Cleanup
222        let _ = std::fs::remove_file(&test_file);
223    }
224
225    #[tokio::test]
226    async fn test_edit_tool_no_match() {
227        let temp_dir = std::env::temp_dir();
228        let test_file = temp_dir.join("test_edit_tool_no_match.txt");
229
230        // Create file with known content
231        let content = "some content\nmore content";
232        std::fs::write(&test_file, content).unwrap();
233
234        let tool = EditTool;
235        let ctx = create_tool_context();
236
237        let params = json!({
238            "path": test_file.to_string_lossy(),
239            "old_string": "this string does not exist",
240            "new_string": "replacement"
241        });
242
243        let result = tool.execute(params, ctx).await;
244
245        // Should return error about string not found
246        assert!(result.is_err());
247        let error = result.unwrap_err().to_string();
248        assert!(error.contains("old_string not found"));
249
250        // Cleanup
251        let _ = std::fs::remove_file(&test_file);
252    }
253
254    // Regression: two files sharing a stem but differing by extension must not
255    // collide on the same temp path. `Path::with_extension("agent-tmp")` mapped
256    // both `x.md` and `x.exe` to `x.agent-tmp`; concurrent edits corrupted each
257    // other and left an orphan temp file.
258    #[tokio::test]
259    async fn test_edit_same_stem_different_ext_no_collision() {
260        let dir = std::env::temp_dir().join(format!("edit_collision_{}", std::process::id()));
261        std::fs::create_dir_all(&dir).unwrap();
262        let md = dir.join("x.md");
263        let exe = dir.join("x.exe");
264        std::fs::write(&md, "hello from MD\ncommon").unwrap();
265        std::fs::write(&exe, "hello from EXE\ncommon").unwrap();
266
267        let tool = EditTool;
268        let edit_md = tool.execute(
269            json!({"path": md.to_string_lossy(), "old_string": "hello from MD", "new_string": "MD EDITED"}),
270            create_tool_context(),
271        );
272        let edit_exe = tool.execute(
273            json!({"path": exe.to_string_lossy(), "old_string": "hello from EXE", "new_string": "EXE EDITED"}),
274            create_tool_context(),
275        );
276        // Drive both concurrently to exercise the temp-path race.
277        let (r_md, r_exe) = tokio::join!(edit_md, edit_exe);
278        r_md.unwrap();
279        r_exe.unwrap();
280
281        // Each file keeps its own edited content — no cross-contamination.
282        let md_content = std::fs::read_to_string(&md).unwrap();
283        let exe_content = std::fs::read_to_string(&exe).unwrap();
284        assert!(md_content.contains("MD EDITED"), "md content: {md_content:?}");
285        assert!(!md_content.contains("EXE"), "md leaked exe content: {md_content:?}");
286        assert!(exe_content.contains("EXE EDITED"), "exe content: {exe_content:?}");
287        assert!(!exe_content.contains("from MD"), "exe leaked md content: {exe_content:?}");
288
289        // No orphan temp files left behind.
290        let leftovers: Vec<_> = std::fs::read_dir(&dir)
291            .unwrap()
292            .filter_map(|e| e.ok())
293            .filter(|e| e.file_name().to_string_lossy().contains("agent-tmp"))
294            .collect();
295        assert!(leftovers.is_empty(), "orphan temp files: {leftovers:?}");
296
297        let _ = std::fs::remove_dir_all(&dir);
298    }
299}