""" Edit tool - Precise file editing Edit files through exact text replacement """ import os from typing import Dict, Any from agent.tools.base_tool import BaseTool, ToolResult from agent.tools.utils.diff import ( strip_bom, detect_line_ending, normalize_to_lf, restore_line_endings, normalize_for_fuzzy_match, fuzzy_find_text, generate_diff_string ) class Edit(BaseTool): """Tool for precise file editing""" name: str = "edit" description: str = "Edit a file by replacing exact text. The oldText must match exactly (including whitespace). Use this for precise, surgical edits." params: dict = { "type": "object", "properties": { "path": { "type": "string", "description": "Path to the file to edit (relative or absolute)" }, "oldText": { "type": "string", "description": "Exact text to find and replace (must match exactly, cannot be empty). To append to end of file, include the last few lines as oldText." }, "newText": { "type": "string", "description": "New text to replace the old text with" } }, "required": ["path", "oldText", "newText"] } def __init__(self, config: dict = None): self.config = config or {} self.cwd = self.config.get("cwd", os.getcwd()) self.memory_manager = self.config.get("memory_manager", None) def execute(self, args: Dict[str, Any]) -> ToolResult: """ Execute file edit operation :param args: Contains file path, old text and new text :return: Operation result """ path = args.get("path", "").strip() old_text = args.get("oldText", "") new_text = args.get("newText", "") if not path: return ToolResult.fail("Error: path parameter is required") # Resolve path absolute_path = self._resolve_path(path) # Check if file exists if not os.path.exists(absolute_path): return ToolResult.fail(f"Error: File not found: {path}") # Check if readable/writable if not os.access(absolute_path, os.R_OK | os.W_OK): return ToolResult.fail(f"Error: File is not readable/writable: {path}") try: # Read file with open(absolute_path, 'r', encoding='utf-8') as f: raw_content = f.read() # Remove BOM (LLM won't include invisible BOM in oldText) bom, content = strip_bom(raw_content) # Detect original line ending original_ending = detect_line_ending(content) # Normalize to LF normalized_content = normalize_to_lf(content) normalized_old_text = normalize_to_lf(old_text) normalized_new_text = normalize_to_lf(new_text) # Use fuzzy matching to find old text (try exact match first, then fuzzy match) match_result = fuzzy_find_text(normalized_content, normalized_old_text) if not match_result.found: return ToolResult.fail( f"Error: Could not find the exact text in {path}. " "The old text must match exactly including all whitespace and newlines." ) # Calculate occurrence count (use fuzzy normalized content for consistency) fuzzy_content = normalize_for_fuzzy_match(normalized_content) fuzzy_old_text = normalize_for_fuzzy_match(normalized_old_text) occurrences = fuzzy_content.count(fuzzy_old_text) if occurrences > 1: return ToolResult.fail( f"Error: Found {occurrences} occurrences of the text in {path}. " "The text must be unique. Please provide more context to make it unique." ) # Execute replacement (use matched text position) base_content = match_result.content_for_replacement new_content = ( base_content[:match_result.index] + normalized_new_text + base_content[match_result.index + match_result.match_length:] ) # Verify replacement actually changed content if base_content == new_content: return ToolResult.fail( f"Error: No changes made to {path}. " "The replacement produced identical content. " "This might indicate an issue with special characters or the text not existing as expected." ) # Restore original line endings final_content = bom + restore_line_endings(new_content, original_ending) # Write file with open(absolute_path, 'w', encoding='utf-8') as f: f.write(final_content) # Generate diff diff_result = generate_diff_string(base_content, new_content) result = { "message": f"Successfully replaced text in {path}", "path": path, "diff": diff_result['diff'], "first_changed_line": diff_result['first_changed_line'] } # Notify memory manager if file is in memory directory if self.memory_manager and "memory/" in path: try: self.memory_manager.mark_dirty() except Exception as e: # Don't fail the edit if memory notification fails pass return ToolResult.success(result) except UnicodeDecodeError: return ToolResult.fail(f"Error: File is not a valid text file (encoding error): {path}") except PermissionError: return ToolResult.fail(f"Error: Permission denied accessing {path}") except Exception as e: return ToolResult.fail(f"Error editing file: {str(e)}") def _resolve_path(self, path: str) -> str: """ Resolve path to absolute path :param path: Relative or absolute path :return: Absolute path """ # Expand ~ to user home directory path = os.path.expanduser(path) if os.path.isabs(path): return path return os.path.abspath(os.path.join(self.cwd, path))