Source code for atloop.tools.filesystem.edit_file

"""Edit file tool with Git-style diff editing."""

import base64
import shlex
from typing import Any, Dict, Optional

from atloop.runtime.sandbox_adapter import SandboxAdapter
from atloop.tools.base import BaseTool, ToolResult


[docs] class EditFileTool(BaseTool): """ Tool for editing files using Git-style diff (old_string -> new_string). **⚠️ This is the preferred tool for modifying existing files!** **Why use edit_file instead of write_file:** - ✓ More precise: Only modifies the specified part - ✓ Safer: Doesn't risk overwriting unrelated code - ✓ More efficient: No need to read and rewrite entire file - ✓ Better for local modifications: Functions, classes, paragraphs, etc. **⚠️ CRITICAL: Do NOT generate code with {variable} patterns**: Patterns like `{error_output}`, `{variable}`, etc. will be written literally to the file and will NOT be populated by shell variable expansion. Use proper templating in the target language instead (e.g., Python f-strings, format(), etc.) **Safety features:** - Match count validation: Only replaces if old_string appears exactly once - Prevents accidental multiple replacements - Clear error messages when matches are not found or ambiguous **Use cases:** - Modifying a function or method - Updating a class definition - Changing a specific section of code - Fixing a bug in a specific location - Updating configuration values """
[docs] def __init__(self, sandbox: SandboxAdapter): """ Initialize edit file tool. Args: sandbox: Sandbox adapter instance """ self.sandbox = sandbox
@property def name(self) -> str: """Tool name.""" return "edit_file" @property def description(self) -> str: """Tool description.""" return ( "Edit files using Git-style diff (old_string -> new_string). " "⚠️ This is the preferred tool for modifying existing files! " "Why use edit_file instead of write_file: more precise (only modifies specified part), " "safer (doesn't risk overwriting unrelated code), more efficient (no need to read and rewrite entire file), " "better for local modifications (functions, classes, paragraphs, etc.). " "Safety features: match count validation (only replaces if old_string appears exactly once), " "prevents accidental multiple replacements, clear error messages when matches are not found or ambiguous. " "Use cases: modifying a function or method, updating a class definition, changing a specific section of code, " "fixing a bug in a specific location, updating configuration values. " "Content format: <old>old_string</old><new>new_string</new>. " "⚠️ CRITICAL: Do NOT generate code with {variable} patterns - they will be written literally. " "Use proper templating in the target language instead." )
[docs] def validate_args(self, args: Dict[str, Any]) -> tuple[bool, Optional[str]]: """Validate arguments.""" if "path" not in args: return False, "Missing required argument: 'path'" if "content" not in args: return False, "Missing required argument: 'content'" if not isinstance(args["path"], str): return False, "Argument 'path' must be a string" if not isinstance(args["content"], str): return False, "Argument 'content' must be a string" # Parse content to extract old_string and new_string content = args["content"] import re old_match = re.search(r"<old>(.*?)</old>", content, re.DOTALL) new_match = re.search(r"<new>(.*?)</new>", content, re.DOTALL) if not old_match or not new_match: return False, "content must be in format: <old>old_string</old><new>new_string</new>" old_string = old_match.group(1) new_string = new_match.group(1) # Check if old_string and new_string are the same if old_string == new_string: return False, "old_string and new_string are the same. No changes to make." return True, None
[docs] def execute(self, args: Dict[str, Any]) -> ToolResult: """ Execute edit file tool. **⚠️ IMPORTANT**: This is the preferred tool for modifying existing files! Use this instead of `write_file` for any file modifications. **Args:** args: Tool arguments dictionary - path (str, required): File path. Relative paths are relative to /workspace. - content (str, required): Content in format `<old>old_string</old><new>new_string</new>`. The old_string is the exact text to replace. Must match exactly including whitespace, indentation, and newlines. Should include at least 3 lines of context before and after to ensure uniqueness. - replace_all (bool, optional): If True, replace all occurrences of old_string. Default: False. When False, only replaces if old_string appears exactly once. **Returns:** ToolResult with: - ok (bool): True if edit was successful (no errors in stderr) - stdout (str): Diff summary (e.g., "Updated file: +2 lines, -1 lines") - stderr (str): Error messages if any (empty string means success) - meta (dict): Contains path, operation, replace_all **Examples:** # Modify a function edit_file( path="src/utils.py", content="<old>def calculate(x, y):\n return x + y</old><new>def calculate(x, y):\n return x * y</new>" ) # Add context for uniqueness edit_file( path="src/main.py", content="<old># Configuration\nDEBUG = True\n# End config</old><new># Configuration\nDEBUG = False\n# End config</new>" ) **Safety Checks:** - **Match count validation**: When replace_all=False (default): - If old_string appears 0 times → Error: "old_string not found" - If old_string appears 1 time → Success: Replaces it - If old_string appears 2+ times → Error: "found X times, make old_string more specific" - **When replace_all=True**: Replaces all occurrences without validation **Best Practices:** 1. **Include context**: Add at least 3 lines before and after the code you're changing 2. **Be precise**: Match exact whitespace, indentation, and newlines 3. **Make it unique**: Ensure old_string appears only once in the file 4. **Use read_file first**: Read the file to see exact formatting before editing **Error Messages:** - "old_string not found" → Check that old_string exactly matches file content - "found X times" → Add more context to make old_string unique, or use replace_all=True - "File not found" → File doesn't exist **Note on Trailing Newlines:** Files always end with exactly one newline character after editing, regardless of whether new_string ends with a newline or not. **⚠️ Important: Content with {variable} patterns:** Do NOT generate code or text with patterns like `{error_output}`, `{variable}`, etc. expecting them to be populated by shell variable expansion. These are written literally to the file and will NOT be expanded. If you need variable substitution, use proper templating mechanisms in the target language (e.g., Python f-strings, format(), etc.). """ path = args["path"] content = args["content"] replace_all = args.get("replace_all", False) # Parse content to extract old_string and new_string import re old_match = re.search(r"<old>(.*?)</old>", content, re.DOTALL) new_match = re.search(r"<new>(.*?)</new>", content, re.DOTALL) if not old_match or not new_match: return ToolResult( ok=False, stdout="", stderr="Invalid content format. Expected format: <old>old_string</old><new>new_string</new>", meta={"path": path}, ) old_string = old_match.group(1) new_string = new_match.group(1) # Handle paths - sandbox runs in /workspace directory # Relative paths are already relative to /workspace path_escaped = shlex.quote(path) # Check if file exists if old_string: check_cmd = f"test -f {path_escaped} && echo 'exists' || echo 'not_found'" check_result = self._run_command(check_cmd, timeout_sec=5) if "not_found" in check_result["stdout"]: return ToolResult( ok=False, stdout="", stderr=f"File not found: {path}. Cannot edit non-existent file.", meta={"path": path, "file_type": "not_found"}, ) # Read current file content read_cmd = f"cat {path_escaped} 2>/dev/null" read_result = self._run_command(read_cmd, timeout_sec=30) # Check for read errors in stderr, not exit code if read_result.get("stderr", "").strip(): return ToolResult( ok=False, stdout="", stderr=f"Failed to read file: {path}. Error: {read_result.get('stderr', 'Unknown error')}", meta={"path": path}, ) original_content = read_result.get("stdout", "") # Normalize line endings (handle both LF and CRLF) original_content = self._normalize_line_endings(original_content) old_string_normalized = self._normalize_line_endings(old_string) new_string_normalized = self._normalize_line_endings(new_string) # Handle edge case: if new_string is empty and old_string doesn't end with newline, # but the file has old_string + newline, match that old_string_for_replace = old_string_normalized if new_string_normalized == "" and not old_string_normalized.endswith("\n"): if original_content.find(old_string_normalized + "\n") != -1: old_string_for_replace = old_string_normalized + "\n" # Count occurrences for safety check match_count = original_content.count(old_string_for_replace) # Safety check: only replace if exactly one match (unless replace_all is True) if replace_all: # Replace all occurrences updated_content = original_content.replace( old_string_for_replace, new_string_normalized ) else: # Only replace if exactly one match if match_count == 0: return ToolResult( ok=False, stdout="", stderr="Edit failed: old_string not found in file. The text you're trying to replace does not exist in the file. Please check the old_string and try again.", meta={"path": path, "match_count": 0}, ) elif match_count > 1: return ToolResult( ok=False, stdout="", stderr=f"Edit failed: old_string found {match_count} times in file. To ensure safety, edit_file only replaces when old_string appears exactly once. Please make old_string more specific (add more context) to uniquely identify the location, or use replace_all=true to replace all occurrences.", meta={"path": path, "match_count": match_count}, ) else: # Exactly one match - safe to replace updated_content = original_content.replace( old_string_for_replace, new_string_normalized, 1 ) # Check if content actually changed (shouldn't happen with our checks, but just in case) if updated_content == original_content: return ToolResult( ok=False, stdout="", stderr="Edit failed: No changes made. This should not happen - please report this error.", meta={"path": path}, ) # Write updated content using base64 encoding to safely write file content # This avoids shell interpretation issues with special characters like {, }, $, etc. # Ensure content ends with exactly one newline content_for_write = updated_content if not content_for_write.endswith("\n"): content_for_write = content_for_write + "\n" # Encode content to base64 for safe transmission through shell content_b64 = base64.b64encode(content_for_write.encode("utf-8")).decode("ascii") write_cmd = f"echo {shlex.quote(content_b64)} | base64 -d > {path_escaped}" write_result = self._run_command(write_cmd, timeout_sec=30) # Check for write errors in stderr, not exit code if write_result.get("stderr", "").strip(): return ToolResult( ok=False, stdout="", stderr=f"Failed to write file: {path}. Error: {write_result.get('stderr', 'Unknown error')}", meta={"path": path}, ) # Generate diff summary diff_summary = self._generate_diff_summary( original_content, updated_content, old_string, new_string, ) # Get exit code from write_result exit_code = write_result.get("exitCode", write_result.get("exit_code", 0)) return ToolResult( ok=True, stdout=diff_summary, stderr="", meta={ "path": path, "operation": "create" if not old_string else ("delete" if not new_string else "update"), "exitCode": exit_code, # Include exit code for proper display "replace_all": replace_all, }, )
def _normalize_line_endings(self, text: str) -> str: """Normalize line endings to LF.""" # Replace CRLF with LF, then ensure all are LF return text.replace("\r\n", "\n").replace("\r", "\n") def _generate_diff_summary( self, original: str, updated: str, old_string: str, new_string: str, ) -> str: """Generate a human-readable diff summary.""" if not new_string: # Deletion return f"Removed {len(old_string.split(chr(10)))} lines." # Calculate line changes old_lines = old_string.split("\n") new_lines = new_string.split("\n") # Simple diff: count added/removed lines # This is a simplified version - full diff would use diff algorithm if len(new_lines) > len(old_lines): added = len(new_lines) - len(old_lines) return f"Updated file: +{added} lines, -{len(old_lines)} lines, +{len(new_lines)} lines total." elif len(new_lines) < len(old_lines): removed = len(old_lines) - len(new_lines) return f"Updated file: -{removed} lines, +{len(new_lines)} lines, -{len(old_lines)} lines total." else: return f"Updated file: {len(old_lines)} lines modified." def _run_command(self, cmd: str, timeout_sec: int = 600) -> Dict[str, Any]: """Run a shell command in sandbox.""" return self.sandbox.exec_shell( command=cmd, workdir="/workspace", timeout_seconds=timeout_sec, )