Source code for agenix.tools.edit

"""Edit file tool with exact string replacement."""

import difflib
import os
from typing import Any, Callable, Dict, Optional

from .base import Tool, ToolResult


[docs] class EditTool(Tool): """Edit file by replacing exact strings."""
[docs] def __init__(self, working_dir: str = "."): self.working_dir = working_dir super().__init__( name="edit", description=( "Edit a file by replacing exact text. The old_string must match exactly " "(including whitespace). Use this for precise, surgical edits. For multiple " "changes, make separate edit calls. If unsure of exact text, use read first." ), parameters={ "type": "object", "properties": { "file_path": { "type": "string", "description": "Path to the file to edit" }, "old_string": { "type": "string", "description": "Exact string to replace (must match exactly including whitespace)" }, "new_string": { "type": "string", "description": "New string to replace with" }, "replace_all": { "type": "boolean", "description": "Replace all occurrences (default: false, only first occurrence)", "default": False } }, "required": ["file_path", "old_string", "new_string"] } )
[docs] async def execute( self, tool_call_id: str, arguments: Dict[str, Any], on_update: Optional[Callable[[str], None]] = None, ) -> ToolResult: """Execute edit operation.""" # Validate arguments if "file_path" not in arguments: return ToolResult( content="Error: Missing required argument 'file_path'. Please provide the path to the file you want to edit.", is_error=True ) if "old_string" not in arguments: return ToolResult( content="Error: Missing required argument 'old_string'. Please provide the exact text you want to replace.", is_error=True ) if "new_string" not in arguments: return ToolResult( content="Error: Missing required argument 'new_string'. Please provide the new text to replace with.", is_error=True ) file_path = arguments["file_path"] old_string = arguments["old_string"] new_string = arguments["new_string"] replace_all = arguments.get("replace_all", False) # Resolve path if not os.path.isabs(file_path): file_path = os.path.join(self.working_dir, file_path) try: # Read file if not os.path.exists(file_path): return ToolResult( content=f"Error: File not found: {file_path}. Please check the path and ensure the file exists.", is_error=True ) with open(file_path, 'r', encoding='utf-8') as f: original_content = f.read() # Check if old_string exists if old_string not in original_content: # Try to provide helpful feedback similar = self._find_similar_strings( original_content, old_string) msg = f"Error: Could not find the exact text to replace in {file_path}.\n" msg += "The old_string must match exactly (including whitespace).\n" msg += "Tip: Use the read tool first to see the exact content." if similar: msg += f"\n\nDid you mean one of these?\n{similar}" return ToolResult(content=msg, is_error=True) # Perform replacement if replace_all: new_content = original_content.replace(old_string, new_string) count = original_content.count(old_string) else: new_content = original_content.replace( old_string, new_string, 1) count = 1 # Write back with open(file_path, 'w', encoding='utf-8') as f: f.write(new_content) # Generate diff diff = self._generate_diff( original_content, new_content, file_path) # Find first changed line first_changed_line = self._find_first_changed_line( original_content, new_content) result_msg = f"Successfully replaced {count} occurrence(s) in {file_path}" if first_changed_line: result_msg += f" (first change at line {first_changed_line})" return ToolResult( content=f"{result_msg}\n\n{diff}", details={ "diff": diff, "first_changed_line": first_changed_line, "replacements": count } ) except PermissionError: return ToolResult( content=f"Error: Permission denied editing {file_path}. Check file permissions.", is_error=True ) except UnicodeDecodeError: return ToolResult( content=f"Error: Unable to read {file_path}. File might be binary or use unsupported encoding.", is_error=True ) except Exception as e: return ToolResult( content=f"Error editing file: {str(e)}. Please check the file and arguments, then try again.", is_error=True )
def _generate_diff(self, old: str, new: str, filename: str) -> str: """Generate unified diff.""" old_lines = old.splitlines(keepends=True) new_lines = new.splitlines(keepends=True) diff_lines = list(difflib.unified_diff( old_lines, new_lines, fromfile=f"a/{filename}", tofile=f"b/{filename}", lineterm='' )) return ''.join(diff_lines[:50]) # Limit diff size def _find_first_changed_line(self, old: str, new: str) -> Optional[int]: """Find the first line that changed.""" old_lines = old.splitlines() new_lines = new.splitlines() for i, (old_line, new_line) in enumerate(zip(old_lines, new_lines), start=1): if old_line != new_line: return i # Check if lines were added/removed at the end if len(old_lines) != len(new_lines): return min(len(old_lines), len(new_lines)) + 1 return None def _find_similar_strings(self, content: str, target: str, n: int = 3) -> str: """Find similar strings in content.""" lines = content.splitlines() target_lines = target.splitlines() if not target_lines: return "" # Find lines similar to the first line of target first_target_line = target_lines[0].strip() similar = [] for i, line in enumerate(lines, start=1): ratio = difflib.SequenceMatcher( None, line.strip(), first_target_line).ratio() if ratio > 0.6: # 60% similarity threshold similar.append((ratio, i, line)) # Sort by similarity and take top n similar.sort(reverse=True, key=lambda x: x[0]) results = [] for ratio, line_num, line in similar[:n]: results.append(f" Line {line_num}: {line.strip()[:80]}") return "\n".join(results) if results else ""