agent_sdk/primitive_tools/
edit.rs1use crate::reminders::{append_reminder, builtin};
2use crate::{Environment, Tool, ToolContext, ToolResult, ToolTier};
3use anyhow::{Context, Result};
4use async_trait::async_trait;
5use serde::Deserialize;
6use serde_json::{Value, json};
7use std::sync::Arc;
8
9use super::PrimitiveToolContext;
10
11pub struct EditTool<E: Environment> {
13 ctx: PrimitiveToolContext<E>,
14}
15
16impl<E: Environment> EditTool<E> {
17 #[must_use]
18 pub const fn new(environment: Arc<E>, capabilities: crate::AgentCapabilities) -> Self {
19 Self {
20 ctx: PrimitiveToolContext::new(environment, capabilities),
21 }
22 }
23}
24
25#[derive(Debug, Deserialize)]
26struct EditInput {
27 #[serde(alias = "file_path")]
29 path: String,
30 old_string: String,
32 new_string: String,
34 #[serde(default)]
36 replace_all: bool,
37}
38
39#[async_trait]
40impl<E: Environment + 'static> Tool<()> for EditTool<E> {
41 fn name(&self) -> &'static str {
42 "edit"
43 }
44
45 fn description(&self) -> &'static str {
46 "Edit a file by replacing a string. The old_string must match exactly and uniquely (unless replace_all is true)."
47 }
48
49 fn tier(&self) -> ToolTier {
50 ToolTier::Confirm
51 }
52
53 fn input_schema(&self) -> Value {
54 json!({
55 "type": "object",
56 "properties": {
57 "path": {
58 "type": "string",
59 "description": "Path to the file to edit"
60 },
61 "old_string": {
62 "type": "string",
63 "description": "The exact string to find and replace"
64 },
65 "new_string": {
66 "type": "string",
67 "description": "The replacement string"
68 },
69 "replace_all": {
70 "type": "boolean",
71 "description": "Replace all occurrences instead of requiring unique match. Default: false"
72 }
73 },
74 "required": ["path", "old_string", "new_string"]
75 })
76 }
77
78 async fn execute(&self, _ctx: &ToolContext<()>, input: Value) -> Result<ToolResult> {
79 let input: EditInput =
80 serde_json::from_value(input).context("Invalid input for edit tool")?;
81
82 let path = self.ctx.environment.resolve_path(&input.path);
83
84 if !self.ctx.capabilities.can_write(&path) {
86 return Ok(ToolResult::error(format!(
87 "Permission denied: cannot edit '{path}'"
88 )));
89 }
90
91 let exists = self
93 .ctx
94 .environment
95 .exists(&path)
96 .await
97 .context("Failed to check file existence")?;
98
99 if !exists {
100 return Ok(ToolResult::error(format!("File not found: '{path}'")));
101 }
102
103 let is_dir = self
105 .ctx
106 .environment
107 .is_dir(&path)
108 .await
109 .context("Failed to check if path is directory")?;
110
111 if is_dir {
112 return Ok(ToolResult::error(format!(
113 "'{path}' is a directory, cannot edit"
114 )));
115 }
116
117 let content = self
119 .ctx
120 .environment
121 .read_file(&path)
122 .await
123 .context("Failed to read file")?;
124
125 let count = content.matches(&input.old_string).count();
127
128 if count == 0 {
129 return Ok(ToolResult::error(format!(
130 "String not found in '{}': '{}'",
131 path,
132 truncate_string(&input.old_string, 100)
133 )));
134 }
135
136 if count > 1 && !input.replace_all {
137 return Ok(ToolResult::error(format!(
138 "Found {count} occurrences of the string in '{path}'. Use replace_all: true to replace all, or provide a more specific string."
139 )));
140 }
141
142 let new_content = if input.replace_all {
144 content.replace(&input.old_string, &input.new_string)
145 } else {
146 content.replacen(&input.old_string, &input.new_string, 1)
147 };
148
149 self.ctx
151 .environment
152 .write_file(&path, &new_content)
153 .await
154 .context("Failed to write file")?;
155
156 let replacements = if input.replace_all { count } else { 1 };
157 let mut result = ToolResult::success(format!(
158 "Successfully replaced {replacements} occurrence(s) in '{path}'"
159 ));
160
161 append_reminder(&mut result, builtin::EDIT_VERIFICATION_REMINDER);
163
164 Ok(result)
165 }
166}
167
168fn truncate_string(s: &str, max_len: usize) -> String {
169 if s.len() <= max_len {
170 s.to_string()
171 } else {
172 format!("{}...", &s[..max_len])
173 }
174}
175
176#[cfg(test)]
177mod tests {
178 use super::*;
179 use crate::{AgentCapabilities, InMemoryFileSystem};
180
181 fn create_test_tool(
182 fs: Arc<InMemoryFileSystem>,
183 capabilities: AgentCapabilities,
184 ) -> EditTool<InMemoryFileSystem> {
185 EditTool::new(fs, capabilities)
186 }
187
188 fn tool_ctx() -> ToolContext<()> {
189 ToolContext::new(())
190 }
191
192 #[tokio::test]
197 async fn test_edit_simple_replacement() -> anyhow::Result<()> {
198 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
199 fs.write_file("test.txt", "Hello, World!").await?;
200
201 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
202 let result = tool
203 .execute(
204 &tool_ctx(),
205 json!({
206 "path": "/workspace/test.txt",
207 "old_string": "World",
208 "new_string": "Rust"
209 }),
210 )
211 .await?;
212
213 assert!(result.success);
214 assert!(result.output.contains("1 occurrence"));
215
216 let content = fs.read_file("/workspace/test.txt").await?;
217 assert_eq!(content, "Hello, Rust!");
218 Ok(())
219 }
220
221 #[tokio::test]
222 async fn test_edit_replace_all_true() -> anyhow::Result<()> {
223 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
224 fs.write_file("test.txt", "foo bar foo baz foo").await?;
225
226 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
227 let result = tool
228 .execute(
229 &tool_ctx(),
230 json!({
231 "path": "/workspace/test.txt",
232 "old_string": "foo",
233 "new_string": "qux",
234 "replace_all": true
235 }),
236 )
237 .await?;
238
239 assert!(result.success);
240 assert!(result.output.contains("3 occurrence"));
241
242 let content = fs.read_file("/workspace/test.txt").await?;
243 assert_eq!(content, "qux bar qux baz qux");
244 Ok(())
245 }
246
247 #[tokio::test]
248 async fn test_edit_multiline_replacement() -> anyhow::Result<()> {
249 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
250 fs.write_file("test.rs", "fn main() {\n println!(\"Hello\");\n}")
251 .await?;
252
253 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
254 let result = tool
255 .execute(
256 &tool_ctx(),
257 json!({
258 "path": "/workspace/test.rs",
259 "old_string": "println!(\"Hello\");",
260 "new_string": "println!(\"Hello, World!\");\n println!(\"Goodbye!\");"
261 }),
262 )
263 .await?;
264
265 assert!(result.success);
266
267 let content = fs.read_file("/workspace/test.rs").await?;
268 assert!(content.contains("Hello, World!"));
269 assert!(content.contains("Goodbye!"));
270 Ok(())
271 }
272
273 #[tokio::test]
278 async fn test_edit_permission_denied() -> anyhow::Result<()> {
279 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
280 fs.write_file("test.txt", "content").await?;
281
282 let caps = AgentCapabilities::read_only();
284
285 let tool = create_test_tool(fs, caps);
286 let result = tool
287 .execute(
288 &tool_ctx(),
289 json!({
290 "path": "/workspace/test.txt",
291 "old_string": "content",
292 "new_string": "new content"
293 }),
294 )
295 .await?;
296
297 assert!(!result.success);
298 assert!(result.output.contains("Permission denied"));
299 Ok(())
300 }
301
302 #[tokio::test]
303 async fn test_edit_denied_path() -> anyhow::Result<()> {
304 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
305 fs.write_file("secrets/config.txt", "secret=value").await?;
306
307 let caps = AgentCapabilities::full_access()
308 .with_denied_paths(vec!["/workspace/secrets/**".into()]);
309
310 let tool = create_test_tool(fs, caps);
311 let result = tool
312 .execute(
313 &tool_ctx(),
314 json!({
315 "path": "/workspace/secrets/config.txt",
316 "old_string": "value",
317 "new_string": "newvalue"
318 }),
319 )
320 .await?;
321
322 assert!(!result.success);
323 assert!(result.output.contains("Permission denied"));
324 Ok(())
325 }
326
327 #[tokio::test]
332 async fn test_edit_string_not_found() -> anyhow::Result<()> {
333 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
334 fs.write_file("test.txt", "Hello, World!").await?;
335
336 let tool = create_test_tool(fs, AgentCapabilities::full_access());
337 let result = tool
338 .execute(
339 &tool_ctx(),
340 json!({
341 "path": "/workspace/test.txt",
342 "old_string": "Rust",
343 "new_string": "Go"
344 }),
345 )
346 .await?;
347
348 assert!(!result.success);
349 assert!(result.output.contains("String not found"));
350 Ok(())
351 }
352
353 #[tokio::test]
354 async fn test_edit_multiple_occurrences_without_replace_all() -> anyhow::Result<()> {
355 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
356 fs.write_file("test.txt", "foo bar foo baz").await?;
357
358 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
359 let result = tool
360 .execute(
361 &tool_ctx(),
362 json!({
363 "path": "/workspace/test.txt",
364 "old_string": "foo",
365 "new_string": "qux"
366 }),
367 )
368 .await?;
369
370 assert!(!result.success);
371 assert!(result.output.contains("2 occurrences"));
372 assert!(result.output.contains("replace_all"));
373
374 let content = fs.read_file("/workspace/test.txt").await?;
376 assert_eq!(content, "foo bar foo baz");
377 Ok(())
378 }
379
380 #[tokio::test]
381 async fn test_edit_file_not_found() -> anyhow::Result<()> {
382 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
383
384 let tool = create_test_tool(fs, AgentCapabilities::full_access());
385 let result = tool
386 .execute(
387 &tool_ctx(),
388 json!({
389 "path": "/workspace/nonexistent.txt",
390 "old_string": "foo",
391 "new_string": "bar"
392 }),
393 )
394 .await?;
395
396 assert!(!result.success);
397 assert!(result.output.contains("File not found"));
398 Ok(())
399 }
400
401 #[tokio::test]
402 async fn test_edit_directory_path() -> anyhow::Result<()> {
403 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
404 fs.create_dir("/workspace/subdir").await?;
405
406 let tool = create_test_tool(fs, AgentCapabilities::full_access());
407 let result = tool
408 .execute(
409 &tool_ctx(),
410 json!({
411 "path": "/workspace/subdir",
412 "old_string": "foo",
413 "new_string": "bar"
414 }),
415 )
416 .await?;
417
418 assert!(!result.success);
419 assert!(result.output.contains("is a directory"));
420 Ok(())
421 }
422
423 #[tokio::test]
424 async fn test_edit_empty_new_string_deletes() -> anyhow::Result<()> {
425 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
426 fs.write_file("test.txt", "Hello, World!").await?;
427
428 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
429 let result = tool
430 .execute(
431 &tool_ctx(),
432 json!({
433 "path": "/workspace/test.txt",
434 "old_string": ", World",
435 "new_string": ""
436 }),
437 )
438 .await?;
439
440 assert!(result.success);
441
442 let content = fs.read_file("/workspace/test.txt").await?;
443 assert_eq!(content, "Hello!");
444 Ok(())
445 }
446
447 #[tokio::test]
448 async fn test_edit_special_characters() -> anyhow::Result<()> {
449 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
450 fs.write_file("test.txt", "įđæŪåįŽĶ emoji ð here").await?;
451
452 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
453 let result = tool
454 .execute(
455 &tool_ctx(),
456 json!({
457 "path": "/workspace/test.txt",
458 "old_string": "ð",
459 "new_string": "ð"
460 }),
461 )
462 .await?;
463
464 assert!(result.success);
465
466 let content = fs.read_file("/workspace/test.txt").await?;
467 assert!(content.contains("ð"));
468 assert!(!content.contains("ð"));
469 Ok(())
470 }
471
472 #[tokio::test]
473 async fn test_edit_tool_metadata() {
474 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
475 let tool = create_test_tool(fs, AgentCapabilities::full_access());
476
477 assert_eq!(tool.name(), "edit");
478 assert_eq!(tool.tier(), ToolTier::Confirm);
479 assert!(tool.description().contains("Edit"));
480
481 let schema = tool.input_schema();
482 assert!(schema.get("properties").is_some());
483 assert!(schema["properties"].get("path").is_some());
484 assert!(schema["properties"].get("old_string").is_some());
485 assert!(schema["properties"].get("new_string").is_some());
486 assert!(schema["properties"].get("replace_all").is_some());
487 }
488
489 #[tokio::test]
490 async fn test_edit_invalid_input() -> anyhow::Result<()> {
491 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
492 let tool = create_test_tool(fs, AgentCapabilities::full_access());
493
494 let result = tool
496 .execute(&tool_ctx(), json!({"path": "/workspace/test.txt"}))
497 .await;
498 assert!(result.is_err());
499 Ok(())
500 }
501
502 #[tokio::test]
503 async fn test_truncate_string_function() {
504 assert_eq!(truncate_string("short", 10), "short");
505 assert_eq!(
506 truncate_string("this is a longer string", 10),
507 "this is a ..."
508 );
509 }
510
511 #[tokio::test]
512 async fn test_edit_preserves_surrounding_content() -> anyhow::Result<()> {
513 let fs = Arc::new(InMemoryFileSystem::new("/workspace"));
514 let original = "line 1\nline 2 with target\nline 3";
515 fs.write_file("test.txt", original).await?;
516
517 let tool = create_test_tool(Arc::clone(&fs), AgentCapabilities::full_access());
518 let result = tool
519 .execute(
520 &tool_ctx(),
521 json!({
522 "path": "/workspace/test.txt",
523 "old_string": "target",
524 "new_string": "replacement"
525 }),
526 )
527 .await?;
528
529 assert!(result.success);
530
531 let content = fs.read_file("/workspace/test.txt").await?;
532 assert_eq!(content, "line 1\nline 2 with replacement\nline 3");
533 Ok(())
534 }
535}