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 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 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 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 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 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 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 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 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 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 assert!(result.is_err());
247 let error = result.unwrap_err().to_string();
248 assert!(error.contains("old_string not found"));
249
250 let _ = std::fs::remove_file(&test_file);
252 }
253
254 #[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 let (r_md, r_exe) = tokio::join!(edit_md, edit_exe);
278 r_md.unwrap();
279 r_exe.unwrap();
280
281 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 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}