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