Source code for atloop.tools.filesystem.edit_file

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

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. **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 "编辑文件(Git 风格 diff 编辑,使用 content 参数,格式为 <old>old_string</old><new>new_string</new>)"
[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. """ 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 # Heredoc automatically adds a newline before FILE_EOF, so we need to handle trailing newlines # If content ends with \n, remove it to avoid double newline content_for_write = updated_content if content_for_write.endswith("\n"): content_for_write = content_for_write[:-1] write_cmd = f"cat > {path_escaped} <<'FILE_EOF'\n{content_for_write}\nFILE_EOF" 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, ) 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"), "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, )