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