agent_engine/tools/
edit.rs1use 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 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 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 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 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 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 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 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 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 assert!(result.is_err());
239 let error = result.unwrap_err().to_string();
240 assert!(error.contains("old_string not found"));
241
242 let _ = std::fs::remove_file(&test_file);
244 }
245
246 #[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 let (r_md, r_exe) = tokio::join!(edit_md, edit_exe);
270 r_md.unwrap();
271 r_exe.unwrap();
272
273 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 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}