baqr/computer_use_ootb
0
1from collections import defaultdict2from pathlib import Path3from typing import Literal, get_args4 5from anthropic.types.beta import BetaToolTextEditor20241022Param6 7from .base import BaseAnthropicTool, CLIResult, ToolError, ToolResult8from .run import maybe_truncate, run9 10Command = Literal[11 "view",12 "create",13 "str_replace",14 "insert",15 "undo_edit",16]17SNIPPET_LINES: int = 418 19 20class EditTool(BaseAnthropicTool):21 """22 An filesystem editor tool that allows the agent to view, create, and edit files.23 The tool parameters are defined by Anthropic and are not editable.24 """25 26 api_type: Literal["text_editor_20241022"] = "text_editor_20241022"27 name: Literal["str_replace_editor"] = "str_replace_editor"28 29 _file_history: dict[Path, list[str]]30 31 def __init__(self):32 self._file_history = defaultdict(list)33 super().__init__()34 35 def to_params(self) -> BetaToolTextEditor20241022Param:36 return {37 "name": self.name,38 "type": self.api_type,39 }40 41 async def __call__(42 self,43 *,44 command: Command,45 path: str,46 file_text: str | None = None,47 view_range: list[int] | None = None,48 old_str: str | None = None,49 new_str: str | None = None,50 insert_line: int | None = None,51 **kwargs,52 ):53 _path = Path(path)54 self.validate_path(command, _path)55 if command == "view":56 return await self.view(_path, view_range)57 elif command == "create":58 if not file_text:59 raise ToolError("Parameter `file_text` is required for command: create")60 self.write_file(_path, file_text)61 self._file_history[_path].append(file_text)62 return ToolResult(output=f"File created successfully at: {_path}")63 elif command == "str_replace":64 if not old_str:65 raise ToolError(66 "Parameter `old_str` is required for command: str_replace"67 )68 return self.str_replace(_path, old_str, new_str)69 elif command == "insert":70 if insert_line is None:71 raise ToolError(72 "Parameter `insert_line` is required for command: insert"73 )74 if not new_str:75 raise ToolError("Parameter `new_str` is required for command: insert")76 return self.insert(_path, insert_line, new_str)77 elif command == "undo_edit":78 return self.undo_edit(_path)79 raise ToolError(80 f'Unrecognized command {command}. The allowed commands for the {self.name} tool are: {", ".join(get_args(Command))}'81 )82 83 def validate_path(self, command: str, path: Path):84 """85 Check that the path/command combination is valid.86 """87 # Check if its an absolute path88 if not path.is_absolute():89 suggested_path = Path("") / path90 raise ToolError(91 f"The path {path} is not an absolute path, it should start with `/`. Maybe you meant {suggested_path}?"92 )93 # Check if path exists94 if not path.exists() and command != "create":95 raise ToolError(96 f"The path {path} does not exist. Please provide a valid path."97 )98 if path.exists() and command == "create":99 raise ToolError(100 f"File already exists at: {path}. Cannot overwrite files using command `create`."101 )102 # Check if the path points to a directory103 if path.is_dir():104 if command != "view":105 raise ToolError(106 f"The path {path} is a directory and only the `view` command can be used on directories"107 )108 109 async def view(self, path: Path, view_range: list[int] | None = None):110 """Implement the view command"""111 if path.is_dir():112 if view_range:113 raise ToolError(114 "The `view_range` parameter is not allowed when `path` points to a directory."115 )116 117 _, stdout, stderr = await run(118 rf"find {path} -maxdepth 2 -not -path '*/\.*'"119 )120 if not stderr:121 stdout = f"Here's the files and directories up to 2 levels deep in {path}, excluding hidden items:\n{stdout}\n"122 return CLIResult(output=stdout, error=stderr)123 124 file_content = self.read_file(path)125 init_line = 1126 if view_range:127 if len(view_range) != 2 or not all(isinstance(i, int) for i in view_range):128 raise ToolError(129 "Invalid `view_range`. It should be a list of two integers."130 )131 file_lines = file_content.split("\n")132 n_lines_file = len(file_lines)133 init_line, final_line = view_range134 if init_line < 1 or init_line > n_lines_file:135 raise ToolError(136 f"Invalid `view_range`: {view_range}. It's first element `{init_line}` should be within the range of lines of the file: {[1, n_lines_file]}"137 )138 if final_line > n_lines_file:139 raise ToolError(140 f"Invalid `view_range`: {view_range}. It's second element `{final_line}` should be smaller than the number of lines in the file: `{n_lines_file}`"141 )142 if final_line != -1 and final_line < init_line:143 raise ToolError(144 f"Invalid `view_range`: {view_range}. It's second element `{final_line}` should be larger or equal than its first `{init_line}`"145 )146 147 if final_line == -1:148 file_content = "\n".join(file_lines[init_line - 1 :])149 else:150 file_content = "\n".join(file_lines[init_line - 1 : final_line])151 152 return CLIResult(153 output=self._make_output(file_content, str(path), init_line=init_line)154 )155 156 def str_replace(self, path: Path, old_str: str, new_str: str | None):157 """Implement the str_replace command, which replaces old_str with new_str in the file content"""158 # Read the file content159 file_content = self.read_file(path).expandtabs()160 old_str = old_str.expandtabs()161 new_str = new_str.expandtabs() if new_str is not None else ""162 163 # Check if old_str is unique in the file164 occurrences = file_content.count(old_str)165 if occurrences == 0:166 raise ToolError(167 f"No replacement was performed, old_str `{old_str}` did not appear verbatim in {path}."168 )169 elif occurrences > 1:170 file_content_lines = file_content.split("\n")171 lines = [172 idx + 1173 for idx, line in enumerate(file_content_lines)174 if old_str in line175 ]176 raise ToolError(177 f"No replacement was performed. Multiple occurrences of old_str `{old_str}` in lines {lines}. Please ensure it is unique"178 )179 180 # Replace old_str with new_str181 new_file_content = file_content.replace(old_str, new_str)182 183 # Write the new content to the file184 self.write_file(path, new_file_content)185 186 # Save the content to history187 self._file_history[path].append(file_content)188 189 # Create a snippet of the edited section190 replacement_line = file_content.split(old_str)[0].count("\n")191 start_line = max(0, replacement_line - SNIPPET_LINES)192 end_line = replacement_line + SNIPPET_LINES + new_str.count("\n")193 snippet = "\n".join(new_file_content.split("\n")[start_line : end_line + 1])194 195 # Prepare the success message196 success_msg = f"The file {path} has been edited. "197 success_msg += self._make_output(198 snippet, f"a snippet of {path}", start_line + 1199 )200 success_msg += "Review the changes and make sure they are as expected. Edit the file again if necessary."201 202 return CLIResult(output=success_msg)203 204 def insert(self, path: Path, insert_line: int, new_str: str):205 """Implement the insert command, which inserts new_str at the specified line in the file content."""206 file_text = self.read_file(path).expandtabs()207 new_str = new_str.expandtabs()208 file_text_lines = file_text.split("\n")209 n_lines_file = len(file_text_lines)210 211 if insert_line < 0 or insert_line > n_lines_file:212 raise ToolError(213 f"Invalid `insert_line` parameter: {insert_line}. It should be within the range of lines of the file: {[0, n_lines_file]}"214 )215 216 new_str_lines = new_str.split("\n")217 new_file_text_lines = (218 file_text_lines[:insert_line]219 + new_str_lines220 + file_text_lines[insert_line:]221 )222 snippet_lines = (223 file_text_lines[max(0, insert_line - SNIPPET_LINES) : insert_line]224 + new_str_lines225 + file_text_lines[insert_line : insert_line + SNIPPET_LINES]226 )227 228 new_file_text = "\n".join(new_file_text_lines)229 snippet = "\n".join(snippet_lines)230 231 self.write_file(path, new_file_text)232 self._file_history[path].append(file_text)233 234 success_msg = f"The file {path} has been edited. "235 success_msg += self._make_output(236 snippet,237 "a snippet of the edited file",238 max(1, insert_line - SNIPPET_LINES + 1),239 )240 success_msg += "Review the changes and make sure they are as expected (correct indentation, no duplicate lines, etc). Edit the file again if necessary."241 return CLIResult(output=success_msg)242 243 def undo_edit(self, path: Path):244 """Implement the undo_edit command."""245 if not self._file_history[path]:246 raise ToolError(f"No edit history found for {path}.")247 248 old_text = self._file_history[path].pop()249 self.write_file(path, old_text)250 251 return CLIResult(252 output=f"Last edit to {path} undone successfully. {self._make_output(old_text, str(path))}"253 )254 255 def read_file(self, path: Path):256 """Read the content of a file from a given path; raise a ToolError if an error occurs."""257 try:258 return path.read_text()259 except Exception as e:260 raise ToolError(f"Ran into {e} while trying to read {path}") from None261 262 def write_file(self, path: Path, file: str):263 """Write the content of a file to a given path; raise a ToolError if an error occurs."""264 try:265 path.write_text(file)266 except Exception as e:267 raise ToolError(f"Ran into {e} while trying to write to {path}") from None268 269 def _make_output(270 self,271 file_content: str,272 file_descriptor: str,273 init_line: int = 1,274 expand_tabs: bool = True,275 ):276 """Generate output for the CLI based on the content of a file."""277 file_content = maybe_truncate(file_content)278 if expand_tabs:279 file_content = file_content.expandtabs()280 file_content = "\n".join(281 [282 f"{i + init_line:6}\t{line}"283 for i, line in enumerate(file_content.split("\n"))284 ]285 )286 return (287 f"Here's the result of running `cat -n` on {file_descriptor}:\n"288 + file_content289 + "\n"290 )291 