From 8e7696b8e6027404b399cb27ec1aae39b34bf10d Mon Sep 17 00:00:00 2001 From: liushaojie Date: Mon, 26 Aug 2024 13:40:11 +0800 Subject: [PATCH 01/25] update: editor --- metagpt/prompts/di/swe_agent.py | 4 +- metagpt/roles/di/role_zero.py | 23 +- metagpt/roles/di/swe_agent.py | 3 +- metagpt/tools/libs/editor.py | 1004 ++++++++++++++++++----- metagpt/tools/libs/linter.py | 222 +++++ requirements.txt | 2 + tests/metagpt/tools/libs/test_editor.py | 490 ++++++++++- 7 files changed, 1516 insertions(+), 232 deletions(-) create mode 100644 metagpt/tools/libs/linter.py diff --git a/metagpt/prompts/di/swe_agent.py b/metagpt/prompts/di/swe_agent.py index 86a062214..b543c01d5 100644 --- a/metagpt/prompts/di/swe_agent.py +++ b/metagpt/prompts/di/swe_agent.py @@ -183,7 +183,9 @@ IMPORTANT_TIPS = """ 15. When the edit fails, try to enlarge the starting line. -16. Once again, and this is critical: YOU CAN ONLY ENTER ONE COMMAND AT A TIME. +16. Use an absolute path instead of a relative path. + +17. Once again, and this is critical: YOU CAN ONLY ENTER ONE COMMAND AT A TIME. """ NEXT_STEP_TEMPLATE = f""" diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index ab56dfa59..e32292b96 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -109,9 +109,6 @@ class RoleZero(Role): "Plan.append_task": self.planner.plan.append_task, "Plan.reset_task": self.planner.plan.reset_task, "Plan.replace_task": self.planner.plan.replace_task, - "Editor.write": self.editor.write, - "Editor.write_content": self.editor.write_content, - "Editor.read": self.editor.read, "RoleZero.ask_human": self.ask_human, "RoleZero.reply_to_human": self.reply_to_human, } @@ -132,6 +129,26 @@ class RoleZero(Role): ] } ) + self.tool_execution_map.update( + { + f"Editor.{i}": getattr(self.editor, i) + for i in [ + "append_file", + "create_file", + "edit_file_by_replace", + "find_file", + "goto_line", + "insert_content_at_line", + "open_file", + # "read", + "scroll_down", + "scroll_up", + "search_dir", + "search_file", + # "write", + ] + } + ) # can be updated by subclass self._update_tool_execution() return self diff --git a/metagpt/roles/di/swe_agent.py b/metagpt/roles/di/swe_agent.py index e1d2c9613..9efe9ce34 100644 --- a/metagpt/roles/di/swe_agent.py +++ b/metagpt/roles/di/swe_agent.py @@ -19,10 +19,11 @@ class SWEAgent(RoleZero): goal: str = "Resolve GitHub issue or bug in any existing codebase" _instruction: str = NEXT_STEP_TEMPLATE tools: list[str] = [ - "Bash", + # "Bash", "Browser:goto,scroll", "RoleZero", "git_create_pull", + "Editor", ] terminal: Bash = Field(default_factory=Bash, exclude=True) output_diff: str = "" diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index 240c28767..dde5df613 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -1,7 +1,8 @@ import base64 import os +import re import shutil -import subprocess +import tempfile from pathlib import Path from typing import List, Optional, Union @@ -9,12 +10,16 @@ from pydantic import BaseModel, ConfigDict from metagpt.config2 import Config from metagpt.logs import logger +from metagpt.tools.libs.linter import Linter from metagpt.tools.tool_registry import register_tool from metagpt.utils import read_docx from metagpt.utils.common import aread, aread_bin, awrite_bin, check_http_endpoint -from metagpt.utils.repo_to_markdown import is_text_file from metagpt.utils.report import EditorReporter +# This is also used in unit tests! +MSG_FILE_UPDATED = "[File updated (edited at line {line_number}). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]" +LINTER_ERROR_MSG = "[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n" + class FileBlock(BaseModel): """A block of content in a file""" @@ -23,203 +28,65 @@ class FileBlock(BaseModel): block_content: str +class LineNumberError(Exception): + pass + + @register_tool() class Editor(BaseModel): """ - A tool for reading, understanding, writing, and editing files. - Support local file including text-based files (txt, md, json, py, html, js, css, etc.), pdf, docx, excluding images, csv, excel, or online links + A state-of-state tool for reading, understanding, writing, and editing files. + All path parameters should use an absolute path. """ model_config = ConfigDict(arbitrary_types_allowed=True) resource: EditorReporter = EditorReporter() + # CURRENT_FILE: Optional[str] = None + current_file: Optional[str] = None + current_line: int = 1 + # WINDOW: int = 100 + window: int = 100 - def write(self, path: str, content: str): - """Write the whole content to a file. When used, make sure content arg contains the full content of the file.""" - if "\n" not in content and "\\n" in content: - # A very raw rule to correct the content: If 'content' lacks actual newlines ('\n') but includes '\\n', consider - # replacing them with '\n' to potentially correct mistaken representations of newline characters. - content = content.replace("\\n", "\n") - directory = os.path.dirname(path) - if directory and not os.path.exists(directory): - os.makedirs(directory) - with open(path, "w", encoding="utf-8") as f: - f.write(content) - # self.resource.report(path, "path") - return f"The writing/coding the of the file {os.path.basename(path)}' is now completed. The file '{os.path.basename(path)}' has been successfully created." + # def write(self, path: str, content: str): + # """Write the whole content to a file. When used, make sure content arg contains the full content of the file.""" + # if "\n" not in content and "\\n" in content: + # # A very raw rule to correct the content: If 'content' lacks actual newlines ('\n') but includes '\\n', consider + # # replacing them with '\n' to potentially correct mistaken representations of newline characters. + # content = content.replace("\\n", "\n") + # directory = os.path.dirname(path) + # if directory and not os.path.exists(directory): + # os.makedirs(directory) + # with open(path, "w", encoding="utf-8") as f: + # f.write(content) + # # self.resource.report(path, "path") + # return f"The writing/coding the of the file {os.path.basename(path)}' is now completed. The file '{os.path.basename(path)}' has been successfully created." - async def read(self, path: str) -> FileBlock: - """Read the whole content of a file. Using absolute paths as the argument for specifying the file location.""" - is_text, mime_type = await is_text_file(path) - if is_text: - lines = await self._read_text(path) - elif mime_type == "application/pdf": - lines = await self._read_pdf(path) - elif mime_type in { - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-word.document.macroEnabled.12", - "application/vnd.openxmlformats-officedocument.wordprocessingml.template", - "application/vnd.ms-word.template.macroEnabled.12", - }: - lines = await self._read_docx(path) - else: - return FileBlock(file_path=str(path), block_content="") - self.resource.report(str(path), "path") - - lines_with_num = [f"{i + 1:03}|{line}" for i, line in enumerate(lines)] - result = FileBlock( - file_path=str(path), - block_content="".join(lines_with_num), - ) - return result - - def search_content(self, symbol: str, root_path: str = ".", window: int = 50) -> FileBlock: - """ - Search symbol in all files under root_path, return the context of symbol with window size - Useful for locating class or function in a large codebase. Example symbol can be "def some_function", "class SomeClass", etc. - In searching, attempt different symbols of different granualities, e.g. "def some_function", "class SomeClass", a certain line of code, etc. - - Args: - symbol (str): The symbol to search. - root_path (str, optional): The root path to search in, the path can be a folder or a file. If not provided, search in the current directory. Defaults to ".". - window (int, optional): The window size to return. Defaults to 20. - - Returns: - FileBlock: The block containing the symbol, a pydantic BaseModel with the schema below. - class FileBlock(BaseModel): - file_path: str - block_content: str - """ - if not os.path.exists(root_path): - print(f"Currently at {os.getcwd()} containing: {os.listdir()}. Path {root_path} does not exist.") - return None - not_found_msg = ( - "symbol not found, you may try searching another one, or break down your search term to search a part of it" - ) - if os.path.isfile(root_path): - result = self._search_content_in_file(symbol, root_path, window) - if not result: - print(not_found_msg) - return result - for root, _, files in os.walk(root_path or "."): - for file in files: - file_path = os.path.join(root, file) - result = self._search_content_in_file(symbol, file_path, window) - if result: - # FIXME: This returns the first found result, not all results. - return result - print(not_found_msg) - return None - - def _search_content_in_file(self, symbol: str, file_path: str, window: int = 50) -> FileBlock: - print("search in", file_path) - if not file_path.endswith(".py"): - return None - with open(file_path, "r", encoding="utf-8") as f: - try: - lines = f.readlines() - except Exception: - return None - for i, line in enumerate(lines): - if symbol in line: - start = max(i - window, 0) - end = min(i + window, len(lines) - 1) - for row_num in range(start, end + 1): - lines[row_num] = f"{(row_num + 1):03}|{lines[row_num]}" - block_content = "".join(lines[start : end + 1]) - result = FileBlock( - file_path=file_path, - block_content=block_content, - ) - self.resource.report(result.file_path, "path", extra={"type": "search", "line": i, "symbol": symbol}) - return result - return None - - def write_content(self, file_path: str, start_line: int, end_line: int, new_block_content: str = "") -> str: - """ - Write a new block of content into a file. Use this method to update a block of code in a file. There are three cases: - 1. If the new block content is empty, the original block will be deleted. - 2. If the new block content is not empty and end_line < start_line (e.g. set end_line = -1) the new block content will be inserted at start_line. - 3. If the new block content is not empty and end_line >= start_line, the original block from start_line to end_line (both inclusively) will be replaced by the new block content. - This function can sometimes be used given a FileBlock upstream. You should carefully review its row number. Determine the start_line and end_line based on the row number of the FileBlock. - The file content from start_line to end_line will be replaced by your new_block_content. DON'T replace more than you intend to. - - Args: - file_path (str): The file path to write the new block content. - start_line (int): start line of the original block to be updated (inclusive). - end_line (int): end line of the original block to be updated (inclusive). - new_block_content (str): The new block content to write. Don't include row number in the content. - - Returns: - str: A message indicating the status of the write operation. - """ - # Create a temporary copy of the file - temp_file_path = file_path + ".temp" - shutil.copy(file_path, temp_file_path) - - try: - # Modify the temporary file with the new content - self._write_content(temp_file_path, start_line, end_line, new_block_content) - - # Lint the modified temporary file - lint_passed, lint_message = self._lint_file(temp_file_path) - # if not lint_passed: - # return f"Linting the content at a temp file, failed with:\n{lint_message}" - - # If linting passes, overwrite the original file with the temporary file - shutil.move(temp_file_path, file_path) - - new_file_block = FileBlock( - file_path=file_path, - block_content=new_block_content, - ) - self.resource.report(new_file_block.file_path, "path") - - return f"Content written successfully to {file_path}" - - finally: - # Clean up: Ensure the temporary file is removed if it still exists - if os.path.exists(temp_file_path): - os.remove(temp_file_path) - - def _write_content(self, file_path: str, start_line: int, end_line: int, new_block_content: str = ""): - """start_line and end_line are both 1-based indices and inclusive.""" - with open(file_path, "r") as file: - lines = file.readlines() - - start_line_index = start_line - 1 # Adjusting because list indices start at 0 - end_line_index = end_line - - if new_block_content: - # Split the new_block_content by newline and ensure each line ends with a newline character - new_content_lines = new_block_content.splitlines( - keepends=True - ) # FIXME: This will split \n within a line, such as ab\ncd - if end_line >= start_line: - # This replaces the block between start_line and end_line with new_block_content - # irrespective of the length difference between the original and new content. - lines[start_line_index:end_line_index] = new_content_lines - else: - lines.insert(start_line_index, "".join(new_content_lines)) - else: - del lines[start_line_index:end_line_index] - - with open(file_path, "w") as file: - file.writelines(lines) - - @classmethod - def _lint_file(cls, file_path: str) -> (bool, str): - """Lints an entire Python file using pylint, returns True if linting passes, along with pylint's output.""" - result = subprocess.run( - ["pylint", file_path, "--disable=all", "--enable=E"], - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - ) - lint_passed = result.returncode == 0 - lint_message = result.stdout - return lint_passed, lint_message + # async def read(self, path: str) -> FileBlock: + # """Read the whole content of a file. Using absolute paths as the argument for specifying the file location.""" + # is_text, mime_type = await is_text_file(path) + # if is_text: + # lines = await self._read_text(path) + # elif mime_type == "application/pdf": + # lines = await self._read_pdf(path) + # elif mime_type in { + # "application/msword", + # "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + # "application/vnd.ms-word.document.macroEnabled.12", + # "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + # "application/vnd.ms-word.template.macroEnabled.12", + # }: + # lines = await self._read_docx(path) + # else: + # return FileBlock(file_path=str(path), block_content="") + # self.resource.report(str(path), "path") + # + # lines_with_num = [f"{i + 1:03}|{line}" for i, line in enumerate(lines)] + # result = FileBlock( + # file_path=str(path), + # block_content="".join(lines_with_num), + # ) + # return result @staticmethod async def _read_text(path: Union[str, Path]) -> List[str]: @@ -292,3 +159,762 @@ class Editor(BaseModel): if config.omniparse and config.omniparse.url: return config.omniparse.url return "" + + @staticmethod + def _is_valid_filename(file_name) -> bool: + if not file_name or not isinstance(file_name, str) or not file_name.strip(): + return False + invalid_chars = '<>:"/\\|?*' + if os.name == "nt": # Windows + invalid_chars = '<>:"/\\|?*' + elif os.name == "posix": # Unix-like systems + invalid_chars = "\0" + + for char in invalid_chars: + if char in file_name: + return False + return True + + @staticmethod + def _is_valid_path(path) -> bool: + if not path or not isinstance(path, str): + return False + try: + return os.path.exists(os.path.normpath(path)) + except PermissionError: + return False + + @staticmethod + def _create_paths(file_name) -> bool: + try: + dirname = os.path.dirname(file_name) + if dirname: + os.makedirs(dirname, exist_ok=True) + return True + except PermissionError: + return False + + def _check_current_file(self, file_path: Optional[str] = None) -> bool: + if not file_path: + file_path = self.current_file + if not file_path or not os.path.isfile(file_path): + raise ValueError("No file open. Use the open_file function first.") + return True + + @staticmethod + def _clamp(value, min_value, max_value): + return max(min_value, min(value, max_value)) + + @staticmethod + def _lint_file(file_path: str) -> tuple[Optional[str], Optional[int]]: + """Lint the file at the given path and return a tuple with a boolean indicating if there are errors, + and the line number of the first error, if any. + + Returns: + tuple[str | None, int | None]: (lint_error, first_error_line_number) + """ + linter = Linter(root=os.getcwd()) + lint_error = linter.lint(file_path) + if not lint_error: + # Linting successful. No issues found. + return None, None + return "ERRORS:\n" + lint_error.text, lint_error.lines[0] + + def _print_window(self, file_path, targeted_line, window, return_str=False): + self._check_current_file(file_path) + with open(file_path) as file: + content = file.read() + + # Ensure the content ends with a newline character + if not content.endswith("\n"): + content += "\n" + + lines = content.splitlines(True) # Keep all line ending characters + total_lines = len(lines) + + # cover edge cases + self.current_line = self._clamp(targeted_line, 1, total_lines) + half_window = max(1, window // 2) + + # Ensure at least one line above and below the targeted line + start = max(1, self.current_line - half_window) + end = min(total_lines, self.current_line + half_window) + + # Adjust start and end to ensure at least one line above and below + if start == 1: + end = min(total_lines, start + window - 1) + if end == total_lines: + start = max(1, end - window + 1) + + output = "" + + # only display this when there's at least one line above + if start > 1: + output += f"({start - 1} more lines above)\n" + else: + output += "(this is the beginning of the file)\n" + for i in range(start, end + 1): + _new_line = f"{i}|{lines[i - 1]}" + if not _new_line.endswith("\n"): + _new_line += "\n" + output += _new_line + if end < total_lines: + output += f"({total_lines - end} more lines below)\n" + else: + output += "(this is the end of the file)\n" + output = output.rstrip() + + if return_str: + return output + else: + print(output) + + @staticmethod + def _cur_file_header(current_file, total_lines) -> str: + if not current_file: + return "" + return f"[File: {os.path.abspath(current_file)} ({total_lines} lines total)]\n" + + def open_file(self, path: str, line_number: Optional[int] = 1, context_lines: Optional[int] = None) -> None: + """Opens the file at the given path in the editor. If line_number is provided, the window will be moved to include that line. + It only shows the first 100 lines by default! Max `context_lines` supported is 2000, use `scroll up/down` + to view the file if you want to see more. + + Args: + path: str: The path to the file to open, preferred absolute path. + line_number: int | None = 1: The line number to move to. Defaults to 1. + context_lines: int | None = 100: Only shows this number of lines in the context window (usually from line 1), with line_number as the center (if possible). Defaults to 100. + """ + if context_lines is None: + context_lines = self.window + + if not os.path.isfile(path): + raise FileNotFoundError(f"File {path} not found") + + CURRENT_FILE = os.path.abspath(path) + with open(CURRENT_FILE) as file: + total_lines = max(1, sum(1 for _ in file)) + + if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines: + raise ValueError(f"Line number must be between 1 and {total_lines}") + self.current_line = line_number + + # Override WINDOW with context_lines + if context_lines is None or context_lines < 1: + context_lines = self.window + + output = self._cur_file_header(CURRENT_FILE, total_lines) + output += self._print_window( + CURRENT_FILE, self.current_line, self._clamp(context_lines, 1, 2000), return_str=True + ) + print(output) + + def goto_line(self, line_number: int) -> None: + """Moves the window to show the specified line number. + + Args: + line_number: int: The line number to move to. + """ + self._check_current_file() + + with open(str(self.current_file)) as file: + total_lines = max(1, sum(1 for _ in file)) + if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines: + raise ValueError(f"Line number must be between 1 and {total_lines}") + + self.current_line = self._clamp(line_number, 1, total_lines) + + output = self._cur_file_header(self.current_file, total_lines) + output += self._print_window(self.current_file, self.current_line, self.window, return_str=True) + print(output) + + def scroll_down(self) -> None: + """Moves the window down by 100 lines.""" + self._check_current_file() + + with open(str(self.current_file)) as file: + total_lines = max(1, sum(1 for _ in file)) + self.current_line = self._clamp(self.current_line + self.window, 1, total_lines) + output = self._cur_file_header(self.current_file, total_lines) + output += self._print_window(self.current_file, self.current_line, self.window, return_str=True) + print(output) + + def scroll_up(self) -> None: + """Moves the window up by 100 lines.""" + self._check_current_file() + + with open(str(self.current_file)) as file: + total_lines = max(1, sum(1 for _ in file)) + self.current_line = self._clamp(self.current_line - self.window, 1, total_lines) + output = self._cur_file_header(self.current_file, total_lines) + output += self._print_window(self.current_file, self.current_line, self.window, return_str=True) + print(output) + + @classmethod + def create_file(cls, filename: str) -> None: + """Creates and opens a new file with the given name. + + Args: + filename: str: The name of the file to create. + """ + if os.path.exists(filename): + raise FileExistsError(f"File '{filename}' already exists.") + + with open(filename, "w") as file: + file.write("\n") + + cls.open_file(filename) + print(f"[File {filename} created.]") + + @staticmethod + def _append_impl(lines, content): + """Internal method to handle appending to a file. + + Args: + lines: list[str]: The lines in the original file. + content: str: The content to append to the file. + + Returns: + content: str: The new content of the file. + n_added_lines: int: The number of lines added to the file. + """ + content_lines = content.splitlines(keepends=True) + n_added_lines = len(content_lines) + if lines and not (len(lines) == 1 and lines[0].strip() == ""): + # file is not empty + if not lines[-1].endswith("\n"): + lines[-1] += "\n" + new_lines = lines + content_lines + content = "".join(new_lines) + else: + # file is empty + content = "".join(content_lines) + + return content, n_added_lines + + @staticmethod + def _insert_impl(lines, start, content): + """Internal method to handle inserting to a file. + + Args: + lines: list[str]: The lines in the original file. + start: int: The start line number for inserting. + content: str: The content to insert to the file. + + Returns: + content: str: The new content of the file. + n_added_lines: int: The number of lines added to the file. + + Raises: + LineNumberError: If the start line number is invalid. + """ + inserted_lines = [content + "\n" if not content.endswith("\n") else content] + if len(lines) == 0: + new_lines = inserted_lines + elif start is not None: + if len(lines) == 1 and lines[0].strip() == "": + # if the file with only 1 line and that line is empty + lines = [] + + if len(lines) == 0: + new_lines = inserted_lines + else: + new_lines = lines[: start - 1] + inserted_lines + lines[start - 1 :] + else: + raise LineNumberError( + f"Invalid line number: {start}. Line numbers must be between 1 and {len(lines)} (inclusive)." + ) + + content = "".join(new_lines) + n_added_lines = len(inserted_lines) + return content, n_added_lines + + @staticmethod + def _edit_impl(lines, start, end, content): + """Internal method to handle editing a file. + + REQUIRES (should be checked by caller): + start <= end + start and end are between 1 and len(lines) (inclusive) + content ends with a newline + + Args: + lines: list[str]: The lines in the original file. + start: int: The start line number for editing. + end: int: The end line number for editing. + content: str: The content to replace the lines with. + + Returns: + content: str: The new content of the file. + n_added_lines: int: The number of lines added to the file. + """ + # Handle cases where start or end are None + if start is None: + start = 1 # Default to the beginning + if end is None: + end = len(lines) # Default to the end + # Check arguments + if not (1 <= start <= len(lines)): + raise LineNumberError( + f"Invalid start line number: {start}. Line numbers must be between 1 and {len(lines)} (inclusive)." + ) + if not (1 <= end <= len(lines)): + raise LineNumberError( + f"Invalid end line number: {end}. Line numbers must be between 1 and {len(lines)} (inclusive)." + ) + if start > end: + raise LineNumberError(f"Invalid line range: {start}-{end}. Start must be less than or equal to end.") + + if not content.endswith("\n"): + content += "\n" + content_lines = content.splitlines(True) + n_added_lines = len(content_lines) + new_lines = lines[: start - 1] + content_lines + lines[end:] + content = "".join(new_lines) + return content, n_added_lines + + def _edit_file_impl( + self, + file_name: str, + start: Optional[int] = None, + end: Optional[int] = None, + content: str = "", + is_insert: bool = False, + is_append: bool = False, + ) -> str: + """Internal method to handle common logic for edit_/append_file methods. + + Args: + file_name: str: The name of the file to edit or append to. + start: int | None = None: The start line number for editing. Ignored if is_append is True. + end: int | None = None: The end line number for editing. Ignored if is_append is True. + content: str: The content to replace the lines with or to append. + is_insert: bool = False: Whether to insert content at the given line number instead of editing. + is_append: bool = False: Whether to append content to the file instead of editing. + """ + ret_str = "" + + ERROR_MSG = f"[Error editing file {file_name}. Please confirm the file is correct.]" + ERROR_MSG_SUFFIX = ( + "Your changes have NOT been applied. Please fix your edit command and try again.\n" + "You either need to 1) Open the correct file and try again or 2) Specify the correct line number arguments.\n" + "DO NOT re-run the same failed edit command. Running it again will lead to the same error." + ) + + if not self._is_valid_filename(file_name): + raise FileNotFoundError("Invalid file name.") + + if not self._is_valid_path(file_name): + raise FileNotFoundError("Invalid path or file name.") + + if not self._create_paths(file_name): + raise PermissionError("Could not access or create directories.") + + if not os.path.isfile(file_name): + raise FileNotFoundError(f"File {file_name} not found.") + + if is_insert and is_append: + raise ValueError("Cannot insert and append at the same time.") + + # Use a temporary file to write changes + content = str(content or "") + temp_file_path = "" + src_abs_path = os.path.abspath(file_name) + first_error_line = None + + try: + n_added_lines = None + + # lint the original file + enable_auto_lint = os.getenv("ENABLE_AUTO_LINT", "false").lower() == "true" + if enable_auto_lint: + original_lint_error, _ = self._lint_file(file_name) + + # Create a temporary file + with tempfile.NamedTemporaryFile("w", delete=False) as temp_file: + temp_file_path = temp_file.name + + # Read the original file and check if empty and for a trailing newline + with open(file_name) as original_file: + lines = original_file.readlines() + + if is_append: + content, n_added_lines = self._append_impl(lines, content) + elif is_insert: + try: + content, n_added_lines = self._insert_impl(lines, start, content) + except LineNumberError as e: + ret_str += (f"{ERROR_MSG}\n" f"{e}\n" f"{ERROR_MSG_SUFFIX}") + "\n" + return ret_str + else: + try: + content, n_added_lines = self._edit_impl(lines, start, end, content) + except LineNumberError as e: + ret_str += (f"{ERROR_MSG}\n" f"{e}\n" f"{ERROR_MSG_SUFFIX}") + "\n" + return ret_str + + if not content.endswith("\n"): + content += "\n" + + # Write the new content to the temporary file + temp_file.write(content) + + # Replace the original file with the temporary file atomically + shutil.move(temp_file_path, src_abs_path) + + # Handle linting + # NOTE: we need to get env var inside this function + # because the env var will be set AFTER the agentskills is imported + if enable_auto_lint: + # BACKUP the original file + original_file_backup_path = os.path.join( + os.path.dirname(file_name), + f".backup.{os.path.basename(file_name)}", + ) + with open(original_file_backup_path, "w") as f: + f.writelines(lines) + + lint_error, first_error_line = self._lint_file(file_name) + + # Select the errors caused by the modification + def extract_last_part(line): + parts = line.split(":") + if len(parts) > 1: + return parts[-1].strip() + return line.strip() + + def subtract_strings(str1, str2) -> str: + lines1 = str1.splitlines() + lines2 = str2.splitlines() + + last_parts1 = [extract_last_part(line) for line in lines1] + + remaining_lines = [line for line in lines2 if extract_last_part(line) not in last_parts1] + + result = "\n".join(remaining_lines) + return result + + if original_lint_error and lint_error: + lint_error = subtract_strings(original_lint_error, lint_error) + if lint_error == "": + lint_error = None + first_error_line = None + + if lint_error is not None: + if first_error_line is not None: + show_line = int(first_error_line) + elif is_append: + # original end-of-file + show_line = len(lines) + # insert OR edit WILL provide meaningful line numbers + elif start is not None and end is not None: + show_line = int((start + end) / 2) + else: + raise ValueError("Invalid state. This should never happen.") + + ret_str += LINTER_ERROR_MSG + ret_str += lint_error + "\n" + + editor_lines = n_added_lines + 20 + + ret_str += "[This is how your edit would have looked if applied]\n" + ret_str += "-------------------------------------------------\n" + ret_str += self._print_window(file_name, show_line, editor_lines, return_str=True) + "\n" + ret_str += "-------------------------------------------------\n\n" + + ret_str += "[This is the original code before your edit]\n" + ret_str += "-------------------------------------------------\n" + ret_str += ( + self._print_window( + original_file_backup_path, + show_line, + editor_lines, + return_str=True, + ) + + "\n" + ) + ret_str += "-------------------------------------------------\n" + + ret_str += ( + "Your changes have NOT been applied. Please fix your edit command and try again.\n" + "You either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\n" + "DO NOT re-run the same failed edit command. Running it again will lead to the same error." + ) + + # recover the original file + with open(original_file_backup_path) as fin, open(file_name, "w") as fout: + fout.write(fin.read()) + os.remove(original_file_backup_path) + return ret_str + + except FileNotFoundError as e: + ret_str += f"File not found: {e}\n" + except IOError as e: + ret_str += f"An error occurred while handling the file: {e}\n" + except ValueError as e: + ret_str += f"Invalid input: {e}\n" + except Exception as e: + # Clean up the temporary file if an error occurs + if temp_file_path and os.path.exists(temp_file_path): + os.remove(temp_file_path) + print(f"An unexpected error occurred: {e}") + raise e + + # Update the file information and print the updated content + with open(file_name, "r", encoding="utf-8") as file: + n_total_lines = max(1, len(file.readlines())) + if first_error_line is not None and int(first_error_line) > 0: + self.current_line = first_error_line + else: + if is_append: + self.current_line = max(1, len(lines)) # end of original file + else: + self.current_line = start or n_total_lines or 1 + ret_str += f"[File: {os.path.abspath(file_name)} ({n_total_lines} lines total after edit)]\n" + CURRENT_FILE = file_name + ret_str += self._print_window(CURRENT_FILE, self.current_line, self.window, return_str=True) + "\n" + ret_str += MSG_FILE_UPDATED.format(line_number=self.current_line) + return ret_str + + @classmethod + def edit_file_by_replace(cls, file_name: str, to_replace: str, new_content: str) -> None: + """Edit a file. This will search for `to_replace` in the given file and replace it with `new_content`. + + Every *to_replace* must *EXACTLY MATCH* the existing source code, character for character, including all comments, docstrings, etc. + + Include enough lines to make code in `to_replace` unique. `to_replace` should NOT be empty. + + For example, given a file "/workspace/example.txt" with the following content: + ``` + line 1 + line 2 + line 2 + line 3 + ``` + + EDITING: If you want to replace the second occurrence of "line 2", you can make `to_replace` unique: + + edit_file_by_replace( + '/workspace/example.txt', + to_replace='line 2\nline 3', + new_content='new line\nline 3', + ) + + This will replace only the second "line 2" with "new line". The first "line 2" will remain unchanged. + + The resulting file will be: + ``` + line 1 + line 2 + new line + line 3 + ``` + + REMOVAL: If you want to remove "line 2" and "line 3", you can set `new_content` to an empty string: + + edit_file_by_replace( + '/workspace/example.txt', + to_replace='line 2\nline 3', + new_content='', + ) + + Args: + file_name: str: The name of the file to edit. + to_replace: str: The content to search for and replace. + new_content: str: The new content to replace the old content with. + """ + # FIXME: support replacing *all* occurrences + if to_replace.strip() == "": + raise ValueError("`to_replace` must not be empty.") + + if to_replace == new_content: + raise ValueError("`to_replace` and `new_content` must be different.") + + # search for `to_replace` in the file + # if found, replace it with `new_content` + # if not found, perform a fuzzy search to find the closest match and replace it with `new_content` + with open(file_name, "r") as file: + file_content = file.read() + + if file_content.count(to_replace) > 1: + raise ValueError( + "`to_replace` appears more than once, please include enough lines to make code in `to_replace` unique." + ) + + start = file_content.find(to_replace) + if start != -1: + # Convert start from index to line number + start_line_number = file_content[:start].count("\n") + 1 + end_line_number = start_line_number + len(to_replace.splitlines()) - 1 + else: + + def _fuzzy_transform(s: str) -> str: + # remove all space except newline + return re.sub(r"[^\S\n]+", "", s) + + # perform a fuzzy search (remove all spaces except newlines) + to_replace_fuzzy = _fuzzy_transform(to_replace) + file_content_fuzzy = _fuzzy_transform(file_content) + # find the closest match + start = file_content_fuzzy.find(to_replace_fuzzy) + if start == -1: + print(f"[No exact match found in {file_name} for\n```\n{to_replace}\n```\n]") + return + # Convert start from index to line number for fuzzy match + start_line_number = file_content_fuzzy[:start].count("\n") + 1 + end_line_number = start_line_number + len(to_replace.splitlines()) - 1 + + ret_str = cls._edit_file_impl( + file_name, + start=start_line_number, + end=end_line_number, + content=new_content, + is_insert=False, + ) + # lint_error = bool(LINTER_ERROR_MSG in ret_str) + # TODO: automatically tries to fix linter error (maybe involve some static analysis tools on the location near the edit to figure out indentation) + print(ret_str) + + @classmethod + def insert_content_at_line(cls, file_name: str, line_number: int, content: str) -> None: + """Insert content at the given line number in a file. + This will NOT modify the content of the lines before OR after the given line number. + + For example, if the file has the following content: + ``` + line 1 + line 2 + line 3 + ``` + and you call `insert_content_at_line('file.txt', 2, 'new line')`, the file will be updated to: + ``` + line 1 + new line + line 2 + line 3 + ``` + + Args: + file_name: str: The name of the file to edit. + line_number: int: The line number (starting from 1) to insert the content after. + content: str: The content to insert. + """ + ret_str = cls._edit_file_impl( + file_name, + start=line_number, + end=line_number, + content=content, + is_insert=True, + is_append=False, + ) + print(ret_str) + + @classmethod + def append_file(cls, file_name: str, content: str) -> None: + """Append content to the given file. + It appends text `content` to the end of the specified file. + + Args: + file_name: str: The name of the file to edit. + line_number: int: The line number (starting from 1) to insert the content after. + content: str: The content to insert. + """ + ret_str = cls._edit_file_impl( + file_name, + start=None, + end=None, + content=content, + is_insert=False, + is_append=True, + ) + print(ret_str) + + @classmethod + def search_dir(cls, search_term: str, dir_path: str = "./") -> None: + """Searches for search_term in all files in dir. If dir is not provided, searches in the current directory. + + Args: + search_term: str: The term to search for. + dir_path: str: The path to the directory to search. + """ + if not os.path.isdir(dir_path): + raise FileNotFoundError(f"Directory {dir_path} not found") + matches = [] + for root, _, files in os.walk(dir_path): + for file in files: + if file.startswith("."): + continue + file_path = os.path.join(root, file) + with open(file_path, "r", errors="ignore") as f: + for line_num, line in enumerate(f, 1): + if search_term in line: + matches.append((file_path, line_num, line.strip())) + + if not matches: + print(f'No matches found for "{search_term}" in {dir_path}') + return + + num_matches = len(matches) + num_files = len(set(match[0] for match in matches)) + + if num_files > 100: + print(f'More than {num_files} files matched for "{search_term}" in {dir_path}. Please narrow your search.') + return + + print(f'[Found {num_matches} matches for "{search_term}" in {dir_path}]') + for file_path, line_num, line in matches: + print(f"{file_path} (Line {line_num}): {line}") + print(f'[End of matches for "{search_term}" in {dir_path}]') + + def search_file(self, search_term: str, file_path: Optional[str] = None) -> None: + """Searches for search_term in file. If file is not provided, searches in the current open file. + + Args: + search_term: str: The term to search for. + file_path: str | None: The path to the file to search. + """ + if file_path is None: + file_path = self.current_file + if file_path is None: + raise FileNotFoundError("No file specified or open. Use the open_file function first.") + if not os.path.isfile(file_path): + raise FileNotFoundError(f"File {file_path} not found") + + matches = [] + with open(file_path) as file: + for i, line in enumerate(file, 1): + if search_term in line: + matches.append((i, line.strip())) + + if matches: + print(f'[Found {len(matches)} matches for "{search_term}" in {file_path}]') + for match in matches: + print(f"Line {match[0]}: {match[1]}") + print(f'[End of matches for "{search_term}" in {file_path}]') + else: + print(f'[No matches found for "{search_term}" in {file_path}]') + + @staticmethod + def find_file(file_name: str, dir_path: str = "./") -> None: + """Finds all files with the given name in the specified directory. + + Args: + file_name: str: The name of the file to find. + dir_path: str: The path to the directory to search. + """ + if not os.path.isdir(dir_path): + raise FileNotFoundError(f"Directory {dir_path} not found") + + matches = [] + for root, _, files in os.walk(dir_path): + for file in files: + if file_name in file: + matches.append(os.path.join(root, file)) + + if matches: + print(f'[Found {len(matches)} matches for "{file_name}" in {dir_path}]') + for match in matches: + print(f"{match}") + print(f'[End of matches for "{file_name}" in {dir_path}]') + else: + print(f'[No matches found for "{file_name}" in {dir_path}]') diff --git a/metagpt/tools/libs/linter.py b/metagpt/tools/libs/linter.py new file mode 100644 index 000000000..509cb04c3 --- /dev/null +++ b/metagpt/tools/libs/linter.py @@ -0,0 +1,222 @@ +import os +import subprocess +import sys +import traceback +import warnings +from dataclasses import dataclass +from pathlib import Path +from typing import Optional + +from grep_ast import TreeContext, filename_to_lang +from tree_sitter_languages import get_parser # noqa: E402 + +# tree_sitter is throwing a FutureWarning +warnings.simplefilter("ignore", category=FutureWarning) + + +@dataclass +class LintResult: + text: str + lines: list + + +class Linter: + def __init__(self, encoding="utf-8", root=None): + self.encoding = encoding + self.root = root + + self.languages = dict( + python=self.py_lint, + ) + self.all_lint_cmd = None + + # def set_linter(self, lang, cmd): + # if lang: + # self.languages[lang] = cmd + # return + # + # self.all_lint_cmd = cmd + + def get_rel_fname(self, fname): + if self.root: + return os.path.relpath(fname, self.root) + else: + return fname + + def run_cmd(self, cmd, rel_fname, code): + cmd += " " + rel_fname + cmd = cmd.split() + + process = subprocess.Popen(cmd, cwd=self.root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + stdout, _ = process.communicate() + errors = stdout.decode().strip() + self.returncode = process.returncode + if self.returncode == 0: + return # zero exit status + + cmd = " ".join(cmd) + res = "" + res += errors + line_num = extract_error_line_from(res) + return LintResult(text=res, lines=[line_num]) + + def get_abs_fname(self, fname): + if os.path.isabs(fname): + return fname + elif os.path.isfile(fname): + rel_fname = self.get_rel_fname(fname) + return os.path.abspath(rel_fname) + else: # if a temp file + return self.get_rel_fname(fname) + + def lint(self, fname, cmd=None) -> Optional[LintResult]: + code = Path(fname).read_text(self.encoding) + absolute_fname = self.get_abs_fname(fname) + if cmd: + cmd = cmd.strip() + if not cmd: + lang = filename_to_lang(fname) + if not lang: + return None + if self.all_lint_cmd: + cmd = self.all_lint_cmd + else: + cmd = self.languages.get(lang) + if callable(cmd): + linkres = cmd(fname, absolute_fname, code) + elif cmd: + linkres = self.run_cmd(cmd, absolute_fname, code) + else: + linkres = basic_lint(absolute_fname, code) + return linkres + + def flake_lint(self, rel_fname, code): + fatal = "F821,F822,F831,E112,E113,E999,E902" + flake8 = f"flake8 --select={fatal} --isolated" + + try: + flake_res = self.run_cmd(flake8, rel_fname, code) + except FileNotFoundError: + flake_res = None + return flake_res + + def py_lint(self, fname, rel_fname, code): + error = self.flake_lint(rel_fname, code) + if not error: + error = lint_python_compile(fname, code) + if not error: + error = basic_lint(rel_fname, code) + return error + + +def lint_python_compile(fname, code): + try: + compile(code, fname, "exec") # USE TRACEBACK BELOW HERE + return + except IndentationError as err: + end_lineno = getattr(err, "end_lineno", err.lineno) + if isinstance(end_lineno, int): + line_numbers = list(range(end_lineno - 1, end_lineno)) + else: + line_numbers = [] + + tb_lines = traceback.format_exception(type(err), err, err.__traceback__) + last_file_i = 0 + + target = "# USE TRACEBACK" + target += " BELOW HERE" + for i in range(len(tb_lines)): + if target in tb_lines[i]: + last_file_i = i + break + tb_lines = tb_lines[:1] + tb_lines[last_file_i + 1 :] + + res = "".join(tb_lines) + return LintResult(text=res, lines=line_numbers) + + +def basic_lint(fname, code): + """ + Use tree-sitter to look for syntax errors, display them with tree context. + """ + + lang = filename_to_lang(fname) + if not lang: + return + + parser = get_parser(lang) + tree = parser.parse(bytes(code, "utf-8")) + + errors = traverse_tree(tree.root_node) + if not errors: + return + return LintResult(text=f"{fname}:{errors[0]}", lines=errors) + + +def extract_error_line_from(lint_error): + # moved from openhands.agentskills#_lint_file + for line in lint_error.splitlines(True): + if line.strip(): + # The format of the error message is: ::: + parts = line.split(":") + if len(parts) >= 2: + try: + first_error_line = int(parts[1]) + break + except ValueError: + continue + return first_error_line + + +def tree_context(fname, code, line_nums): + context = TreeContext( + fname, + code, + color=False, + line_number=True, + child_context=False, + last_line=False, + margin=0, + mark_lois=True, + loi_pad=3, + # header_max=30, + show_top_of_file_parent_scope=False, + ) + line_nums = set(line_nums) + context.add_lines_of_interest(line_nums) + context.add_context() + output = context.format() + + return output + + +# Traverse the tree to find errors +def traverse_tree(node): + errors = [] + if node.type == "ERROR" or node.is_missing: + line_no = node.start_point[0] + 1 + errors.append(line_no) + + for child in node.children: + errors += traverse_tree(child) + + return errors + + +def main(): + """ + Main function to parse files provided as command line arguments. + """ + if len(sys.argv) < 2: + print("Usage: python linter.py ...") + sys.exit(1) + + linter = Linter(root=os.getcwd()) + for file_path in sys.argv[1:]: + errors = linter.lint(file_path) + if errors: + print(errors) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt index 23806eb63..e669da46d 100644 --- a/requirements.txt +++ b/requirements.txt @@ -74,3 +74,5 @@ pylint~=3.0.3 pygithub~=2.3 htmlmin fsspec +grep-ast~=0.3.3 +tree-sitter~=0.21.3 \ No newline at end of file diff --git a/tests/metagpt/tools/libs/test_editor.py b/tests/metagpt/tools/libs/test_editor.py index 64149fdb7..6f0861c75 100644 --- a/tests/metagpt/tools/libs/test_editor.py +++ b/tests/metagpt/tools/libs/test_editor.py @@ -1,7 +1,10 @@ +import contextlib +import io + import pytest from metagpt.const import TEST_DATA_PATH -from metagpt.tools.libs.editor import Editor, FileBlock +from metagpt.tools.libs.editor import WINDOW, Editor TEST_FILE_CONTENT = """ # this is line one @@ -25,21 +28,6 @@ def test_file(): f.write("") -EXPECTED_SEARCHED_BLOCK = FileBlock( - file_path=str(TEST_FILE_PATH), - block_content='001|# this is line one\n002|def test_function_for_fm():\n003| "some docstring"\n004| a = 1\n005| b = 2\n', - block_start_line=1, - block_end_line=5, - symbol="def test_function_for_fm", - symbol_line=2, -) - - -def test_search_content(test_file): - block = Editor().search_content("def test_function_for_fm", root_path=TEST_DATA_PATH, window=3) - assert block == EXPECTED_SEARCHED_BLOCK - - EXPECTED_CONTENT_AFTER_REPLACE = """ # this is line one def test_function_for_fm(): @@ -103,28 +91,6 @@ def test_insert_content(test_file): assert new_content == EXPECTED_CONTENT_AFTER_INSERT -@pytest.mark.skip -def test_new_content_wrong_indentation(test_file): - msg = Editor().write_content( - file_path=str(TEST_FILE_PATH), - start_line=3, - end_line=-1, - new_block_content=" This is the new line to be inserted, at line 3", # omit # should throw a syntax error - ) - assert "failed" in msg - - -@pytest.mark.skip -def test_new_content_format_issue(test_file): - msg = Editor().write_content( - file_path=str(TEST_FILE_PATH), - start_line=3, - end_line=-1, - new_block_content=" # This is the new line to be inserted, at line 3 ", # trailing spaces are format issue only, and should not throw an error - ) - assert "failed" not in msg - - @pytest.mark.parametrize( "filename", [ @@ -151,5 +117,453 @@ async def test_read_files(filename): assert file_block.block_content +@pytest.fixture(autouse=True) +def reset_current_file(): + global CURRENT_FILE + CURRENT_FILE = None + + +def _numbered_test_lines(start, end) -> str: + return ("\n".join(f"{i}|" for i in range(start, end + 1))) + "\n" + + +def _generate_test_file_with_lines(temp_path, num_lines) -> str: + file_path = temp_path / "test_file.py" + file_path.write_text("\n" * num_lines) + return file_path + + +def _generate_ruby_test_file_with_lines(temp_path, num_lines) -> str: + file_path = temp_path / "test_file.rb" + file_path.write_text("\n" * num_lines) + return file_path + + +def _calculate_window_bounds(current_line, total_lines, window_size): + half_window = window_size // 2 + if current_line - half_window < 0: + start = 1 + end = window_size + else: + start = current_line - half_window + end = current_line + half_window + return start, end + + +@pytest.mark.asyncio +async def test_open_file_unexist_path(): + editor = Editor() + with pytest.raises(FileNotFoundError): + editor.open_file("/unexist/path/a.txt") + + +@pytest.mark.asyncio +async def test_open_file(tmp_path): + editor = Editor() + assert tmp_path is not None + temp_file_path = tmp_path / "a.txt" + temp_file_path.write_text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5") + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(temp_file_path)) + result = buf.getvalue() + assert result is not None + expected = ( + f"[File: {temp_file_path} (5 lines total)]\n" + "(this is the beginning of the file)\n" + "1|Line 1\n" + "2|Line 2\n" + "3|Line 3\n" + "4|Line 4\n" + "5|Line 5\n" + "(this is the end of the file)\n" + ) + assert result.split("\n") == expected.split("\n") + + +@pytest.mark.asyncio +async def test_open_file_with_indentation(tmp_path): + editor = Editor() + temp_file_path = tmp_path / "a.txt" + temp_file_path.write_text("Line 1\n Line 2\nLine 3\nLine 4\nLine 5") + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(temp_file_path)) + result = buf.getvalue() + assert result is not None + expected = ( + f"[File: {temp_file_path} (5 lines total)]\n" + "(this is the beginning of the file)\n" + "1|Line 1\n" + "2| Line 2\n" + "3|Line 3\n" + "4|Line 4\n" + "5|Line 5\n" + "(this is the end of the file)\n" + ) + assert result.split("\n") == expected.split("\n") + + +@pytest.mark.asyncio +async def test_open_file_long(tmp_path): + editor = Editor() + temp_file_path = tmp_path / "a.txt" + content = "\n".join([f"Line {i}" for i in range(1, 1001)]) + temp_file_path.write_text(content) + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(temp_file_path), 1, 50) + result = buf.getvalue() + assert result is not None + expected = f"[File: {temp_file_path} (1000 lines total)]\n" + expected += "(this is the beginning of the file)\n" + for i in range(1, 51): + expected += f"{i}|Line {i}\n" + expected += "(950 more lines below)\n" + assert result.split("\n") == expected.split("\n") + + +@pytest.mark.asyncio +async def test_open_file_long_with_lineno(tmp_path): + editor = Editor() + temp_file_path = tmp_path / "a.txt" + content = "\n".join([f"Line {i}" for i in range(1, 1001)]) + temp_file_path.write_text(content) + + cur_line = 100 + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(temp_file_path), cur_line) + result = buf.getvalue() + assert result is not None + expected = f"[File: {temp_file_path} (1000 lines total)]\n" + start, end = _calculate_window_bounds(cur_line, 1000, WINDOW) + if start == 1: + expected += "(this is the beginning of the file)\n" + else: + expected += f"({start - 1} more lines above)\n" + for i in range(start, end + 1): + expected += f"{i}|Line {i}\n" + if end == 1000: + expected += "(this is the end of the file)\n" + else: + expected += f"({1000 - end} more lines below)\n" + assert result.split("\n") == expected.split("\n") + + +@pytest.mark.asyncio +async def test_create_file_unexist_path(): + editor = Editor() + with pytest.raises(FileNotFoundError): + editor.create_file("/unexist/path/a.txt") + + +@pytest.mark.asyncio +async def test_create_file(tmp_path): + editor = Editor() + temp_file_path = tmp_path / "a.txt" + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.create_file(str(temp_file_path)) + result = buf.getvalue() + + expected = ( + f"[File: {temp_file_path} (1 lines total)]\n" + "(this is the beginning of the file)\n" + "1|\n" + "(this is the end of the file)\n" + f"[File {temp_file_path} created.]\n" + ) + assert result.split("\n") == expected.split("\n") + + +@pytest.mark.asyncio +async def test_goto_line(tmp_path): + editor = Editor() + temp_file_path = tmp_path / "a.txt" + total_lines = 1000 + content = "\n".join([f"Line {i}" for i in range(1, total_lines + 1)]) + temp_file_path.write_text(content) + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(temp_file_path)) + result = buf.getvalue() + assert result is not None + + expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" + expected += "(this is the beginning of the file)\n" + for i in range(1, WINDOW + 1): + expected += f"{i}|Line {i}\n" + expected += f"({total_lines - WINDOW} more lines below)\n" + assert result.split("\n") == expected.split("\n") + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.goto_line(500) + result = buf.getvalue() + assert result is not None + + cur_line = 500 + expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" + start, end = _calculate_window_bounds(cur_line, total_lines, WINDOW) + if start == 1: + expected += "(this is the beginning of the file)\n" + else: + expected += f"({start - 1} more lines above)\n" + for i in range(start, end + 1): + expected += f"{i}|Line {i}\n" + if end == total_lines: + expected += "(this is the end of the file)\n" + else: + expected += f"({total_lines - end} more lines below)\n" + assert result.split("\n") == expected.split("\n") + + +@pytest.mark.asyncio +async def test_goto_line_negative(tmp_path): + editor = Editor() + temp_file_path = tmp_path / "a.txt" + content = "\n".join([f"Line {i}" for i in range(1, 5)]) + temp_file_path.write_text(content) + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(temp_file_path)) + with pytest.raises(ValueError): + editor.goto_line(-1) + + +@pytest.mark.asyncio +async def test_goto_line_out_of_bound(tmp_path): + editor = Editor() + temp_file_path = tmp_path / "a.txt" + content = "\n".join([f"Line {i}" for i in range(1, 5)]) + temp_file_path.write_text(content) + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(temp_file_path)) + with pytest.raises(ValueError): + editor.goto_line(100) + + +@pytest.mark.asyncio +async def test_scroll_down(tmp_path): + editor = Editor() + temp_file_path = tmp_path / "a.txt" + total_lines = 1000 + content = "\n".join([f"Line {i}" for i in range(1, total_lines + 1)]) + temp_file_path.write_text(content) + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(temp_file_path)) + result = buf.getvalue() + assert result is not None + + expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" + start, end = _calculate_window_bounds(1, total_lines, WINDOW) + if start == 1: + expected += "(this is the beginning of the file)\n" + else: + expected += f"({start - 1} more lines above)\n" + for i in range(start, end + 1): + expected += f"{i}|Line {i}\n" + if end == total_lines: + expected += "(this is the end of the file)\n" + else: + expected += f"({total_lines - end} more lines below)\n" + assert result.split("\n") == expected.split("\n") + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.scroll_down() + result = buf.getvalue() + assert result is not None + + expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" + start, end = _calculate_window_bounds(WINDOW + 1, total_lines, WINDOW) + if start == 1: + expected += "(this is the beginning of the file)\n" + else: + expected += f"({start - 1} more lines above)\n" + for i in range(start, end + 1): + expected += f"{i}|Line {i}\n" + if end == total_lines: + expected += "(this is the end of the file)\n" + else: + expected += f"({total_lines - end} more lines below)\n" + assert result.split("\n") == expected.split("\n") + + +@pytest.mark.asyncio +async def test_scroll_up(tmp_path): + editor = Editor() + temp_file_path = tmp_path / "a.txt" + total_lines = 1000 + content = "\n".join([f"Line {i}" for i in range(1, total_lines + 1)]) + temp_file_path.write_text(content) + + cur_line = 300 + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(temp_file_path), cur_line) + result = buf.getvalue() + assert result is not None + + expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" + start, end = _calculate_window_bounds(cur_line, total_lines, WINDOW) + if start == 1: + expected += "(this is the beginning of the file)\n" + else: + expected += f"({start - 1} more lines above)\n" + for i in range(start, end + 1): + expected += f"{i}|Line {i}\n" + if end == total_lines: + expected += "(this is the end of the file)\n" + else: + expected += f"({total_lines - end} more lines below)\n" + assert result.split("\n") == expected.split("\n") + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.scroll_up() + result = buf.getvalue() + assert result is not None + + cur_line = cur_line - WINDOW + + expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" + start, end = _calculate_window_bounds(cur_line, total_lines, WINDOW) + if start == 1: + expected += "(this is the beginning of the file)\n" + else: + expected += f"({start - 1} more lines above)\n" + for i in range(start, end + 1): + expected += f"{i}|Line {i}\n" + if end == total_lines: + expected += "(this is the end of the file)\n" + else: + expected += f"({total_lines - end} more lines below)\n" + assert result.split("\n") == expected.split("\n") + + +@pytest.mark.asyncio +async def test_scroll_down_edge(tmp_path): + editor = Editor() + temp_file_path = tmp_path / "a.txt" + content = "\n".join([f"Line {i}" for i in range(1, 10)]) + temp_file_path.write_text(content) + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(temp_file_path)) + result = buf.getvalue() + assert result is not None + + expected = f"[File: {temp_file_path} (9 lines total)]\n" + expected += "(this is the beginning of the file)\n" + for i in range(1, 10): + expected += f"{i}|Line {i}\n" + expected += "(this is the end of the file)\n" + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.scroll_down() + result = buf.getvalue() + assert result is not None + + assert result.split("\n") == expected.split("\n") + + +@pytest.mark.asyncio +async def test_print_window_internal(tmp_path): + editor = Editor() + test_file_path = tmp_path / "a.txt" + await editor.create_file(str(test_file_path)) + editor.open_file(str(test_file_path)) + with open(test_file_path, "w") as file: + for i in range(1, 101): + file.write(f"Line `{i}`\n") + + current_line = 50 + window = 2 + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor._print_window(str(test_file_path), current_line, window, return_str=False) + result = buf.getvalue() + expected = "(48 more lines above)\n" "49|Line `49`\n" "50|Line `50`\n" "51|Line `51`\n" "(49 more lines below)\n" + assert result == expected + + +@pytest.mark.asyncio +async def test_open_file_large_line_number(tmp_path): + editor = Editor() + test_file_path = tmp_path / "a.txt" + editor.create_file(str(test_file_path)) + editor.open_file(str(test_file_path)) + with open(test_file_path, "w") as file: + for i in range(1, 1000): + file.write(f"Line `{i}`\n") + + current_line = 800 + window = 100 + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(test_file_path), current_line, window) + result = buf.getvalue() + expected = f"[File: {test_file_path} (999 lines total)]\n" + expected += "(749 more lines above)\n" + for i in range(750, 850 + 1): + expected += f"{i}|Line `{i}`\n" + expected += "(149 more lines below)\n" + assert result == expected + + +@pytest.mark.asyncio +async def test_open_file_large_line_number_consecutive_diff_window(tmp_path): + editor = Editor() + test_file_path = tmp_path / "a.txt" + editor.create_file(str(test_file_path)) + editor.open_file(str(test_file_path)) + total_lines = 1000 + with open(test_file_path, "w") as file: + for i in range(1, total_lines + 1): + file.write(f"Line `{i}`\n") + + current_line = 800 + cur_window = 300 + + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.open_file(str(test_file_path), current_line, cur_window) + result = buf.getvalue() + expected = f"[File: {test_file_path} ({total_lines} lines total)]\n" + start, end = _calculate_window_bounds(current_line, total_lines, cur_window) + if start == 1: + expected += "(this is the beginning of the file)\n" + else: + expected += f"({start - 1} more lines above)\n" + for i in range(current_line - cur_window // 2, current_line + cur_window // 2 + 1): + expected += f"{i}|Line `{i}`\n" + if end == total_lines: + expected += "(this is the end of the file)\n" + else: + expected += f"({total_lines - end} more lines below)\n" + assert result == expected + + current_line = current_line - WINDOW + with io.StringIO() as buf: + with contextlib.redirect_stdout(buf): + editor.scroll_up() + + if __name__ == "__main__": pytest.main([__file__, "-s"]) From e191ea31c41653b04159c027417adf9b4bda413c Mon Sep 17 00:00:00 2001 From: seeker-jie Date: Mon, 26 Aug 2024 18:19:13 +0800 Subject: [PATCH 02/25] update: editor --- metagpt/actions/write_code.py | 4 +- metagpt/actions/write_code_review.py | 4 +- metagpt/ext/cr/actions/modify_code.py | 4 +- metagpt/roles/qa_engineer.py | 4 +- metagpt/tools/libs/cr.py | 6 +- metagpt/tools/libs/editor.py | 343 ++++++++----------------- metagpt/tools/libs/file_io_operator.py | 147 +++++++++++ metagpt/tools/libs/linter.py | 12 +- metagpt/utils/report.py | 8 +- tests/metagpt/test_reporter.py | 6 +- 10 files changed, 283 insertions(+), 255 deletions(-) create mode 100644 metagpt/tools/libs/file_io_operator.py diff --git a/metagpt/actions/write_code.py b/metagpt/actions/write_code.py index da25fe621..a2d55ff13 100644 --- a/metagpt/actions/write_code.py +++ b/metagpt/actions/write_code.py @@ -29,7 +29,7 @@ from metagpt.logs import logger from metagpt.schema import CodingContext, Document, RunCodeResult from metagpt.utils.common import CodeParser, get_markdown_code_block_type from metagpt.utils.project_repo import ProjectRepo -from metagpt.utils.report import EditorReporter +from metagpt.utils.report import FileIOOperatorReporter PROMPT_TEMPLATE = """ NOTICE @@ -152,7 +152,7 @@ class WriteCode(Action): summary_log=summary_doc.content if summary_doc else "", ) logger.info(f"Writing {coding_context.filename}..") - async with EditorReporter(enable_llm_stream=True) as reporter: + async with FileIOOperatorReporter(enable_llm_stream=True) as reporter: await reporter.async_report({"type": "code", "filename": coding_context.filename}, "meta") code = await self.write_code(prompt) if not coding_context.code_doc: diff --git a/metagpt/actions/write_code_review.py b/metagpt/actions/write_code_review.py index 6a283f812..a7141747a 100644 --- a/metagpt/actions/write_code_review.py +++ b/metagpt/actions/write_code_review.py @@ -22,7 +22,7 @@ from metagpt.schema import CodingContext, Document from metagpt.tools.tool_registry import register_tool from metagpt.utils.common import CodeParser, aread, awrite from metagpt.utils.project_repo import ProjectRepo -from metagpt.utils.report import EditorReporter +from metagpt.utils.report import FileIOOperatorReporter PROMPT_TEMPLATE = """ # System @@ -144,7 +144,7 @@ class WriteCodeReview(Action): return result, None # if LBTM, rewrite code - async with EditorReporter(enable_llm_stream=True) as reporter: + async with FileIOOperatorReporter(enable_llm_stream=True) as reporter: await reporter.async_report( {"type": "code", "filename": filename, "src_path": doc.root_relative_path}, "meta" ) diff --git a/metagpt/ext/cr/actions/modify_code.py b/metagpt/ext/cr/actions/modify_code.py index 820bdae4a..e4c637347 100644 --- a/metagpt/ext/cr/actions/modify_code.py +++ b/metagpt/ext/cr/actions/modify_code.py @@ -13,7 +13,7 @@ from metagpt.ext.cr.utils.cleaner import ( rm_patch_useless_part, ) from metagpt.utils.common import CodeParser -from metagpt.utils.report import EditorReporter +from metagpt.utils.report import FileIOOperatorReporter SYSTEM_MSGS_PROMPT = """ You're an adaptive software developer who excels at refining code based on user inputs. You're proficient in creating Git patches to represent code modifications. @@ -100,7 +100,7 @@ class ModifyCode(Action): ) patch_file = output_dir / f"{patch_target_file_name}.patch" patch_file.parent.mkdir(exist_ok=True, parents=True) - async with EditorReporter(enable_llm_stream=True) as reporter: + async with FileIOOperatorReporter(enable_llm_stream=True) as reporter: await reporter.async_report( {"type": "Patch", "src_path": str(patch_file), "filename": patch_file.name}, "meta" ) diff --git a/metagpt/roles/qa_engineer.py b/metagpt/roles/qa_engineer.py index fc8fa5353..de2b27372 100644 --- a/metagpt/roles/qa_engineer.py +++ b/metagpt/roles/qa_engineer.py @@ -33,7 +33,7 @@ from metagpt.utils.common import ( parse_recipient, ) from metagpt.utils.project_repo import ProjectRepo -from metagpt.utils.report import EditorReporter +from metagpt.utils.report import FileIOOperatorReporter class QaEngineer(Role): @@ -80,7 +80,7 @@ class QaEngineer(Role): context = TestingContext(filename=test_doc.filename, test_doc=test_doc, code_doc=code_doc) context = await WriteTest(i_context=context, context=self.context, llm=self.llm).run() - async with EditorReporter(enable_llm_stream=True) as reporter: + async with FileIOOperatorReporter(enable_llm_stream=True) as reporter: await reporter.async_report({"type": "test", "filename": test_doc.filename}, "meta") doc = await self.repo.tests.save_doc( diff --git a/metagpt/tools/libs/cr.py b/metagpt/tools/libs/cr.py index 5fca23a66..1f42bfe3a 100644 --- a/metagpt/tools/libs/cr.py +++ b/metagpt/tools/libs/cr.py @@ -12,7 +12,7 @@ from metagpt.ext.cr.actions.modify_code import ModifyCode from metagpt.ext.cr.utils.schema import Point from metagpt.tools.libs.browser import Browser from metagpt.tools.tool_registry import register_tool -from metagpt.utils.report import EditorReporter +from metagpt.utils.report import FileIOOperatorReporter @register_tool(tags=["codereview"], include_functions=["review", "fix"]) @@ -38,7 +38,7 @@ class CodeReview: cr_point_content = await f.read() cr_points = [Point(**i) for i in json.loads(cr_point_content)] - async with EditorReporter(enable_llm_stream=True) as reporter: + async with FileIOOperatorReporter(enable_llm_stream=True) as reporter: src_path = cr_output_file cr_output_path = Path(cr_output_file) await reporter.async_report( @@ -87,7 +87,7 @@ class CodeReview: else: async with aiofiles.open(patch_path, encoding="utf-8") as f: patch_file_content = await f.read() - await EditorReporter().async_report(patch_path) + await FileIOOperatorReporter().async_report(patch_path) patch: PatchSet = PatchSet(patch_file_content) return patch diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index dde5df613..806098522 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -1,33 +1,22 @@ -import base64 import os import re import shutil import tempfile from pathlib import Path -from typing import List, Optional, Union +from typing import Optional -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel -from metagpt.config2 import Config +from metagpt.const import DEFAULT_WORKSPACE_ROOT from metagpt.logs import logger from metagpt.tools.libs.linter import Linter from metagpt.tools.tool_registry import register_tool -from metagpt.utils import read_docx -from metagpt.utils.common import aread, aread_bin, awrite_bin, check_http_endpoint -from metagpt.utils.report import EditorReporter # This is also used in unit tests! MSG_FILE_UPDATED = "[File updated (edited at line {line_number}). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]" LINTER_ERROR_MSG = "[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n" -class FileBlock(BaseModel): - """A block of content in a file""" - - file_path: str - block_content: str - - class LineNumberError(Exception): pass @@ -35,134 +24,17 @@ class LineNumberError(Exception): @register_tool() class Editor(BaseModel): """ - A state-of-state tool for reading, understanding, writing, and editing files. - All path parameters should use an absolute path. + A state-of-state tool for open, reading, and editing files. """ - model_config = ConfigDict(arbitrary_types_allowed=True) - - resource: EditorReporter = EditorReporter() - # CURRENT_FILE: Optional[str] = None - current_file: Optional[str] = None + current_file: Optional[Path] = None current_line: int = 1 - # WINDOW: int = 100 window: int = 100 - - # def write(self, path: str, content: str): - # """Write the whole content to a file. When used, make sure content arg contains the full content of the file.""" - # if "\n" not in content and "\\n" in content: - # # A very raw rule to correct the content: If 'content' lacks actual newlines ('\n') but includes '\\n', consider - # # replacing them with '\n' to potentially correct mistaken representations of newline characters. - # content = content.replace("\\n", "\n") - # directory = os.path.dirname(path) - # if directory and not os.path.exists(directory): - # os.makedirs(directory) - # with open(path, "w", encoding="utf-8") as f: - # f.write(content) - # # self.resource.report(path, "path") - # return f"The writing/coding the of the file {os.path.basename(path)}' is now completed. The file '{os.path.basename(path)}' has been successfully created." - - # async def read(self, path: str) -> FileBlock: - # """Read the whole content of a file. Using absolute paths as the argument for specifying the file location.""" - # is_text, mime_type = await is_text_file(path) - # if is_text: - # lines = await self._read_text(path) - # elif mime_type == "application/pdf": - # lines = await self._read_pdf(path) - # elif mime_type in { - # "application/msword", - # "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - # "application/vnd.ms-word.document.macroEnabled.12", - # "application/vnd.openxmlformats-officedocument.wordprocessingml.template", - # "application/vnd.ms-word.template.macroEnabled.12", - # }: - # lines = await self._read_docx(path) - # else: - # return FileBlock(file_path=str(path), block_content="") - # self.resource.report(str(path), "path") - # - # lines_with_num = [f"{i + 1:03}|{line}" for i, line in enumerate(lines)] - # result = FileBlock( - # file_path=str(path), - # block_content="".join(lines_with_num), - # ) - # return result + working_dir: Path = DEFAULT_WORKSPACE_ROOT @staticmethod - async def _read_text(path: Union[str, Path]) -> List[str]: - content = await aread(path) - lines = content.split("\n") - return lines - - @staticmethod - async def _read_pdf(path: Union[str, Path]) -> List[str]: - result = await Editor._omniparse_read_file(path) - if result: - return result - - from llama_index.readers.file import PDFReader - - reader = PDFReader() - lines = reader.load_data(file=Path(path)) - return [i.text for i in lines] - - @staticmethod - async def _read_docx(path: Union[str, Path]) -> List[str]: - result = await Editor._omniparse_read_file(path) - if result: - return result - return read_docx(str(path)) - - @staticmethod - async def _omniparse_read_file(path: Union[str, Path]) -> Optional[List[str]]: - from metagpt.tools.libs import get_env_default - from metagpt.utils.omniparse_client import OmniParseClient - - base_url = await get_env_default(key="base_url", app_name="OmniParse", default_value="") - if not base_url: - base_url = await Editor._read_omniparse_config() - if not base_url: - return None - api_key = await get_env_default(key="api_key", app_name="OmniParse", default_value="") - v = await get_env_default(key="timeout", app_name="OmniParse", default_value="120") - try: - timeout = int(v) or 120 - except ValueError: - timeout = 120 - - try: - if not await check_http_endpoint(url=base_url): - logger.warning(f"{base_url}: NOT AVAILABLE") - return None - client = OmniParseClient(api_key=api_key, base_url=base_url, max_timeout=timeout) - file_data = await aread_bin(filename=path) - ret = await client.parse_document(file_input=file_data, bytes_filename=str(path)) - except (ValueError, Exception) as e: - logger.exception(f"{path}: {e}") - return None - if not ret.images: - return [ret.text] if ret.text else None - - result = [ret.text] - img_dir = Path(path).parent / (Path(path).name.replace(".", "_") + "_images") - img_dir.mkdir(parents=True, exist_ok=True) - for i in ret.images: - byte_data = base64.b64decode(i.image) - filename = img_dir / i.image_name - await awrite_bin(filename=filename, data=byte_data) - result.append(f"![{i.image_name}]({str(filename)})") - return result - - @staticmethod - async def _read_omniparse_config() -> str: - config = Config.default() - if config.omniparse and config.omniparse.url: - return config.omniparse.url - return "" - - @staticmethod - def _is_valid_filename(file_name) -> bool: - if not file_name or not isinstance(file_name, str) or not file_name.strip(): + def _is_valid_filename(file_name: str) -> bool: + if not file_name or not file_name.strip(): return False invalid_chars = '<>:"/\\|?*' if os.name == "nt": # Windows @@ -176,28 +48,25 @@ class Editor(BaseModel): return True @staticmethod - def _is_valid_path(path) -> bool: - if not path or not isinstance(path, str): - return False + def _is_valid_path(path: Path) -> bool: try: - return os.path.exists(os.path.normpath(path)) + return path.exists() except PermissionError: return False @staticmethod - def _create_paths(file_name) -> bool: + def _create_paths(file_path: Path) -> bool: try: - dirname = os.path.dirname(file_name) - if dirname: - os.makedirs(dirname, exist_ok=True) + if file_path.parent: + file_path.parent.mkdir(parents=True, exist_ok=True) return True except PermissionError: return False - def _check_current_file(self, file_path: Optional[str] = None) -> bool: - if not file_path: + def _check_current_file(self, file_path: Optional[Path] = None) -> bool: + if file_path is None: file_path = self.current_file - if not file_path or not os.path.isfile(file_path): + if not file_path or not file_path.is_file(): raise ValueError("No file open. Use the open_file function first.") return True @@ -206,7 +75,7 @@ class Editor(BaseModel): return max(min_value, min(value, max_value)) @staticmethod - def _lint_file(file_path: str) -> tuple[Optional[str], Optional[int]]: + def _lint_file(file_path: Path) -> tuple[Optional[str], Optional[int]]: """Lint the file at the given path and return a tuple with a boolean indicating if there are errors, and the line number of the first error, if any. @@ -214,15 +83,15 @@ class Editor(BaseModel): tuple[str | None, int | None]: (lint_error, first_error_line_number) """ linter = Linter(root=os.getcwd()) - lint_error = linter.lint(file_path) + lint_error = linter.lint(str(file_path)) if not lint_error: # Linting successful. No issues found. return None, None return "ERRORS:\n" + lint_error.text, lint_error.lines[0] - def _print_window(self, file_path, targeted_line, window, return_str=False): + def _print_window(self, file_path: Path, targeted_line: int, window: int, return_str: bool = False): self._check_current_file(file_path) - with open(file_path) as file: + with file_path.open() as file: content = file.read() # Ensure the content ends with a newline character @@ -267,13 +136,23 @@ class Editor(BaseModel): if return_str: return output else: - print(output) + logger.info(output) @staticmethod - def _cur_file_header(current_file, total_lines) -> str: + def _cur_file_header(current_file: Path, total_lines: int) -> str: if not current_file: return "" - return f"[File: {os.path.abspath(current_file)} ({total_lines} lines total)]\n" + return f"[File: {current_file.resolve()} ({total_lines} lines total)]\n" + + def set_workdir(self, path: str) -> None: + """ + Sets the working directory to the given path. eg: repo directory. + You need to set it up before operating the file. + + Args: + path: str: The path to set as the working directory. + """ + self.working_dir = Path(path) def open_file(self, path: str, line_number: Optional[int] = 1, context_lines: Optional[int] = None) -> None: """Opens the file at the given path in the editor. If line_number is provided, the window will be moved to include that line. @@ -288,11 +167,13 @@ class Editor(BaseModel): if context_lines is None: context_lines = self.window - if not os.path.isfile(path): + path = self.working_dir / Path(path) + + if not path.is_file(): raise FileNotFoundError(f"File {path} not found") - CURRENT_FILE = os.path.abspath(path) - with open(CURRENT_FILE) as file: + self.current_file = path + with path.open() as file: total_lines = max(1, sum(1 for _ in file)) if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines: @@ -303,11 +184,9 @@ class Editor(BaseModel): if context_lines is None or context_lines < 1: context_lines = self.window - output = self._cur_file_header(CURRENT_FILE, total_lines) - output += self._print_window( - CURRENT_FILE, self.current_line, self._clamp(context_lines, 1, 2000), return_str=True - ) - print(output) + output = self._cur_file_header(path, total_lines) + output += self._print_window(path, self.current_line, self._clamp(context_lines, 1, 2000), return_str=True) + logger.info(output) def goto_line(self, line_number: int) -> None: """Moves the window to show the specified line number. @@ -317,7 +196,7 @@ class Editor(BaseModel): """ self._check_current_file() - with open(str(self.current_file)) as file: + with self.current_file.open() as file: total_lines = max(1, sum(1 for _ in file)) if not isinstance(line_number, int) or line_number < 1 or line_number > total_lines: raise ValueError(f"Line number must be between 1 and {total_lines}") @@ -326,45 +205,46 @@ class Editor(BaseModel): output = self._cur_file_header(self.current_file, total_lines) output += self._print_window(self.current_file, self.current_line, self.window, return_str=True) - print(output) + logger.info(output) def scroll_down(self) -> None: """Moves the window down by 100 lines.""" self._check_current_file() - with open(str(self.current_file)) as file: + with self.current_file.open() as file: total_lines = max(1, sum(1 for _ in file)) self.current_line = self._clamp(self.current_line + self.window, 1, total_lines) output = self._cur_file_header(self.current_file, total_lines) output += self._print_window(self.current_file, self.current_line, self.window, return_str=True) - print(output) + logger.info(output) def scroll_up(self) -> None: """Moves the window up by 100 lines.""" self._check_current_file() - with open(str(self.current_file)) as file: + with self.current_file.open() as file: total_lines = max(1, sum(1 for _ in file)) self.current_line = self._clamp(self.current_line - self.window, 1, total_lines) output = self._cur_file_header(self.current_file, total_lines) output += self._print_window(self.current_file, self.current_line, self.window, return_str=True) - print(output) + logger.info(output) - @classmethod - def create_file(cls, filename: str) -> None: + def create_file(self, filename: str) -> None: """Creates and opens a new file with the given name. Args: filename: str: The name of the file to create. """ - if os.path.exists(filename): + filename = self.working_dir / Path(filename) + + if filename.exists(): raise FileExistsError(f"File '{filename}' already exists.") - with open(filename, "w") as file: + with filename.open("w") as file: file.write("\n") - cls.open_file(filename) - print(f"[File {filename} created.]") + self.open_file(filename) + logger.info(f"[File {filename} created.]") @staticmethod def _append_impl(lines, content): @@ -475,7 +355,7 @@ class Editor(BaseModel): def _edit_file_impl( self, - file_name: str, + file_name: Path, start: Optional[int] = None, end: Optional[int] = None, content: str = "", @@ -485,7 +365,7 @@ class Editor(BaseModel): """Internal method to handle common logic for edit_/append_file methods. Args: - file_name: str: The name of the file to edit or append to. + file_name: Path: The name of the file to edit or append to. start: int | None = None: The start line number for editing. Ignored if is_append is True. end: int | None = None: The end line number for editing. Ignored if is_append is True. content: str: The content to replace the lines with or to append. @@ -501,7 +381,7 @@ class Editor(BaseModel): "DO NOT re-run the same failed edit command. Running it again will lead to the same error." ) - if not self._is_valid_filename(file_name): + if not self._is_valid_filename(file_name.name): raise FileNotFoundError("Invalid file name.") if not self._is_valid_path(file_name): @@ -510,7 +390,7 @@ class Editor(BaseModel): if not self._create_paths(file_name): raise PermissionError("Could not access or create directories.") - if not os.path.isfile(file_name): + if not file_name.is_file(): raise FileNotFoundError(f"File {file_name} not found.") if is_insert and is_append: @@ -519,7 +399,7 @@ class Editor(BaseModel): # Use a temporary file to write changes content = str(content or "") temp_file_path = "" - src_abs_path = os.path.abspath(file_name) + src_abs_path = file_name.resolve() first_error_line = None try: @@ -535,7 +415,7 @@ class Editor(BaseModel): temp_file_path = temp_file.name # Read the original file and check if empty and for a trailing newline - with open(file_name) as original_file: + with file_name.open() as original_file: lines = original_file.readlines() if is_append: @@ -567,11 +447,8 @@ class Editor(BaseModel): # because the env var will be set AFTER the agentskills is imported if enable_auto_lint: # BACKUP the original file - original_file_backup_path = os.path.join( - os.path.dirname(file_name), - f".backup.{os.path.basename(file_name)}", - ) - with open(original_file_backup_path, "w") as f: + original_file_backup_path = file_name.parent / f".backup.{file_name.name}" + with original_file_backup_path.open("w") as f: f.writelines(lines) lint_error, first_error_line = self._lint_file(file_name) @@ -642,9 +519,9 @@ class Editor(BaseModel): ) # recover the original file - with open(original_file_backup_path) as fin, open(file_name, "w") as fout: + with original_file_backup_path.open() as fin, file_name.open("w") as fout: fout.write(fin.read()) - os.remove(original_file_backup_path) + original_file_backup_path.unlink() return ret_str except FileNotFoundError as e: @@ -655,13 +532,13 @@ class Editor(BaseModel): ret_str += f"Invalid input: {e}\n" except Exception as e: # Clean up the temporary file if an error occurs - if temp_file_path and os.path.exists(temp_file_path): - os.remove(temp_file_path) - print(f"An unexpected error occurred: {e}") + if temp_file_path and Path(temp_file_path).exists(): + Path(temp_file_path).unlink() + logger.warning(f"An unexpected error occurred: {e}") raise e # Update the file information and print the updated content - with open(file_name, "r", encoding="utf-8") as file: + with file_name.open("r", encoding="utf-8") as file: n_total_lines = max(1, len(file.readlines())) if first_error_line is not None and int(first_error_line) > 0: self.current_line = first_error_line @@ -670,14 +547,13 @@ class Editor(BaseModel): self.current_line = max(1, len(lines)) # end of original file else: self.current_line = start or n_total_lines or 1 - ret_str += f"[File: {os.path.abspath(file_name)} ({n_total_lines} lines total after edit)]\n" + ret_str += f"[File: {file_name.resolve()} ({n_total_lines} lines total after edit)]\n" CURRENT_FILE = file_name ret_str += self._print_window(CURRENT_FILE, self.current_line, self.window, return_str=True) + "\n" ret_str += MSG_FILE_UPDATED.format(line_number=self.current_line) return ret_str - @classmethod - def edit_file_by_replace(cls, file_name: str, to_replace: str, new_content: str) -> None: + def edit_file_by_replace(self, file_name: str, to_replace: str, new_content: str) -> None: """Edit a file. This will search for `to_replace` in the given file and replace it with `new_content`. Every *to_replace* must *EXACTLY MATCH* the existing source code, character for character, including all comments, docstrings, etc. @@ -733,7 +609,8 @@ class Editor(BaseModel): # search for `to_replace` in the file # if found, replace it with `new_content` # if not found, perform a fuzzy search to find the closest match and replace it with `new_content` - with open(file_name, "r") as file: + file_name = self.working_dir / Path(file_name) + with file_name.open("r") as file: file_content = file.read() if file_content.count(to_replace) > 1: @@ -758,13 +635,13 @@ class Editor(BaseModel): # find the closest match start = file_content_fuzzy.find(to_replace_fuzzy) if start == -1: - print(f"[No exact match found in {file_name} for\n```\n{to_replace}\n```\n]") + logger.info(f"[No exact match found in {file_name} for\n```\n{to_replace}\n```\n]") return # Convert start from index to line number for fuzzy match start_line_number = file_content_fuzzy[:start].count("\n") + 1 end_line_number = start_line_number + len(to_replace.splitlines()) - 1 - ret_str = cls._edit_file_impl( + ret_str = self._edit_file_impl( file_name, start=start_line_number, end=end_line_number, @@ -773,10 +650,9 @@ class Editor(BaseModel): ) # lint_error = bool(LINTER_ERROR_MSG in ret_str) # TODO: automatically tries to fix linter error (maybe involve some static analysis tools on the location near the edit to figure out indentation) - print(ret_str) + logger.info(ret_str) - @classmethod - def insert_content_at_line(cls, file_name: str, line_number: int, content: str) -> None: + def insert_content_at_line(self, file_name: str, line_number: int, content: str) -> None: """Insert content at the given line number in a file. This will NOT modify the content of the lines before OR after the given line number. @@ -799,7 +675,8 @@ class Editor(BaseModel): line_number: int: The line number (starting from 1) to insert the content after. content: str: The content to insert. """ - ret_str = cls._edit_file_impl( + file_name = self.working_dir / Path(file_name) + ret_str = self._edit_file_impl( file_name, start=line_number, end=line_number, @@ -807,19 +684,18 @@ class Editor(BaseModel): is_insert=True, is_append=False, ) - print(ret_str) + logger.info(ret_str) - @classmethod - def append_file(cls, file_name: str, content: str) -> None: + def append_file(self, file_name: str, content: str) -> None: """Append content to the given file. It appends text `content` to the end of the specified file. Args: file_name: str: The name of the file to edit. - line_number: int: The line number (starting from 1) to insert the content after. content: str: The content to insert. """ - ret_str = cls._edit_file_impl( + file_name = self.working_dir / Path(file_name) + ret_str = self._edit_file_impl( file_name, start=None, end=None, @@ -827,44 +703,46 @@ class Editor(BaseModel): is_insert=False, is_append=True, ) - print(ret_str) + logger.info(ret_str) - @classmethod - def search_dir(cls, search_term: str, dir_path: str = "./") -> None: + def search_dir(self, search_term: str, dir_path: str = "./") -> None: """Searches for search_term in all files in dir. If dir is not provided, searches in the current directory. Args: search_term: str: The term to search for. dir_path: str: The path to the directory to search. """ - if not os.path.isdir(dir_path): + dir_path = self.working_dir / Path(dir_path) + if not dir_path.is_dir(): raise FileNotFoundError(f"Directory {dir_path} not found") matches = [] for root, _, files in os.walk(dir_path): for file in files: if file.startswith("."): continue - file_path = os.path.join(root, file) - with open(file_path, "r", errors="ignore") as f: + file_path = Path(root) / file + with file_path.open("r", errors="ignore") as f: for line_num, line in enumerate(f, 1): if search_term in line: matches.append((file_path, line_num, line.strip())) if not matches: - print(f'No matches found for "{search_term}" in {dir_path}') + logger.info(f'No matches found for "{search_term}" in {dir_path}') return num_matches = len(matches) num_files = len(set(match[0] for match in matches)) if num_files > 100: - print(f'More than {num_files} files matched for "{search_term}" in {dir_path}. Please narrow your search.') + logger.info( + f'More than {num_files} files matched for "{search_term}" in {dir_path}. Please narrow your search.' + ) return - print(f'[Found {num_matches} matches for "{search_term}" in {dir_path}]') + logger.info(f'[Found {num_matches} matches for "{search_term}" in {dir_path}]') for file_path, line_num, line in matches: - print(f"{file_path} (Line {line_num}): {line}") - print(f'[End of matches for "{search_term}" in {dir_path}]') + logger.info(f"{file_path} (Line {line_num}): {line}") + logger.info(f'[End of matches for "{search_term}" in {dir_path}]') def search_file(self, search_term: str, file_path: Optional[str] = None) -> None: """Searches for search_term in file. If file is not provided, searches in the current open file. @@ -875,46 +753,49 @@ class Editor(BaseModel): """ if file_path is None: file_path = self.current_file + else: + file_path = self.working_dir / Path(file_path) if file_path is None: raise FileNotFoundError("No file specified or open. Use the open_file function first.") - if not os.path.isfile(file_path): + if not file_path.is_file(): raise FileNotFoundError(f"File {file_path} not found") matches = [] - with open(file_path) as file: + with file_path.open() as file: for i, line in enumerate(file, 1): if search_term in line: matches.append((i, line.strip())) if matches: - print(f'[Found {len(matches)} matches for "{search_term}" in {file_path}]') + logger.info(f'[Found {len(matches)} matches for "{search_term}" in {file_path}]') for match in matches: - print(f"Line {match[0]}: {match[1]}") - print(f'[End of matches for "{search_term}" in {file_path}]') + logger.info(f"Line {match[0]}: {match[1]}") + logger.info(f'[End of matches for "{search_term}" in {file_path}]') else: - print(f'[No matches found for "{search_term}" in {file_path}]') + logger.info(f'[No matches found for "{search_term}" in {file_path}]') - @staticmethod - def find_file(file_name: str, dir_path: str = "./") -> None: + def find_file(self, file_name: str, dir_path: str = "./") -> None: """Finds all files with the given name in the specified directory. Args: file_name: str: The name of the file to find. dir_path: str: The path to the directory to search. """ - if not os.path.isdir(dir_path): + file_name = self.working_dir / Path(file_name) + dir_path = self.working_dir / Path(dir_path) + if not dir_path.is_dir(): raise FileNotFoundError(f"Directory {dir_path} not found") matches = [] for root, _, files in os.walk(dir_path): for file in files: if file_name in file: - matches.append(os.path.join(root, file)) + matches.append(Path(root) / file) if matches: - print(f'[Found {len(matches)} matches for "{file_name}" in {dir_path}]') + logger.info(f'[Found {len(matches)} matches for "{file_name}" in {dir_path}]') for match in matches: - print(f"{match}") - print(f'[End of matches for "{file_name}" in {dir_path}]') + logger.info(f"{match}") + logger.info(f'[End of matches for "{file_name}" in {dir_path}]') else: - print(f'[No matches found for "{file_name}" in {dir_path}]') + logger.info(f'[No matches found for "{file_name}" in {dir_path}]') diff --git a/metagpt/tools/libs/file_io_operator.py b/metagpt/tools/libs/file_io_operator.py new file mode 100644 index 000000000..3e846c333 --- /dev/null +++ b/metagpt/tools/libs/file_io_operator.py @@ -0,0 +1,147 @@ +import base64 +import os +from pathlib import Path +from typing import List, Optional, Union + +from pydantic import BaseModel, ConfigDict + +from metagpt.config2 import Config +from metagpt.logs import logger +from metagpt.tools.tool_registry import register_tool +from metagpt.utils import read_docx +from metagpt.utils.common import aread, aread_bin, awrite_bin, check_http_endpoint +from metagpt.utils.repo_to_markdown import is_text_file +from metagpt.utils.report import FileIOOperatorReporter + + +class FileBlock(BaseModel): + """A block of content in a file""" + + file_path: str + block_content: str + + +class LineNumberError(Exception): + pass + + +@register_tool() +class FileIOOperator(BaseModel): + """ + A state-of-state tool for reading, understanding, and writing files. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + resource: FileIOOperatorReporter = FileIOOperatorReporter() + + def write(self, path: str, content: str): + """Write the whole content to a file. When used, make sure content arg contains the full content of the file.""" + if "\n" not in content and "\\n" in content: + # A very raw rule to correct the content: If 'content' lacks actual newlines ('\n') but includes '\\n', consider + # replacing them with '\n' to potentially correct mistaken representations of newline characters. + content = content.replace("\\n", "\n") + directory = os.path.dirname(path) + if directory and not os.path.exists(directory): + os.makedirs(directory) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + # self.resource.report(path, "path") + return f"The writing/coding the of the file {os.path.basename(path)}' is now completed. The file '{os.path.basename(path)}' has been successfully created." + + async def read(self, path: str) -> FileBlock: + """Read the whole content of a file. Using absolute paths as the argument for specifying the file location.""" + is_text, mime_type = await is_text_file(path) + if is_text: + lines = await self._read_text(path) + elif mime_type == "application/pdf": + lines = await self._read_pdf(path) + elif mime_type in { + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-word.document.macroEnabled.12", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.ms-word.template.macroEnabled.12", + }: + lines = await self._read_docx(path) + else: + return FileBlock(file_path=str(path), block_content="") + self.resource.report(str(path), "path") + + lines_with_num = [f"{i + 1:03}|{line}" for i, line in enumerate(lines)] + result = FileBlock( + file_path=str(path), + block_content="".join(lines_with_num), + ) + return result + + @staticmethod + async def _read_text(path: Union[str, Path]) -> List[str]: + content = await aread(path) + lines = content.split("\n") + return lines + + @staticmethod + async def _read_pdf(path: Union[str, Path]) -> List[str]: + result = await FileIOOperator._omniparse_read_file(path) + if result: + return result + + from llama_index.readers.file import PDFReader + + reader = PDFReader() + lines = reader.load_data(file=Path(path)) + return [i.text for i in lines] + + @staticmethod + async def _read_docx(path: Union[str, Path]) -> List[str]: + result = await FileIOOperator._omniparse_read_file(path) + if result: + return result + return read_docx(str(path)) + + @staticmethod + async def _omniparse_read_file(path: Union[str, Path]) -> Optional[List[str]]: + from metagpt.tools.libs import get_env_default + from metagpt.utils.omniparse_client import OmniParseClient + + base_url = await get_env_default(key="base_url", app_name="OmniParse", default_value="") + if not base_url: + base_url = await FileIOOperator._read_omniparse_config() + if not base_url: + return None + api_key = await get_env_default(key="api_key", app_name="OmniParse", default_value="") + v = await get_env_default(key="timeout", app_name="OmniParse", default_value="120") + try: + timeout = int(v) or 120 + except ValueError: + timeout = 120 + + try: + if not await check_http_endpoint(url=base_url): + logger.warning(f"{base_url}: NOT AVAILABLE") + return None + client = OmniParseClient(api_key=api_key, base_url=base_url, max_timeout=timeout) + file_data = await aread_bin(filename=path) + ret = await client.parse_document(file_input=file_data, bytes_filename=str(path)) + except (ValueError, Exception) as e: + logger.exception(f"{path}: {e}") + return None + if not ret.images: + return [ret.text] if ret.text else None + + result = [ret.text] + img_dir = Path(path).parent / (Path(path).name.replace(".", "_") + "_images") + img_dir.mkdir(parents=True, exist_ok=True) + for i in ret.images: + byte_data = base64.b64decode(i.image) + filename = img_dir / i.image_name + await awrite_bin(filename=filename, data=byte_data) + result.append(f"![{i.image_name}]({str(filename)})") + return result + + @staticmethod + async def _read_omniparse_config() -> str: + config = Config.default() + if config.omniparse and config.omniparse.url: + return config.omniparse.url + return "" diff --git a/metagpt/tools/libs/linter.py b/metagpt/tools/libs/linter.py index 509cb04c3..d77384095 100644 --- a/metagpt/tools/libs/linter.py +++ b/metagpt/tools/libs/linter.py @@ -30,12 +30,12 @@ class Linter: ) self.all_lint_cmd = None - # def set_linter(self, lang, cmd): - # if lang: - # self.languages[lang] = cmd - # return - # - # self.all_lint_cmd = cmd + def set_linter(self, lang, cmd): + if lang: + self.languages[lang] = cmd + return + + self.all_lint_cmd = cmd def get_rel_fname(self, fname): if self.root: diff --git a/metagpt/utils/report.py b/metagpt/utils/report.py index 5d1cd93e4..5021011d2 100644 --- a/metagpt/utils/report.py +++ b/metagpt/utils/report.py @@ -35,7 +35,7 @@ class BlockType(str, Enum): TASK = "Task" BROWSER = "Browser" BROWSER_RT = "Browser-RT" - EDITOR = "Editor" + FILE_IO_OPERATOR = "FileIOOperator" GALLERY = "Gallery" NOTEBOOK = "Notebook" DOCS = "Docs" @@ -305,10 +305,10 @@ class DocsReporter(FileReporter): block: Literal[BlockType.DOCS] = BlockType.DOCS -class EditorReporter(FileReporter): - """Equivalent to FileReporter(block=BlockType.Editor).""" +class FileIOOperatorReporter(FileReporter): + """Equivalent to FileReporter(block=BlockType.FileIOOperator).""" - block: Literal[BlockType.EDITOR] = BlockType.EDITOR + block: Literal[BlockType.FILE_IO_OPERATOR] = BlockType.FILE_IO_OPERATOR class GalleryReporter(FileReporter): diff --git a/tests/metagpt/test_reporter.py b/tests/metagpt/test_reporter.py index 41d963448..b1a0918a5 100644 --- a/tests/metagpt/test_reporter.py +++ b/tests/metagpt/test_reporter.py @@ -10,7 +10,7 @@ from metagpt.utils.report import ( BlockType, BrowserReporter, DocsReporter, - EditorReporter, + FileIOOperatorReporter, NotebookReporter, ServerReporter, TaskReporter, @@ -148,8 +148,8 @@ async def test_notebook_reporter(http_server): "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\n\nprint('Hello World')\n", "/data/main.py", {"type": "write_code"}, - BlockType.EDITOR, - EditorReporter, + BlockType.FILE_IO_OPERATOR, + FileIOOperatorReporter, ), ), ids=["test_docs_reporter", "test_editor_reporter"], From f2a8052aa852343c2b3f18eb3370b3e1ae49c172 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Tue, 27 Aug 2024 13:52:34 +0800 Subject: [PATCH 03/25] Add the SWEAgent's abilities to the engineer. --- metagpt/prompts/di/engineer2.py | 110 +++++++++++++++++--- metagpt/roles/di/engineer2.py | 91 +++++++++++++++-- metagpt/roles/di/swe_agent.py | 18 +++- metagpt/strategy/experience_retriever.py | 122 +++++++++++++++++++++++ 4 files changed, 318 insertions(+), 23 deletions(-) diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index 099e46177..83409462b 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -1,21 +1,101 @@ from metagpt.prompts.di.role_zero import ROLE_INSTRUCTION EXTRA_INSTRUCTION = """ -4. Each time you write a code in your response, write with the Editor directly without preparing a repetitive code block beforehand. -5. Take on ONE task and write ONE code file in each response. DON'T attempt all tasks in one response. -6. When not specified, you should write files in a folder named "src". If you know the project path, then write in a "src" folder under the project path. -7. When provided system design or project schedule, you MUST read them first before making a plan, then adhere to them in your implementation, especially in the programming language, package, or framework. You MUST implement all code files prescribed in the system design or project schedule. You can create a plan first with each task corresponding to implementing one code file. -8. Write at most one file per task, do your best to implement THE ONLY ONE FILE. CAREFULLY CHECK THAT YOU DONT MISS ANY NECESSARY CLASS/FUNCTION IN THIS FILE. -9. COMPLETE CODE: Your code will be part of the entire project, so please implement complete, reliable, reusable code snippets. -10. When provided system design, YOU MUST FOLLOW "Data structures and interfaces". DONT CHANGE ANY DESIGN. Do not use public member functions that do not exist in your design. -11. Write out EVERY CODE DETAIL, DON'T LEAVE TODO. -12. To modify code in a file, read the entire file, make changes, and update the file with the complete code, ensuring that no line numbers are included in the final write. -13. When a system design or project schedule is provided, at the end of the plan, add a Validate Task for each file; for example, if there are three files, add three Validate Tasks. For each Validate Task, just call ValidateAndRewriteCode.run. -14. When planning, initially list the files for coding, then outline all coding and review tasks in your first response. -15. Note 'Task for {file_name} completed.' — signifies the {file_name} coding task is done. -16. Avoid re-reviewing or re-coding the same code. When you decide to take a write or review action, include the command 'finish current task' in the same response. -17. When coding JavaScript, avoid using '\'' in strings. -18. If you plan to read a file, do not include other plans in the same response. +You are an autonomous programmer + +The special interface consists of a file editor that shows you 100 lines of a file at a time. + +You can use any bash commands you want (e.g., find, grep, cat, ls, cd) or any custom special tools (including `edit`) by calling Bash.run. +Edit all the files you need. + +You should carefully observe the behavior and results of the previous action, and avoid triggering repeated errors. + +However, the Bash.run does NOT support interactive session commands (e.g. python, vim), so please do not invoke them. + +In addition to the terminal, I also provide additional tools. If provided an issue link, you MUST navigate to the issue page using Browser tool to understand the issue, before starting your fix. + +Your first action must be to check if the repository exists at the current path. If it exists, navigate to the repository path. If the repository doesn't exist, please download it and then navigate to it. +All subsequent actions must be performed within this repository path. Do not leave this directory to execute any actions at any time. +Your terminal session has started, and you can use any bash commands or the special interface to help you. Edit all the files you need. + +Note: +1. Each time you write a code in your response, write with the Editor directly without preparing a repetitive code block beforehand. +2. Take on ONE task and write ONE code file in each response. DON'T attempt all tasks in one response. +3. When not specified, you should write files in a folder named "src". If you know the project path, then write in a "src" folder under the project path. +4. When provided system design or project schedule, you MUST read them first before making a plan, then adhere to them in your implementation, especially in the programming language, package, or framework. You MUST implement all code files prescribed in the system design or project schedule. You can create a plan first with each task corresponding to implementing one code file. +5. Write at most one file per task, do your best to implement THE ONLY ONE FILE. CAREFULLY CHECK THAT YOU DONT MISS ANY NECESSARY CLASS/FUNCTION IN THIS FILE. +6. COMPLETE CODE: Your code will be part of the entire project, so please implement complete, reliable, reusable code snippets. +7. When provided system design, YOU MUST FOLLOW "Data structures and interfaces". DONT CHANGE ANY DESIGN. Do not use public member functions that do not exist in your design. +8. Write out EVERY CODE DETAIL, DON'T LEAVE TODO. +9. To modify code in a file, read the entire file, make changes, and update the file with the complete code, ensuring that no line numbers are included in the final write. +10. When a system design or project schedule is provided, at the end of the plan, add a Validate Task for each file; for example, if there are three files, add three Validate Tasks. For each Validate Task, just call ValidateAndRewriteCode.run. +11. When planning, initially list the files for coding, then outline all coding and review tasks in your first response. +12. Note 'Task for {file_name} completed.' — signifies the {file_name} coding task is done. +13. Avoid re-reviewing or re-coding the same code. When you decide to take a write or review action, include the command 'finish current task' in the same response. +14. If you plan to read a file, do not include other plans in the same response. +15. Your terminal session has started, and you can use any bash commands or the special interface to help you. Edit all the files you need. +16. When editing files, it is easy to accidentally specify a wrong line number or to write code with incorrect indentation. Always check the code after you issue an edit to make sure that it reflects what you wanted to accomplish. If it didn't, issue another command to fix it. +17. If you need to modify a code or fix a bug. Please Use command "Bash.run" ans use "edit 14:14 <.py`. +26. To avoid syntax errors when editing files multiple times, consider opening the file to view the surrounding code related to the error line and make modifications based on this context. +27. When using the `edit` command, remember it operates within a closed range. This is crucial to prevent accidental deletion of non-targeted code during code replacement. +28. Ensure to observe the currently open file and the current working directory, which is displayed right after the open file. The open file might be in a different directory than the working directory. Remember, commands like 'create' open files and might alter the current open file. +29. Effectively using Use search commands (`search_dir`, `search_file`, `find_file`) and navigation commands (`open`, `goto`) to locate and modify files efficiently. Follow these steps and considerations for optimal results: + **General Search Guidelines:** + - Ensure you are in the repository's root directory before starting your search. + - Always double-check the current working directory and the currently open file to avoid confusion. + - Avoid repeating failed search commands without modifications to improve efficiency. + + **Strategies for Searching and Navigating Files:** + + 1. **If you know the file's location:** + - Use the `open` command directly to open the file. + - Use `search_file` to find the `search_term` within the currently open file. + - Alternatively, use the `goto` command to jump to the specified line. + - **Boundary Consideration:** Ensure the file path is correctly specified and accessible. + + 2. **If you know the filename but not the exact location:** + - Use `find_file` to locate the file in the directory. + - Use `open` to open the file once located. + - Use `search_file` to find the `search_term` within the file. + - Use `goto` to jump to the specified line if needed. + - **Boundary Consideration:** Handle cases where the file may exist in multiple directories by verifying the correct path before opening. + + 3. **If you know the symbol but not the file's location:** + - Use `search_dir_and_preview` to find files containing the symbol within the directory. + - Review the search results to identify the relevant file(s). + - Use `open` to open the identified file. + - Use `search_file` to locate the `search_term` within the open file. + - Use `goto` to jump to the specified line. + - **Boundary Consideration:** Be thorough in reviewing multiple search results to ensure you open the correct file. Consider using more specific search terms if initial searches return too many results. + + **Search Tips:** + - The `` for `search_dir_and_preview`, `find_file`, or `search_file` should be an existing class name, function name, or file name. + - Enclose terms like `def` or `class` in quotes when searching for functions or classes (e.g., `search_dir_and_preview 'def apow'` or `search_file 'class Pow'`). + - Use wildcard characters (`*`, `?`) in search terms to broaden or narrow down your search scope. + - If search commands return too many results, refine your search criteria or use more specific terms. + - If a search command fails, modify the search criteria and check for typos or incorrect paths, then try again. + - Based on feedback of observation or bash command in trajectory to guide adjustments in your search strategy. + +30. Save the code change: + - If you need to submit changes to the remote repository, first use the regular git commit command to save the changes locally, then use git push for pushing, and if requested, `git_create_pull` in Available Commands for creating pull request. + - If you don't need to submit code changes to the remote repository. use the command Bash.run('submit') to commit the changes locally. +31. If provided an issue link, you MUST go to the issue page using Browser tool to understand the issue before starting your fix. +32. When the edit fails, try to enlarge the starting line. +33. When using the Bash.run tool's edit command, the response must contain a single command only. +""" + +CURRENT_BASH_STATE = """ +# Output Next Step +The current bash state is: +(Open file: {open_file}) +(Current directory: {working_dir}) """ diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 7af5280e1..f82b19ae0 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -1,12 +1,17 @@ from __future__ import annotations +import json + from pydantic import Field from metagpt.actions.write_code_review import ValidateAndRewriteCode -from metagpt.prompts.di.engineer2 import ENGINEER2_INSTRUCTION +from metagpt.logs import logger +from metagpt.prompts.di.engineer2 import CURRENT_BASH_STATE, ENGINEER2_INSTRUCTION from metagpt.roles.di.role_zero import RoleZero +from metagpt.schema import Message from metagpt.strategy.experience_retriever import ENGINEER_EXAMPLE -from metagpt.tools.libs.terminal import Terminal +from metagpt.tools.libs.git import git_create_pull +from metagpt.tools.libs.terminal import Bash class Engineer2(RoleZero): @@ -15,21 +20,71 @@ class Engineer2(RoleZero): goal: str = "Take on game, app, and web development." instruction: str = ENGINEER2_INSTRUCTION - terminal: Terminal = Field(default_factory=Terminal, exclude=True) + # terminal: Terminal = Field(default_factory=Terminal, exclude=True) + terminal: Bash = Field(default_factory=Bash, exclude=True) - tools: list[str] = ["Plan", "Editor:write,read", "RoleZero", "Terminal:run_command", "ValidateAndRewriteCode"] + tools: list[str] = [ + "Plan", + "Editor:write,read", + "RoleZero", + "ValidateAndRewriteCode", + "Bash", + "Browser:goto,scroll", + "git_create_pull", + ] + # Swe Agent ability + run_eval: bool = False + output_diff: str = "" + max_react_loop: int = 40 + + async def _think(self) -> bool: + await self._format_instruction() + res = await super()._think() + return res + + async def _format_instruction(self): + """ + Formats the instruction message for the SWE agent. + Runs the "state" command in the terminal, parses its output as JSON, + and uses it to format the `_instruction` template. + """ + state_output = await self.terminal.run("state") + bash_state = json.loads(state_output) + self.cmd_prompt_current_state = CURRENT_BASH_STATE.format(**bash_state).strip() def _update_tool_execution(self): validate = ValidateAndRewriteCode() - self.tool_execution_map.update( { - "Terminal.run_command": self.terminal.run_command, "ValidateAndRewriteCode.run": validate.run, "ValidateAndRewriteCode": validate.run, + "Bash.run": self.eval_terminal_run if self.run_eval else self.terminal.run, + "RoleZero.ask_human": self.ask_human, + "RoleZero.reply_to_human": self.reply_to_human, + "git_create_pull": git_create_pull, } ) + async def eval_terminal_run(self, cmd): + """change command pull/push/commit to end.""" + if any([cmd_key_word in cmd for cmd_key_word in ["pull", "push", "commit"]]): + # The SWEAgent attempts to submit the repository after fixing the bug, thereby reaching the end of the fixing process. + # Set self.rc.todo to None to stop the engineer and then will trigger _save_git_diff funcion to save difference. + logger.info("SWEAgent use cmd:{cmd}") + logger.info("Current test case is finished.") + # stop the sweagent + self._set_state(-1) + command_output = "Current test case is finished." + else: + command_output = await self.terminal.run(cmd) + return command_output + + async def _act(self) -> Message: + message = await super()._act() + if self.run_eval: + await self._save_git_diff() + return message + def _retrieve_experience(self) -> str: return ENGINEER_EXAMPLE @@ -42,3 +97,27 @@ class Engineer2(RoleZero): command_output += "All tasks are finished.\n" command_output += await super()._run_special_command(cmd) return command_output + + async def _save_git_diff(self): + """ + Handles actions based on parsed commands. + + Parses commands, checks for a "submit" action, and generates a patch using `git diff`. + Stores the cleaned patch in `output_diff`. Logs any exceptions. + + This function is specifically added for SWE bench evaluation. + """ + # If todo switches to None, it indicates that this is the final round of reactions, and the Swe-Agent will stop. Use git diff to store any changes made. + if not self.rc.todo: + print("finish current task *******************************************************") + from metagpt.tools.swe_agent_commands.swe_agent_utils import extract_patch + + try: + logger.info(await self.terminal.run("submit")) + diff_output = await self.terminal.run("git diff --cached") + clear_diff = extract_patch(diff_output) + logger.info(f"Diff output: \n{clear_diff}") + if clear_diff: + self.output_diff = clear_diff + except Exception as e: + logger.error(f"Error during submission: {e}") diff --git a/metagpt/roles/di/swe_agent.py b/metagpt/roles/di/swe_agent.py index d54c9dd44..b4f7e5fc3 100644 --- a/metagpt/roles/di/swe_agent.py +++ b/metagpt/roles/di/swe_agent.py @@ -38,11 +38,25 @@ class SWEAgent(RoleZero): def _update_tool_execution(self): self.tool_execution_map.update( { - "Bash.run": self.terminal.run, + "Bash.run": self.eval_terminal_run if self.run_eval else self.terminal.run, "git_create_pull": git_create_pull, } ) + async def eval_terminal_run(self, cmd): + """change command pull/push/commit to end.""" + if any([cmd_key_word in cmd for cmd_key_word in ["pull", "push", "commit"]]): + # Observe that SWEAgent tries to submit the repo after fixing the bug. + # Set self.rc.todo to None and use git -diff to record the change. + logger.info("SWEAgent use cmd:{cmd}") + logger.info("finish current task") + # stop the sweagent + self._set_state(-1) + command_output = "Current test case is finished." + else: + command_output = await self.terminal.run(cmd) + return command_output + async def _format_instruction(self): """ Formats the instruction message for the SWE agent. @@ -57,7 +71,7 @@ class SWEAgent(RoleZero): async def _act(self) -> Message: message = await super()._act() if self.run_eval: - self._parse_commands_for_eval() + await self._parse_commands_for_eval() return message async def _parse_commands_for_eval(self): diff --git a/metagpt/strategy/experience_retriever.py b/metagpt/strategy/experience_retriever.py index 52884296d..d4b86aede 100644 --- a/metagpt/strategy/experience_retriever.py +++ b/metagpt/strategy/experience_retriever.py @@ -952,6 +952,128 @@ Explanation: to review the code, call ValidateAndRewriteCode.run. } ] ``` + +## example 5 +I have received a GitHub issue URL. +I will use browser to review the detailed information of this issue in order to understand the problem. +```json +[ + { + "command_name": "Browser.goto", + "args": { + "url": "https://github.com/geekan/MetaGPT/issues/1275" + } + } +] +``` +## example 6 +I need to locating the `openai_api.py` file, so I will search for the `openai_api.py` file. +```json +[ + { + "command_name": "Bash.run", + "args": { + "cmd": "find_file 'openai_api.py'" + } + } +] +``` + +## example 7 +I have located the openai_api.py file. I want to edit this file, so I will open it first. +```json +[ + { + "command_name": "Bash.run", + "args": { + "cmd": "open '/workspace/MetaGPT/provider/openai_api.py'" + } + } +] +``` + +## example 8 +I've found the bug and will start fixing it. I'll pay close attention to the indentation. +Since I only need to modify a few lines in this file, I will use the Bash.run tool with the edit command. +Note that the edit command must be executed in a single response, so this step will only involve using the edit command. +```json +[ + { + "command_name": "Bash.run", + "args": { + "cmd": "edit 93:95 < Date: Tue, 27 Aug 2024 14:03:31 +0800 Subject: [PATCH 04/25] remove redundant code --- metagpt/roles/di/engineer2.py | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index f82b19ae0..33c2c2dd9 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -32,7 +32,7 @@ class Engineer2(RoleZero): "Browser:goto,scroll", "git_create_pull", ] - # Swe Agent ability + # SWE Agent parameter run_eval: bool = False output_diff: str = "" max_react_loop: int = 40 @@ -59,8 +59,6 @@ class Engineer2(RoleZero): "ValidateAndRewriteCode.run": validate.run, "ValidateAndRewriteCode": validate.run, "Bash.run": self.eval_terminal_run if self.run_eval else self.terminal.run, - "RoleZero.ask_human": self.ask_human, - "RoleZero.reply_to_human": self.reply_to_human, "git_create_pull": git_create_pull, } ) From 2c045d6833b7e31bfd3b5a0febb01588a75a8462 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Tue, 27 Aug 2024 16:35:30 +0800 Subject: [PATCH 05/25] update engineer instructions --- metagpt/prompts/di/engineer2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index 83409462b..f2d5f2030 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -88,7 +88,7 @@ Note: - If you don't need to submit code changes to the remote repository. use the command Bash.run('submit') to commit the changes locally. 31. If provided an issue link, you MUST go to the issue page using Browser tool to understand the issue before starting your fix. 32. When the edit fails, try to enlarge the starting line. -33. When using the Bash.run tool's edit command, the response must contain a single command only. +33. When using the Bash.run tool's edit command or open command, the response must contain a single command only. """ CURRENT_BASH_STATE = """ From 47996da2b03dd3ead23fb948ab19152379141edc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Tue, 27 Aug 2024 18:08:24 +0800 Subject: [PATCH 06/25] update run_swe_agent_for_benchmark --- .../roles/di/run_swe_agent_for_benchmark.py | 61 +++++++++++++------ 1 file changed, 44 insertions(+), 17 deletions(-) diff --git a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py index 207521c97..2bac9b823 100644 --- a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py +++ b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py @@ -1,16 +1,21 @@ import asyncio import json +import os +import shutil +import sys from datetime import datetime from metagpt.config2 import Config from metagpt.const import DEFAULT_WORKSPACE_ROOT, METAGPT_ROOT from metagpt.logs import logger -from metagpt.roles.di.swe_agent import SWEAgent +from metagpt.roles.di.engineer2 import Engineer2 from metagpt.tools.libs.terminal import Terminal from metagpt.tools.swe_agent_commands.swe_agent_utils import load_hf_dataset config = Config.default() # Specify by yourself +Role = Engineer2 +MAX_MINUTES_PRE_INSTANCE = 20 TEST_REPO_DIR = METAGPT_ROOT / "data" / "test_repo" DATA_DIR = METAGPT_ROOT / "data/hugging_face" @@ -57,13 +62,20 @@ async def run(instance, swe_result_dir): return repo_path = TEST_REPO_DIR / (instance["repo"].replace("-", "_").replace("/", "__") + "_" + instance["version"]) - - # 前处理 + # 下载仓库 + logger.info(f"repo_path:{repo_path}") + if os.path.exists(repo_path): + # 删除已有的仓库 + logger.info(f"remove exist repo path:{repo_path}") + shutil.rmtree(repo_path) + # 下载仓库 并切换分支 terminal = Terminal() - await terminal.run_command(f"cd {repo_path} && git reset --hard && git clean -n -d && git clean -f -d") - await terminal.run_command("BRANCH=$(git remote show origin | awk '/HEAD branch/ {print $NF}')") - logger.info(await terminal.run_command("echo $BRANCH")) - logger.info(await terminal.run_command('git checkout "$BRANCH"')) + repo_identifier = instance["repo"] + base_commit = instance["base_commit"] + clone_command = f"git clone 'https://github.com/{repo_identifier}.git' {repo_path}" + checkout_command = f"cd {repo_path} && git checkout -f {base_commit}" if base_commit else "" + await terminal.run_command(clone_command) + logger.info(await terminal.run_command(checkout_command)) logger.info(await terminal.run_command("git branch")) user_requirement_and_issue = INSTANCE_TEMPLATE.format( @@ -75,18 +87,23 @@ async def run(instance, swe_result_dir): ) logger.info(f"**** Starting to run {instance['instance_id']}****") - swe_agent = SWEAgent() - swe_agent.run_eval = True - await swe_agent.run(user_requirement_and_issue) - save_predictions(swe_agent, instance, swe_result_dir) + logger.info("User Requirement", user_requirement_and_issue) + try: + role = Role(run_eval=True) + await asyncio.wait_for(role.run(user_requirement_and_issue), timeout=MAX_MINUTES_PRE_INSTANCE * 60) + except: + logger.info(f"**** exception lead to end: {instance['instance_id']}****") + pass + + save_predictions(role, instance, swe_result_dir) logger.info(f"**** Finished running {instance['instance_id']}****") -def save_predictions(swe_agent: SWEAgent, instance, swe_result_dir): +def save_predictions(role, instance, swe_result_dir): output_file = swe_result_dir / "all_preds.jsonl" - instance["model_name_or_path"] = swe_agent.config.llm.model - instance["model_patch"] = swe_agent.output_diff - + instance["model_name_or_path"] = role.config.llm.model + instance["model_patch"] = role.output_diff + logger.info("model_patch", role.output_diff) logger.info(f"Preparing to save predictions to {output_file}") # Save the predictions to a JSONL file @@ -102,11 +119,21 @@ async def async_main(): dataset = load_hf_dataset(dataset_name_or_path=dataset_path, cache_dir=DATA_DIR, split="test") date_time = datetime.now().strftime("%m%d") _round = "first" - # _round = "second" + exp_name = f"nano_mgx_{date_time}_{_round}" + + # now = datetime.now() + # formatted_time = now.strftime("%Y_%m_%d_%H_%M_%S") + # swe_result_dir = ( + # DEFAULT_WORKSPACE_ROOT / f"result_{config.llm.model.replace('/', '_')}_start_time_{formatted_time}" / exp_name + # ) swe_result_dir = DEFAULT_WORKSPACE_ROOT / f"result_{config.llm.model.replace('/', '_')}" / exp_name swe_result_dir.mkdir(parents=True, exist_ok=True) - for instance in dataset: + for index, instance in enumerate(dataset): + # switch to a new logger file + logger.remove() + logger.add(sys.stderr, level="INFO") + logger.add(swe_result_dir / f"{index+1}_{instance['instance_id']}.log", level="DEBUG") await run(instance, swe_result_dir) From 1dad0ae44e5c8765a8d9270c38c1850458077bb2 Mon Sep 17 00:00:00 2001 From: liushaojie Date: Thu, 29 Aug 2024 17:55:11 +0800 Subject: [PATCH 07/25] update: test editor --- metagpt/prompts/di/swe_agent.py | 4 +- metagpt/roles/di/role_zero.py | 5 +- metagpt/tools/libs/editor.py | 154 +++++++----- metagpt/tools/libs/file_io_operator.py | 8 +- metagpt/tools/libs/linter.py | 1 - tests/data/tools/test_script_for_editor.py | 0 tests/metagpt/tools/libs/test_editor.py | 263 ++++++++------------- 7 files changed, 200 insertions(+), 235 deletions(-) create mode 100644 tests/data/tools/test_script_for_editor.py diff --git a/metagpt/prompts/di/swe_agent.py b/metagpt/prompts/di/swe_agent.py index b543c01d5..86a062214 100644 --- a/metagpt/prompts/di/swe_agent.py +++ b/metagpt/prompts/di/swe_agent.py @@ -183,9 +183,7 @@ IMPORTANT_TIPS = """ 15. When the edit fails, try to enlarge the starting line. -16. Use an absolute path instead of a relative path. - -17. Once again, and this is critical: YOU CAN ONLY ENTER ONE COMMAND AT A TIME. +16. Once again, and this is critical: YOU CAN ONLY ENTER ONE COMMAND AT A TIME. """ NEXT_STEP_TEMPLATE = f""" diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index e32292b96..d2486b89e 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -71,7 +71,7 @@ class RoleZero(Role): tools: list[str] = [] # Use special symbol [""] to indicate use of all registered tools tool_recommender: Optional[ToolRecommender] = None tool_execution_map: Annotated[dict[str, Callable], Field(exclude=True)] = {} - special_tool_commands: list[str] = ["Plan.finish_current_task", "end", "Bash.run"] + special_tool_commands: list[str] = ["Plan.finish_current_task", "end"] # Equipped with three basic tools by default for optional use editor: Editor = Editor() browser: Browser = Browser() @@ -140,12 +140,11 @@ class RoleZero(Role): "goto_line", "insert_content_at_line", "open_file", - # "read", "scroll_down", "scroll_up", "search_dir", "search_file", - # "write", + "set_workdir", ] } ) diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index 806098522..71f297acd 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -1,9 +1,15 @@ +""" +This file is borrowed from OpenDevin +You can find the original repository here: +https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/plugins/agent_skills/file_ops/file_ops.py +""" + import os import re import shutil import tempfile from pathlib import Path -from typing import Optional +from typing import Optional, Union from pydantic import BaseModel @@ -25,6 +31,8 @@ class LineNumberError(Exception): class Editor(BaseModel): """ A state-of-state tool for open, reading, and editing files. + Args: + working_dir: The working directory to use for the editor. """ current_file: Optional[Path] = None @@ -74,22 +82,22 @@ class Editor(BaseModel): def _clamp(value, min_value, max_value): return max(min_value, min(value, max_value)) - @staticmethod - def _lint_file(file_path: Path) -> tuple[Optional[str], Optional[int]]: + def _lint_file(self, file_path: Path) -> tuple[Optional[str], Optional[int]]: """Lint the file at the given path and return a tuple with a boolean indicating if there are errors, and the line number of the first error, if any. Returns: tuple[str | None, int | None]: (lint_error, first_error_line_number) """ - linter = Linter(root=os.getcwd()) + + linter = Linter(root=self.working_dir) lint_error = linter.lint(str(file_path)) if not lint_error: # Linting successful. No issues found. return None, None return "ERRORS:\n" + lint_error.text, lint_error.lines[0] - def _print_window(self, file_path: Path, targeted_line: int, window: int, return_str: bool = False): + def _print_window(self, file_path: Path, targeted_line: int, window: int): self._check_current_file(file_path) with file_path.open() as file: content = file.read() @@ -133,10 +141,7 @@ class Editor(BaseModel): output += "(this is the end of the file)\n" output = output.rstrip() - if return_str: - return output - else: - logger.info(output) + return output @staticmethod def _cur_file_header(current_file: Path, total_lines: int) -> str: @@ -154,7 +159,9 @@ class Editor(BaseModel): """ self.working_dir = Path(path) - def open_file(self, path: str, line_number: Optional[int] = 1, context_lines: Optional[int] = None) -> None: + def open_file( + self, path: Union[Path, str], line_number: Optional[int] = 1, context_lines: Optional[int] = None + ) -> str: """Opens the file at the given path in the editor. If line_number is provided, the window will be moved to include that line. It only shows the first 100 lines by default! Max `context_lines` supported is 2000, use `scroll up/down` to view the file if you want to see more. @@ -167,7 +174,7 @@ class Editor(BaseModel): if context_lines is None: context_lines = self.window - path = self.working_dir / Path(path) + path = self._try_fix_path(path) if not path.is_file(): raise FileNotFoundError(f"File {path} not found") @@ -185,10 +192,10 @@ class Editor(BaseModel): context_lines = self.window output = self._cur_file_header(path, total_lines) - output += self._print_window(path, self.current_line, self._clamp(context_lines, 1, 2000), return_str=True) - logger.info(output) + output += self._print_window(path, self.current_line, self._clamp(context_lines, 1, 2000)) + return output - def goto_line(self, line_number: int) -> None: + def goto_line(self, line_number: int) -> str: """Moves the window to show the specified line number. Args: @@ -204,10 +211,10 @@ class Editor(BaseModel): self.current_line = self._clamp(line_number, 1, total_lines) output = self._cur_file_header(self.current_file, total_lines) - output += self._print_window(self.current_file, self.current_line, self.window, return_str=True) - logger.info(output) + output += self._print_window(self.current_file, self.current_line, self.window) + return output - def scroll_down(self) -> None: + def scroll_down(self) -> str: """Moves the window down by 100 lines.""" self._check_current_file() @@ -215,10 +222,10 @@ class Editor(BaseModel): total_lines = max(1, sum(1 for _ in file)) self.current_line = self._clamp(self.current_line + self.window, 1, total_lines) output = self._cur_file_header(self.current_file, total_lines) - output += self._print_window(self.current_file, self.current_line, self.window, return_str=True) - logger.info(output) + output += self._print_window(self.current_file, self.current_line, self.window) + return output - def scroll_up(self) -> None: + def scroll_up(self) -> str: """Moves the window up by 100 lines.""" self._check_current_file() @@ -226,16 +233,16 @@ class Editor(BaseModel): total_lines = max(1, sum(1 for _ in file)) self.current_line = self._clamp(self.current_line - self.window, 1, total_lines) output = self._cur_file_header(self.current_file, total_lines) - output += self._print_window(self.current_file, self.current_line, self.window, return_str=True) - logger.info(output) + output += self._print_window(self.current_file, self.current_line, self.window) + return output - def create_file(self, filename: str) -> None: + def create_file(self, filename: str) -> str: """Creates and opens a new file with the given name. Args: filename: str: The name of the file to create. """ - filename = self.working_dir / Path(filename) + filename = self._try_fix_path(filename) if filename.exists(): raise FileExistsError(f"File '{filename}' already exists.") @@ -244,7 +251,7 @@ class Editor(BaseModel): file.write("\n") self.open_file(filename) - logger.info(f"[File {filename} created.]") + return f"[File {filename} created.]" @staticmethod def _append_impl(lines, content): @@ -345,11 +352,22 @@ class Editor(BaseModel): if start > end: raise LineNumberError(f"Invalid line range: {start}-{end}. Start must be less than or equal to end.") + # Split content into lines and ensure it ends with a newline if not content.endswith("\n"): content += "\n" content_lines = content.splitlines(True) + + # Calculate the number of lines to be added n_added_lines = len(content_lines) + + # Remove the specified range of lines and insert the new content new_lines = lines[: start - 1] + content_lines + lines[end:] + + # Handle the case where the original lines are empty + if len(lines) == 0: + new_lines = content_lines + + # Join the lines to create the new content content = "".join(new_lines) return content, n_added_lines @@ -403,8 +421,6 @@ class Editor(BaseModel): first_error_line = None try: - n_added_lines = None - # lint the original file enable_auto_lint = os.getenv("ENABLE_AUTO_LINT", "false").lower() == "true" if enable_auto_lint: @@ -506,7 +522,6 @@ class Editor(BaseModel): original_file_backup_path, show_line, editor_lines, - return_str=True, ) + "\n" ) @@ -549,11 +564,11 @@ class Editor(BaseModel): self.current_line = start or n_total_lines or 1 ret_str += f"[File: {file_name.resolve()} ({n_total_lines} lines total after edit)]\n" CURRENT_FILE = file_name - ret_str += self._print_window(CURRENT_FILE, self.current_line, self.window, return_str=True) + "\n" + ret_str += self._print_window(CURRENT_FILE, self.current_line, self.window) + "\n" ret_str += MSG_FILE_UPDATED.format(line_number=self.current_line) return ret_str - def edit_file_by_replace(self, file_name: str, to_replace: str, new_content: str) -> None: + def edit_file_by_replace(self, file_name: str, to_replace: str, new_content: str) -> str: """Edit a file. This will search for `to_replace` in the given file and replace it with `new_content`. Every *to_replace* must *EXACTLY MATCH* the existing source code, character for character, including all comments, docstrings, etc. @@ -609,7 +624,7 @@ class Editor(BaseModel): # search for `to_replace` in the file # if found, replace it with `new_content` # if not found, perform a fuzzy search to find the closest match and replace it with `new_content` - file_name = self.working_dir / Path(file_name) + file_name = self._try_fix_path(file_name) with file_name.open("r") as file: file_content = file.read() @@ -635,8 +650,7 @@ class Editor(BaseModel): # find the closest match start = file_content_fuzzy.find(to_replace_fuzzy) if start == -1: - logger.info(f"[No exact match found in {file_name} for\n```\n{to_replace}\n```\n]") - return + return f"[No exact match found in {file_name} for\n```\n{to_replace}\n```\n]" # Convert start from index to line number for fuzzy match start_line_number = file_content_fuzzy[:start].count("\n") + 1 end_line_number = start_line_number + len(to_replace.splitlines()) - 1 @@ -650,9 +664,9 @@ class Editor(BaseModel): ) # lint_error = bool(LINTER_ERROR_MSG in ret_str) # TODO: automatically tries to fix linter error (maybe involve some static analysis tools on the location near the edit to figure out indentation) - logger.info(ret_str) + return ret_str - def insert_content_at_line(self, file_name: str, line_number: int, content: str) -> None: + def insert_content_at_line(self, file_name: str, line_number: int, content: str) -> str: """Insert content at the given line number in a file. This will NOT modify the content of the lines before OR after the given line number. @@ -675,7 +689,8 @@ class Editor(BaseModel): line_number: int: The line number (starting from 1) to insert the content after. content: str: The content to insert. """ - file_name = self.working_dir / Path(file_name) + file_name = self._try_fix_path(file_name) + ret_str = self._edit_file_impl( file_name, start=line_number, @@ -684,9 +699,9 @@ class Editor(BaseModel): is_insert=True, is_append=False, ) - logger.info(ret_str) + return ret_str - def append_file(self, file_name: str, content: str) -> None: + def append_file(self, file_name: str, content: str) -> str: """Append content to the given file. It appends text `content` to the end of the specified file. @@ -694,7 +709,8 @@ class Editor(BaseModel): file_name: str: The name of the file to edit. content: str: The content to insert. """ - file_name = self.working_dir / Path(file_name) + file_name = self._try_fix_path(file_name) + ret_str = self._edit_file_impl( file_name, start=None, @@ -703,16 +719,16 @@ class Editor(BaseModel): is_insert=False, is_append=True, ) - logger.info(ret_str) + return ret_str - def search_dir(self, search_term: str, dir_path: str = "./") -> None: + def search_dir(self, search_term: str, dir_path: str = "./") -> str: """Searches for search_term in all files in dir. If dir is not provided, searches in the current directory. Args: search_term: str: The term to search for. dir_path: str: The path to the directory to search. """ - dir_path = self.working_dir / Path(dir_path) + dir_path = self._try_fix_path(dir_path) if not dir_path.is_dir(): raise FileNotFoundError(f"Directory {dir_path} not found") matches = [] @@ -727,24 +743,21 @@ class Editor(BaseModel): matches.append((file_path, line_num, line.strip())) if not matches: - logger.info(f'No matches found for "{search_term}" in {dir_path}') - return + return f'No matches found for "{search_term}" in {dir_path}' num_matches = len(matches) num_files = len(set(match[0] for match in matches)) if num_files > 100: - logger.info( - f'More than {num_files} files matched for "{search_term}" in {dir_path}. Please narrow your search.' - ) - return + return f'More than {num_files} files matched for "{search_term}" in {dir_path}. Please narrow your search.' - logger.info(f'[Found {num_matches} matches for "{search_term}" in {dir_path}]') + res_list = [f'[Found {num_matches} matches for "{search_term}" in {dir_path}]'] for file_path, line_num, line in matches: - logger.info(f"{file_path} (Line {line_num}): {line}") - logger.info(f'[End of matches for "{search_term}" in {dir_path}]') + res_list.append(f"{file_path} (Line {line_num}): {line}") + res_list.append(f'[End of matches for "{search_term}" in {dir_path}]') + return "\n".join(res_list) - def search_file(self, search_term: str, file_path: Optional[str] = None) -> None: + def search_file(self, search_term: str, file_path: Optional[str] = None) -> str: """Searches for search_term in file. If file is not provided, searches in the current open file. Args: @@ -754,7 +767,7 @@ class Editor(BaseModel): if file_path is None: file_path = self.current_file else: - file_path = self.working_dir / Path(file_path) + file_path = self._try_fix_path(file_path) if file_path is None: raise FileNotFoundError("No file specified or open. Use the open_file function first.") if not file_path.is_file(): @@ -765,24 +778,25 @@ class Editor(BaseModel): for i, line in enumerate(file, 1): if search_term in line: matches.append((i, line.strip())) - + res_list = [] if matches: - logger.info(f'[Found {len(matches)} matches for "{search_term}" in {file_path}]') + res_list.append(f'[Found {len(matches)} matches for "{search_term}" in {file_path}]') for match in matches: - logger.info(f"Line {match[0]}: {match[1]}") - logger.info(f'[End of matches for "{search_term}" in {file_path}]') + res_list.append(f"Line {match[0]}: {match[1]}") + res_list.append(f'[End of matches for "{search_term}" in {file_path}]') else: - logger.info(f'[No matches found for "{search_term}" in {file_path}]') + res_list.append(f'[No matches found for "{search_term}" in {file_path}]') + return "\n".join(res_list) - def find_file(self, file_name: str, dir_path: str = "./") -> None: + def find_file(self, file_name: str, dir_path: str = "./") -> str: """Finds all files with the given name in the specified directory. Args: file_name: str: The name of the file to find. dir_path: str: The path to the directory to search. """ - file_name = self.working_dir / Path(file_name) - dir_path = self.working_dir / Path(dir_path) + file_name = self._try_fix_path(file_name) + dir_path = self._try_fix_path(dir_path) if not dir_path.is_dir(): raise FileNotFoundError(f"Directory {dir_path} not found") @@ -792,10 +806,20 @@ class Editor(BaseModel): if file_name in file: matches.append(Path(root) / file) + res_list = [] if matches: - logger.info(f'[Found {len(matches)} matches for "{file_name}" in {dir_path}]') + res_list.append(f'[Found {len(matches)} matches for "{file_name}" in {dir_path}]') for match in matches: - logger.info(f"{match}") - logger.info(f'[End of matches for "{file_name}" in {dir_path}]') + res_list.append(f"{match}") + res_list.append(f'[End of matches for "{file_name}" in {dir_path}]') else: - logger.info(f'[No matches found for "{file_name}" in {dir_path}]') + res_list.append(f'[No matches found for "{file_name}" in {dir_path}]') + return "\n".join(res_list) + + def _try_fix_path(self, path: Union[Path, str]) -> Path: + """Tries to fix the path if it is not absolute.""" + if not isinstance(path, Path): + path = Path(path) + if not path.is_absolute(): + path = self.working_dir / path + return path diff --git a/metagpt/tools/libs/file_io_operator.py b/metagpt/tools/libs/file_io_operator.py index 3e846c333..f30d2d4fd 100644 --- a/metagpt/tools/libs/file_io_operator.py +++ b/metagpt/tools/libs/file_io_operator.py @@ -26,7 +26,7 @@ class LineNumberError(Exception): @register_tool() -class FileIOOperator(BaseModel): +class FileOperator(BaseModel): """ A state-of-state tool for reading, understanding, and writing files. """ @@ -82,7 +82,7 @@ class FileIOOperator(BaseModel): @staticmethod async def _read_pdf(path: Union[str, Path]) -> List[str]: - result = await FileIOOperator._omniparse_read_file(path) + result = await FileOperator._omniparse_read_file(path) if result: return result @@ -94,7 +94,7 @@ class FileIOOperator(BaseModel): @staticmethod async def _read_docx(path: Union[str, Path]) -> List[str]: - result = await FileIOOperator._omniparse_read_file(path) + result = await FileOperator._omniparse_read_file(path) if result: return result return read_docx(str(path)) @@ -106,7 +106,7 @@ class FileIOOperator(BaseModel): base_url = await get_env_default(key="base_url", app_name="OmniParse", default_value="") if not base_url: - base_url = await FileIOOperator._read_omniparse_config() + base_url = await FileOperator._read_omniparse_config() if not base_url: return None api_key = await get_env_default(key="api_key", app_name="OmniParse", default_value="") diff --git a/metagpt/tools/libs/linter.py b/metagpt/tools/libs/linter.py index d77384095..9f3ab7fd0 100644 --- a/metagpt/tools/libs/linter.py +++ b/metagpt/tools/libs/linter.py @@ -46,7 +46,6 @@ class Linter: def run_cmd(self, cmd, rel_fname, code): cmd += " " + rel_fname cmd = cmd.split() - process = subprocess.Popen(cmd, cwd=self.root, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) stdout, _ = process.communicate() errors = stdout.decode().strip() diff --git a/tests/data/tools/test_script_for_editor.py b/tests/data/tools/test_script_for_editor.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/metagpt/tools/libs/test_editor.py b/tests/metagpt/tools/libs/test_editor.py index 6f0861c75..18b1400bf 100644 --- a/tests/metagpt/tools/libs/test_editor.py +++ b/tests/metagpt/tools/libs/test_editor.py @@ -1,10 +1,7 @@ -import contextlib -import io - import pytest from metagpt.const import TEST_DATA_PATH -from metagpt.tools.libs.editor import WINDOW, Editor +from metagpt.tools.libs.editor import Editor TEST_FILE_CONTENT = """ # this is line one @@ -17,6 +14,7 @@ def test_function_for_fm(): """.strip() TEST_FILE_PATH = TEST_DATA_PATH / "tools/test_script_for_editor.py" +WINDOW = 100 @pytest.fixture @@ -38,32 +36,43 @@ def test_function_for_fm(): """.strip() -@pytest.mark.skip def test_replace_content(test_file): - Editor().write_content( - file_path=str(TEST_FILE_PATH), - start_line=3, - end_line=5, - new_block_content=" # This is the new line A replacing lines 3 to 5.\n # This is the new line B.", + editor = Editor() + editor._edit_file_impl( + file_name=TEST_FILE_PATH, + start=3, + end=5, + content=" # This is the new line A replacing lines 3 to 5.\n # This is the new line B.", + is_insert=False, + is_append=False, ) with open(TEST_FILE_PATH, "r") as f: new_content = f.read() - assert new_content == EXPECTED_CONTENT_AFTER_REPLACE + assert new_content.strip() == EXPECTED_CONTENT_AFTER_REPLACE.strip() EXPECTED_CONTENT_AFTER_DELETE = """ # this is line one def test_function_for_fm(): + c = 3 # this is the 7th line """.strip() def test_delete_content(test_file): - Editor().write_content(file_path=str(TEST_FILE_PATH), start_line=3, end_line=5) + editor = Editor() + editor._edit_file_impl( + file_name=TEST_FILE_PATH, + start=3, + end=5, + content="", + is_insert=False, + is_append=False, + ) with open(TEST_FILE_PATH, "r") as f: new_content = f.read() - assert new_content == EXPECTED_CONTENT_AFTER_DELETE + assert new_content.strip() == EXPECTED_CONTENT_AFTER_DELETE.strip() EXPECTED_CONTENT_AFTER_INSERT = """ @@ -78,17 +87,19 @@ def test_function_for_fm(): """.strip() -@pytest.mark.skip def test_insert_content(test_file): - Editor().write_content( - file_path=str(TEST_FILE_PATH), - start_line=3, - end_line=-1, - new_block_content=" # This is the new line to be inserted, at line 3", + editor = Editor() + editor._edit_file_impl( + file_name=TEST_FILE_PATH, + start=3, + end=3, + content=" # This is the new line to be inserted, at line 3", + is_insert=True, + is_append=False, ) with open(TEST_FILE_PATH, "r") as f: new_content = f.read() - assert new_content == EXPECTED_CONTENT_AFTER_INSERT + assert new_content.strip() == EXPECTED_CONTENT_AFTER_INSERT.strip() @pytest.mark.parametrize( @@ -117,12 +128,6 @@ async def test_read_files(filename): assert file_block.block_content -@pytest.fixture(autouse=True) -def reset_current_file(): - global CURRENT_FILE - CURRENT_FILE = None - - def _numbered_test_lines(start, end) -> str: return ("\n".join(f"{i}|" for i in range(start, end + 1))) + "\n" @@ -150,24 +155,20 @@ def _calculate_window_bounds(current_line, total_lines, window_size): return start, end -@pytest.mark.asyncio -async def test_open_file_unexist_path(): +def test_open_file_unexist_path(): editor = Editor() with pytest.raises(FileNotFoundError): editor.open_file("/unexist/path/a.txt") -@pytest.mark.asyncio -async def test_open_file(tmp_path): +def test_open_file(tmp_path): editor = Editor() assert tmp_path is not None temp_file_path = tmp_path / "a.txt" temp_file_path.write_text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5") - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(temp_file_path)) - result = buf.getvalue() + result = editor.open_file(str(temp_file_path)) + assert result is not None expected = ( f"[File: {temp_file_path} (5 lines total)]\n" @@ -177,21 +178,17 @@ async def test_open_file(tmp_path): "3|Line 3\n" "4|Line 4\n" "5|Line 5\n" - "(this is the end of the file)\n" + "(this is the end of the file)" ) assert result.split("\n") == expected.split("\n") -@pytest.mark.asyncio -async def test_open_file_with_indentation(tmp_path): +def test_open_file_with_indentation(tmp_path): editor = Editor() temp_file_path = tmp_path / "a.txt" temp_file_path.write_text("Line 1\n Line 2\nLine 3\nLine 4\nLine 5") - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(temp_file_path)) - result = buf.getvalue() + result = editor.open_file(str(temp_file_path)) assert result is not None expected = ( f"[File: {temp_file_path} (5 lines total)]\n" @@ -201,33 +198,28 @@ async def test_open_file_with_indentation(tmp_path): "3|Line 3\n" "4|Line 4\n" "5|Line 5\n" - "(this is the end of the file)\n" + "(this is the end of the file)" ) assert result.split("\n") == expected.split("\n") -@pytest.mark.asyncio -async def test_open_file_long(tmp_path): +def test_open_file_long(tmp_path): editor = Editor() temp_file_path = tmp_path / "a.txt" content = "\n".join([f"Line {i}" for i in range(1, 1001)]) temp_file_path.write_text(content) - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(temp_file_path), 1, 50) - result = buf.getvalue() + result = editor.open_file(str(temp_file_path), 1, 50) assert result is not None expected = f"[File: {temp_file_path} (1000 lines total)]\n" expected += "(this is the beginning of the file)\n" for i in range(1, 51): expected += f"{i}|Line {i}\n" - expected += "(950 more lines below)\n" + expected += "(950 more lines below)" assert result.split("\n") == expected.split("\n") -@pytest.mark.asyncio -async def test_open_file_long_with_lineno(tmp_path): +def test_open_file_long_with_lineno(tmp_path): editor = Editor() temp_file_path = tmp_path / "a.txt" content = "\n".join([f"Line {i}" for i in range(1, 1001)]) @@ -235,10 +227,7 @@ async def test_open_file_long_with_lineno(tmp_path): cur_line = 100 - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(temp_file_path), cur_line) - result = buf.getvalue() + result = editor.open_file(str(temp_file_path), cur_line) assert result is not None expected = f"[File: {temp_file_path} (1000 lines total)]\n" start, end = _calculate_window_bounds(cur_line, 1000, WINDOW) @@ -251,61 +240,44 @@ async def test_open_file_long_with_lineno(tmp_path): if end == 1000: expected += "(this is the end of the file)\n" else: - expected += f"({1000 - end} more lines below)\n" + expected += f"({1000 - end} more lines below)" assert result.split("\n") == expected.split("\n") -@pytest.mark.asyncio -async def test_create_file_unexist_path(): +def test_create_file_unexist_path(): editor = Editor() with pytest.raises(FileNotFoundError): editor.create_file("/unexist/path/a.txt") -@pytest.mark.asyncio -async def test_create_file(tmp_path): +def test_create_file(tmp_path): editor = Editor() temp_file_path = tmp_path / "a.txt" - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.create_file(str(temp_file_path)) - result = buf.getvalue() + result = editor.create_file(str(temp_file_path)) - expected = ( - f"[File: {temp_file_path} (1 lines total)]\n" - "(this is the beginning of the file)\n" - "1|\n" - "(this is the end of the file)\n" - f"[File {temp_file_path} created.]\n" - ) + expected = f"[File {temp_file_path} created.]" assert result.split("\n") == expected.split("\n") -@pytest.mark.asyncio -async def test_goto_line(tmp_path): +def test_goto_line(tmp_path): editor = Editor() temp_file_path = tmp_path / "a.txt" total_lines = 1000 content = "\n".join([f"Line {i}" for i in range(1, total_lines + 1)]) temp_file_path.write_text(content) - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(temp_file_path)) - result = buf.getvalue() + result = editor.open_file(str(temp_file_path)) assert result is not None expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" expected += "(this is the beginning of the file)\n" for i in range(1, WINDOW + 1): expected += f"{i}|Line {i}\n" - expected += f"({total_lines - WINDOW} more lines below)\n" + expected += f"({total_lines - WINDOW} more lines below)" assert result.split("\n") == expected.split("\n") - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.goto_line(500) - result = buf.getvalue() + result = editor.goto_line(500) + assert result is not None cur_line = 500 @@ -320,50 +292,39 @@ async def test_goto_line(tmp_path): if end == total_lines: expected += "(this is the end of the file)\n" else: - expected += f"({total_lines - end} more lines below)\n" + expected += f"({total_lines - end} more lines below)" assert result.split("\n") == expected.split("\n") -@pytest.mark.asyncio -async def test_goto_line_negative(tmp_path): +def test_goto_line_negative(tmp_path): editor = Editor() temp_file_path = tmp_path / "a.txt" content = "\n".join([f"Line {i}" for i in range(1, 5)]) temp_file_path.write_text(content) - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(temp_file_path)) + editor.open_file(str(temp_file_path)) with pytest.raises(ValueError): editor.goto_line(-1) -@pytest.mark.asyncio -async def test_goto_line_out_of_bound(tmp_path): +def test_goto_line_out_of_bound(tmp_path): editor = Editor() temp_file_path = tmp_path / "a.txt" content = "\n".join([f"Line {i}" for i in range(1, 5)]) temp_file_path.write_text(content) - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(temp_file_path)) + editor.open_file(str(temp_file_path)) with pytest.raises(ValueError): editor.goto_line(100) -@pytest.mark.asyncio -async def test_scroll_down(tmp_path): +def test_scroll_down(tmp_path): editor = Editor() temp_file_path = tmp_path / "a.txt" total_lines = 1000 content = "\n".join([f"Line {i}" for i in range(1, total_lines + 1)]) temp_file_path.write_text(content) - - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(temp_file_path)) - result = buf.getvalue() + result = editor.open_file(str(temp_file_path)) assert result is not None expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" @@ -375,15 +336,13 @@ async def test_scroll_down(tmp_path): for i in range(start, end + 1): expected += f"{i}|Line {i}\n" if end == total_lines: - expected += "(this is the end of the file)\n" + expected += "(this is the end of the file)" else: - expected += f"({total_lines - end} more lines below)\n" + expected += f"({total_lines - end} more lines below)" assert result.split("\n") == expected.split("\n") - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.scroll_down() - result = buf.getvalue() + result = editor.scroll_down() + assert result is not None expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" @@ -397,12 +356,11 @@ async def test_scroll_down(tmp_path): if end == total_lines: expected += "(this is the end of the file)\n" else: - expected += f"({total_lines - end} more lines below)\n" + expected += f"({total_lines - end} more lines below)" assert result.split("\n") == expected.split("\n") -@pytest.mark.asyncio -async def test_scroll_up(tmp_path): +def test_scroll_up(tmp_path): editor = Editor() temp_file_path = tmp_path / "a.txt" total_lines = 1000 @@ -410,10 +368,8 @@ async def test_scroll_up(tmp_path): temp_file_path.write_text(content) cur_line = 300 - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(temp_file_path), cur_line) - result = buf.getvalue() + + result = editor.open_file(str(temp_file_path), cur_line) assert result is not None expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" @@ -427,13 +383,9 @@ async def test_scroll_up(tmp_path): if end == total_lines: expected += "(this is the end of the file)\n" else: - expected += f"({total_lines - end} more lines below)\n" + expected += f"({total_lines - end} more lines below)" assert result.split("\n") == expected.split("\n") - - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.scroll_up() - result = buf.getvalue() + result = editor.scroll_up() assert result is not None cur_line = cur_line - WINDOW @@ -449,44 +401,35 @@ async def test_scroll_up(tmp_path): if end == total_lines: expected += "(this is the end of the file)\n" else: - expected += f"({total_lines - end} more lines below)\n" + expected += f"({total_lines - end} more lines below)" assert result.split("\n") == expected.split("\n") -@pytest.mark.asyncio -async def test_scroll_down_edge(tmp_path): +def test_scroll_down_edge(tmp_path): editor = Editor() temp_file_path = tmp_path / "a.txt" content = "\n".join([f"Line {i}" for i in range(1, 10)]) temp_file_path.write_text(content) - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(temp_file_path)) - result = buf.getvalue() + result = editor.open_file(str(temp_file_path)) assert result is not None expected = f"[File: {temp_file_path} (9 lines total)]\n" expected += "(this is the beginning of the file)\n" for i in range(1, 10): expected += f"{i}|Line {i}\n" - expected += "(this is the end of the file)\n" + expected += "(this is the end of the file)" - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.scroll_down() - result = buf.getvalue() + result = editor.scroll_down() assert result is not None assert result.split("\n") == expected.split("\n") -@pytest.mark.asyncio -async def test_print_window_internal(tmp_path): +def test_print_window_internal(tmp_path): editor = Editor() test_file_path = tmp_path / "a.txt" - await editor.create_file(str(test_file_path)) - editor.open_file(str(test_file_path)) + editor.create_file(str(test_file_path)) with open(test_file_path, "w") as file: for i in range(1, 101): file.write(f"Line `{i}`\n") @@ -494,20 +437,15 @@ async def test_print_window_internal(tmp_path): current_line = 50 window = 2 - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor._print_window(str(test_file_path), current_line, window, return_str=False) - result = buf.getvalue() - expected = "(48 more lines above)\n" "49|Line `49`\n" "50|Line `50`\n" "51|Line `51`\n" "(49 more lines below)\n" + result = editor._print_window(test_file_path, current_line, window) + expected = "(48 more lines above)\n" "49|Line `49`\n" "50|Line `50`\n" "51|Line `51`\n" "(49 more lines below)" assert result == expected -@pytest.mark.asyncio -async def test_open_file_large_line_number(tmp_path): +def test_open_file_large_line_number(tmp_path): editor = Editor() test_file_path = tmp_path / "a.txt" editor.create_file(str(test_file_path)) - editor.open_file(str(test_file_path)) with open(test_file_path, "w") as file: for i in range(1, 1000): file.write(f"Line `{i}`\n") @@ -515,24 +453,20 @@ async def test_open_file_large_line_number(tmp_path): current_line = 800 window = 100 - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(test_file_path), current_line, window) - result = buf.getvalue() + result = editor.open_file(str(test_file_path), current_line, window) + expected = f"[File: {test_file_path} (999 lines total)]\n" expected += "(749 more lines above)\n" for i in range(750, 850 + 1): expected += f"{i}|Line `{i}`\n" - expected += "(149 more lines below)\n" + expected += "(149 more lines below)" assert result == expected -@pytest.mark.asyncio -async def test_open_file_large_line_number_consecutive_diff_window(tmp_path): +def test_open_file_large_line_number_consecutive_diff_window(tmp_path): editor = Editor() test_file_path = tmp_path / "a.txt" editor.create_file(str(test_file_path)) - editor.open_file(str(test_file_path)) total_lines = 1000 with open(test_file_path, "w") as file: for i in range(1, total_lines + 1): @@ -541,10 +475,8 @@ async def test_open_file_large_line_number_consecutive_diff_window(tmp_path): current_line = 800 cur_window = 300 - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.open_file(str(test_file_path), current_line, cur_window) - result = buf.getvalue() + result = editor.open_file(str(test_file_path), current_line, cur_window) + expected = f"[File: {test_file_path} ({total_lines} lines total)]\n" start, end = _calculate_window_bounds(current_line, total_lines, cur_window) if start == 1: @@ -556,13 +488,26 @@ async def test_open_file_large_line_number_consecutive_diff_window(tmp_path): if end == total_lines: expected += "(this is the end of the file)\n" else: - expected += f"({total_lines - end} more lines below)\n" + expected += f"({total_lines - end} more lines below)" assert result == expected current_line = current_line - WINDOW - with io.StringIO() as buf: - with contextlib.redirect_stdout(buf): - editor.scroll_up() + + result = editor.scroll_up() + + expected = f"[File: {test_file_path} ({total_lines} lines total)]\n" + start, end = _calculate_window_bounds(current_line, total_lines, WINDOW) + if start == 1: + expected += "(this is the beginning of the file)\n" + else: + expected += f"({start - 1} more lines above)\n" + for i in range(start, end + 1): + expected += f"{i}|Line `{i}`\n" + if end == total_lines: + expected += "(this is the end of the file)\n" + else: + expected += f"({total_lines - end} more lines below)" + assert result.split("\n") == expected.split("\n") if __name__ == "__main__": From 6ebb9952b892dd814804f0f6ec46f3a9669cb85b Mon Sep 17 00:00:00 2001 From: liushaojie Date: Fri, 30 Aug 2024 17:43:52 +0800 Subject: [PATCH 08/25] update: editor --- metagpt/actions/write_code.py | 4 +- metagpt/actions/write_code_review.py | 4 +- metagpt/ext/cr/actions/modify_code.py | 4 +- metagpt/roles/qa_engineer.py | 4 +- metagpt/tools/libs/cr.py | 4 +- metagpt/tools/libs/editor.py | 145 ++++++++++++++- metagpt/tools/libs/file_io_operator.py | 149 ---------------- metagpt/utils/report.py | 8 +- tests/metagpt/test_reporter.py | 6 +- tests/metagpt/tools/libs/test_editor.py | 226 +++++++++++++++++------- 10 files changed, 317 insertions(+), 237 deletions(-) delete mode 100644 metagpt/tools/libs/file_io_operator.py diff --git a/metagpt/actions/write_code.py b/metagpt/actions/write_code.py index a2d55ff13..da25fe621 100644 --- a/metagpt/actions/write_code.py +++ b/metagpt/actions/write_code.py @@ -29,7 +29,7 @@ from metagpt.logs import logger from metagpt.schema import CodingContext, Document, RunCodeResult from metagpt.utils.common import CodeParser, get_markdown_code_block_type from metagpt.utils.project_repo import ProjectRepo -from metagpt.utils.report import FileIOOperatorReporter +from metagpt.utils.report import EditorReporter PROMPT_TEMPLATE = """ NOTICE @@ -152,7 +152,7 @@ class WriteCode(Action): summary_log=summary_doc.content if summary_doc else "", ) logger.info(f"Writing {coding_context.filename}..") - async with FileIOOperatorReporter(enable_llm_stream=True) as reporter: + async with EditorReporter(enable_llm_stream=True) as reporter: await reporter.async_report({"type": "code", "filename": coding_context.filename}, "meta") code = await self.write_code(prompt) if not coding_context.code_doc: diff --git a/metagpt/actions/write_code_review.py b/metagpt/actions/write_code_review.py index a7141747a..6a283f812 100644 --- a/metagpt/actions/write_code_review.py +++ b/metagpt/actions/write_code_review.py @@ -22,7 +22,7 @@ from metagpt.schema import CodingContext, Document from metagpt.tools.tool_registry import register_tool from metagpt.utils.common import CodeParser, aread, awrite from metagpt.utils.project_repo import ProjectRepo -from metagpt.utils.report import FileIOOperatorReporter +from metagpt.utils.report import EditorReporter PROMPT_TEMPLATE = """ # System @@ -144,7 +144,7 @@ class WriteCodeReview(Action): return result, None # if LBTM, rewrite code - async with FileIOOperatorReporter(enable_llm_stream=True) as reporter: + async with EditorReporter(enable_llm_stream=True) as reporter: await reporter.async_report( {"type": "code", "filename": filename, "src_path": doc.root_relative_path}, "meta" ) diff --git a/metagpt/ext/cr/actions/modify_code.py b/metagpt/ext/cr/actions/modify_code.py index e4c637347..820bdae4a 100644 --- a/metagpt/ext/cr/actions/modify_code.py +++ b/metagpt/ext/cr/actions/modify_code.py @@ -13,7 +13,7 @@ from metagpt.ext.cr.utils.cleaner import ( rm_patch_useless_part, ) from metagpt.utils.common import CodeParser -from metagpt.utils.report import FileIOOperatorReporter +from metagpt.utils.report import EditorReporter SYSTEM_MSGS_PROMPT = """ You're an adaptive software developer who excels at refining code based on user inputs. You're proficient in creating Git patches to represent code modifications. @@ -100,7 +100,7 @@ class ModifyCode(Action): ) patch_file = output_dir / f"{patch_target_file_name}.patch" patch_file.parent.mkdir(exist_ok=True, parents=True) - async with FileIOOperatorReporter(enable_llm_stream=True) as reporter: + async with EditorReporter(enable_llm_stream=True) as reporter: await reporter.async_report( {"type": "Patch", "src_path": str(patch_file), "filename": patch_file.name}, "meta" ) diff --git a/metagpt/roles/qa_engineer.py b/metagpt/roles/qa_engineer.py index de2b27372..fc8fa5353 100644 --- a/metagpt/roles/qa_engineer.py +++ b/metagpt/roles/qa_engineer.py @@ -33,7 +33,7 @@ from metagpt.utils.common import ( parse_recipient, ) from metagpt.utils.project_repo import ProjectRepo -from metagpt.utils.report import FileIOOperatorReporter +from metagpt.utils.report import EditorReporter class QaEngineer(Role): @@ -80,7 +80,7 @@ class QaEngineer(Role): context = TestingContext(filename=test_doc.filename, test_doc=test_doc, code_doc=code_doc) context = await WriteTest(i_context=context, context=self.context, llm=self.llm).run() - async with FileIOOperatorReporter(enable_llm_stream=True) as reporter: + async with EditorReporter(enable_llm_stream=True) as reporter: await reporter.async_report({"type": "test", "filename": test_doc.filename}, "meta") doc = await self.repo.tests.save_doc( diff --git a/metagpt/tools/libs/cr.py b/metagpt/tools/libs/cr.py index 87a686eb1..7d156b4d6 100644 --- a/metagpt/tools/libs/cr.py +++ b/metagpt/tools/libs/cr.py @@ -13,7 +13,7 @@ from metagpt.ext.cr.actions.modify_code import ModifyCode from metagpt.ext.cr.utils.schema import Point from metagpt.tools.libs.browser import Browser from metagpt.tools.tool_registry import register_tool -from metagpt.utils.report import FileIOOperatorReporter +from metagpt.utils.report import EditorReporter @register_tool(tags=["codereview"], include_functions=["review", "fix"]) @@ -86,7 +86,7 @@ class CodeReview: else: async with aiofiles.open(patch_path, encoding="utf-8") as f: patch_file_content = await f.read() - await FileIOOperatorReporter().async_report(patch_path) + await EditorReporter().async_report(patch_path) if not patch_path.endswith((".diff", ".patch")): name = Path(patch_path).name patch_file_content = "".join( diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index 71f297acd..81f2bd4a7 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -3,26 +3,38 @@ This file is borrowed from OpenDevin You can find the original repository here: https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/plugins/agent_skills/file_ops/file_ops.py """ - +import base64 import os import re import shutil import tempfile from pathlib import Path -from typing import Optional, Union +from typing import List, Optional, Tuple, Union -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict +from metagpt.config2 import Config from metagpt.const import DEFAULT_WORKSPACE_ROOT from metagpt.logs import logger from metagpt.tools.libs.linter import Linter from metagpt.tools.tool_registry import register_tool +from metagpt.utils import read_docx +from metagpt.utils.common import aread, aread_bin, awrite_bin, check_http_endpoint +from metagpt.utils.repo_to_markdown import is_text_file +from metagpt.utils.report import EditorReporter # This is also used in unit tests! MSG_FILE_UPDATED = "[File updated (edited at line {line_number}). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]" LINTER_ERROR_MSG = "[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n" +class FileBlock(BaseModel): + """A block of content in a file""" + + file_path: str + block_content: str + + class LineNumberError(Exception): pass @@ -30,16 +42,133 @@ class LineNumberError(Exception): @register_tool() class Editor(BaseModel): """ - A state-of-state tool for open, reading, and editing files. + A state-of-state tool for open/reading, understanding, and editing/writing files. Args: working_dir: The working directory to use for the editor. """ + model_config = ConfigDict(arbitrary_types_allowed=True) + resource: EditorReporter = EditorReporter() current_file: Optional[Path] = None current_line: int = 1 window: int = 100 + enable_auto_lint: bool = False working_dir: Path = DEFAULT_WORKSPACE_ROOT + def write(self, path: str, content: str): + """Write the whole content to a file. When used, make sure content arg contains the full content of the file.""" + if "\n" not in content and "\\n" in content: + # A very raw rule to correct the content: If 'content' lacks actual newlines ('\n') but includes '\\n', consider + # replacing them with '\n' to potentially correct mistaken representations of newline characters. + content = content.replace("\\n", "\n") + directory = os.path.dirname(path) + if directory and not os.path.exists(directory): + os.makedirs(directory) + with open(path, "w", encoding="utf-8") as f: + f.write(content) + # self.resource.report(path, "path") + return f"The writing/coding the of the file {os.path.basename(path)}' is now completed. The file '{os.path.basename(path)}' has been successfully created." + + async def read(self, path: str) -> FileBlock: + """Read the whole content of a file. Using absolute paths as the argument for specifying the file location.""" + is_text, mime_type = await is_text_file(path) + if is_text: + lines = await self._read_text(path) + elif mime_type == "application/pdf": + lines = await self._read_pdf(path) + elif mime_type in { + "application/msword", + "application/vnd.openxmlformats-officedocument.wordprocessingml.document", + "application/vnd.ms-word.document.macroEnabled.12", + "application/vnd.openxmlformats-officedocument.wordprocessingml.template", + "application/vnd.ms-word.template.macroEnabled.12", + }: + lines = await self._read_docx(path) + else: + return FileBlock(file_path=str(path), block_content="") + self.resource.report(str(path), "path") + + lines_with_num = [f"{i + 1:03}|{line}" for i, line in enumerate(lines)] + result = FileBlock( + file_path=str(path), + block_content="".join(lines_with_num), + ) + return result + + @staticmethod + async def _read_text(path: Union[str, Path]) -> List[str]: + content = await aread(path) + lines = content.split("\n") + return lines + + @staticmethod + async def _read_pdf(path: Union[str, Path]) -> List[str]: + result = await Editor._omniparse_read_file(path) + if result: + return result + + from llama_index.readers.file import PDFReader + + reader = PDFReader() + lines = reader.load_data(file=Path(path)) + return [i.text for i in lines] + + @staticmethod + async def _read_docx(path: Union[str, Path]) -> List[str]: + result = await Editor._omniparse_read_file(path) + if result: + return result + return read_docx(str(path)) + + @staticmethod + async def _omniparse_read_file(path: Union[str, Path]) -> Optional[List[str]]: + from metagpt.tools.libs import get_env_default + from metagpt.utils.omniparse_client import OmniParseClient + + env_base_url = await get_env_default(key="base_url", app_name="OmniParse", default_value="") + env_timeout = await get_env_default(key="timeout", app_name="OmniParse", default_value="") + conf_base_url, conf_timeout = await Editor._read_omniparse_config() + + base_url = env_base_url or conf_base_url + if not base_url: + return None + api_key = await get_env_default(key="api_key", app_name="OmniParse", default_value="") + timeout = env_timeout or conf_timeout or 600 + try: + timeout = int(timeout) + except ValueError: + timeout = 600 + + try: + if not await check_http_endpoint(url=base_url): + logger.warning(f"{base_url}: NOT AVAILABLE") + return None + client = OmniParseClient(api_key=api_key, base_url=base_url, max_timeout=timeout) + file_data = await aread_bin(filename=path) + ret = await client.parse_document(file_input=file_data, bytes_filename=str(path)) + except (ValueError, Exception) as e: + logger.exception(f"{path}: {e}") + return None + if not ret.images: + return [ret.text] if ret.text else None + + result = [ret.text] + img_dir = Path(path).parent / (Path(path).name.replace(".", "_") + "_images") + img_dir.mkdir(parents=True, exist_ok=True) + for i in ret.images: + byte_data = base64.b64decode(i.image) + filename = img_dir / i.image_name + await awrite_bin(filename=filename, data=byte_data) + result.append(f"![{i.image_name}]({str(filename)})") + return result + + @staticmethod + async def _read_omniparse_config() -> Tuple[str, int]: + config = Config.default() + if config.omniparse and config.omniparse.url: + return config.omniparse.url, config.omniparse.timeout + return "", 0 + @staticmethod def _is_valid_filename(file_name: str) -> bool: if not file_name or not file_name.strip(): @@ -422,8 +551,8 @@ class Editor(BaseModel): try: # lint the original file - enable_auto_lint = os.getenv("ENABLE_AUTO_LINT", "false").lower() == "true" - if enable_auto_lint: + # enable_auto_lint = os.getenv("ENABLE_AUTO_LINT", "false").lower() == "true" + if self.enable_auto_lint: original_lint_error, _ = self._lint_file(file_name) # Create a temporary file @@ -461,7 +590,7 @@ class Editor(BaseModel): # Handle linting # NOTE: we need to get env var inside this function # because the env var will be set AFTER the agentskills is imported - if enable_auto_lint: + if self.enable_auto_lint: # BACKUP the original file original_file_backup_path = file_name.parent / f".backup.{file_name.name}" with original_file_backup_path.open("w") as f: @@ -803,7 +932,7 @@ class Editor(BaseModel): matches = [] for root, _, files in os.walk(dir_path): for file in files: - if file_name in file: + if str(file_name) in file: matches.append(Path(root) / file) res_list = [] diff --git a/metagpt/tools/libs/file_io_operator.py b/metagpt/tools/libs/file_io_operator.py deleted file mode 100644 index 29578789d..000000000 --- a/metagpt/tools/libs/file_io_operator.py +++ /dev/null @@ -1,149 +0,0 @@ -import base64 -import os -from pathlib import Path -from typing import List, Optional, Tuple, Union - -from pydantic import BaseModel, ConfigDict - -from metagpt.config2 import Config -from metagpt.logs import logger -from metagpt.tools.tool_registry import register_tool -from metagpt.utils import read_docx -from metagpt.utils.common import aread, aread_bin, awrite_bin, check_http_endpoint -from metagpt.utils.repo_to_markdown import is_text_file -from metagpt.utils.report import FileIOOperatorReporter - - -class FileBlock(BaseModel): - """A block of content in a file""" - - file_path: str - block_content: str - - -class LineNumberError(Exception): - pass - - -@register_tool() -class FileOperator(BaseModel): - """ - A state-of-state tool for reading, understanding, and writing files. - """ - - model_config = ConfigDict(arbitrary_types_allowed=True) - resource: FileIOOperatorReporter = FileIOOperatorReporter() - - def write(self, path: str, content: str): - """Write the whole content to a file. When used, make sure content arg contains the full content of the file.""" - if "\n" not in content and "\\n" in content: - # A very raw rule to correct the content: If 'content' lacks actual newlines ('\n') but includes '\\n', consider - # replacing them with '\n' to potentially correct mistaken representations of newline characters. - content = content.replace("\\n", "\n") - directory = os.path.dirname(path) - if directory and not os.path.exists(directory): - os.makedirs(directory) - with open(path, "w", encoding="utf-8") as f: - f.write(content) - # self.resource.report(path, "path") - return f"The writing/coding the of the file {os.path.basename(path)}' is now completed. The file '{os.path.basename(path)}' has been successfully created." - - async def read(self, path: str) -> FileBlock: - """Read the whole content of a file. Using absolute paths as the argument for specifying the file location.""" - is_text, mime_type = await is_text_file(path) - if is_text: - lines = await self._read_text(path) - elif mime_type == "application/pdf": - lines = await self._read_pdf(path) - elif mime_type in { - "application/msword", - "application/vnd.openxmlformats-officedocument.wordprocessingml.document", - "application/vnd.ms-word.document.macroEnabled.12", - "application/vnd.openxmlformats-officedocument.wordprocessingml.template", - "application/vnd.ms-word.template.macroEnabled.12", - }: - lines = await self._read_docx(path) - else: - return FileBlock(file_path=str(path), block_content="") - self.resource.report(str(path), "path") - - lines_with_num = [f"{i + 1:03}|{line}" for i, line in enumerate(lines)] - result = FileBlock( - file_path=str(path), - block_content="".join(lines_with_num), - ) - return result - - @staticmethod - async def _read_text(path: Union[str, Path]) -> List[str]: - content = await aread(path) - lines = content.split("\n") - return lines - - @staticmethod - async def _read_pdf(path: Union[str, Path]) -> List[str]: - result = await FileOperator._omniparse_read_file(path) - if result: - return result - - from llama_index.readers.file import PDFReader - - reader = PDFReader() - lines = reader.load_data(file=Path(path)) - return [i.text for i in lines] - - @staticmethod - async def _read_docx(path: Union[str, Path]) -> List[str]: - result = await FileOperator._omniparse_read_file(path) - if result: - return result - return read_docx(str(path)) - - @staticmethod - async def _omniparse_read_file(path: Union[str, Path]) -> Optional[List[str]]: - from metagpt.tools.libs import get_env_default - from metagpt.utils.omniparse_client import OmniParseClient - - env_base_url = await get_env_default(key="base_url", app_name="OmniParse", default_value="") - env_timeout = await get_env_default(key="timeout", app_name="OmniParse", default_value="") - conf_base_url, conf_timeout = await FileOperator._read_omniparse_config() - - base_url = env_base_url or conf_base_url - if not base_url: - return None - api_key = await get_env_default(key="api_key", app_name="OmniParse", default_value="") - timeout = env_timeout or conf_timeout or 600 - try: - timeout = int(timeout) - except ValueError: - timeout = 600 - - try: - if not await check_http_endpoint(url=base_url): - logger.warning(f"{base_url}: NOT AVAILABLE") - return None - client = OmniParseClient(api_key=api_key, base_url=base_url, max_timeout=timeout) - file_data = await aread_bin(filename=path) - ret = await client.parse_document(file_input=file_data, bytes_filename=str(path)) - except (ValueError, Exception) as e: - logger.exception(f"{path}: {e}") - return None - if not ret.images: - return [ret.text] if ret.text else None - - result = [ret.text] - img_dir = Path(path).parent / (Path(path).name.replace(".", "_") + "_images") - img_dir.mkdir(parents=True, exist_ok=True) - for i in ret.images: - byte_data = base64.b64decode(i.image) - filename = img_dir / i.image_name - await awrite_bin(filename=filename, data=byte_data) - result.append(f"![{i.image_name}]({str(filename)})") - return result - - @staticmethod - async def _read_omniparse_config() -> Tuple[str, int]: - config = Config.default() - if config.omniparse and config.omniparse.url: - return config.omniparse.url, config.omniparse.timeout - return "", 0 diff --git a/metagpt/utils/report.py b/metagpt/utils/report.py index 5021011d2..427f401ab 100644 --- a/metagpt/utils/report.py +++ b/metagpt/utils/report.py @@ -35,7 +35,7 @@ class BlockType(str, Enum): TASK = "Task" BROWSER = "Browser" BROWSER_RT = "Browser-RT" - FILE_IO_OPERATOR = "FileIOOperator" + EDITOR = "Editor" GALLERY = "Gallery" NOTEBOOK = "Notebook" DOCS = "Docs" @@ -305,10 +305,10 @@ class DocsReporter(FileReporter): block: Literal[BlockType.DOCS] = BlockType.DOCS -class FileIOOperatorReporter(FileReporter): - """Equivalent to FileReporter(block=BlockType.FileIOOperator).""" +class EditorReporter(FileReporter): + """Equivalent to FileReporter(block=BlockType.EDITOR).""" - block: Literal[BlockType.FILE_IO_OPERATOR] = BlockType.FILE_IO_OPERATOR + block: Literal[BlockType.EDITOR] = BlockType.EDITOR class GalleryReporter(FileReporter): diff --git a/tests/metagpt/test_reporter.py b/tests/metagpt/test_reporter.py index b1a0918a5..41d963448 100644 --- a/tests/metagpt/test_reporter.py +++ b/tests/metagpt/test_reporter.py @@ -10,7 +10,7 @@ from metagpt.utils.report import ( BlockType, BrowserReporter, DocsReporter, - FileIOOperatorReporter, + EditorReporter, NotebookReporter, ServerReporter, TaskReporter, @@ -148,8 +148,8 @@ async def test_notebook_reporter(http_server): "#!/usr/bin/env python\n# -*- coding: utf-8 -*-\n\nprint('Hello World')\n", "/data/main.py", {"type": "write_code"}, - BlockType.FILE_IO_OPERATOR, - FileIOOperatorReporter, + BlockType.EDITOR, + EditorReporter, ), ), ids=["test_docs_reporter", "test_editor_reporter"], diff --git a/tests/metagpt/tools/libs/test_editor.py b/tests/metagpt/tools/libs/test_editor.py index 18b1400bf..e2774ddc5 100644 --- a/tests/metagpt/tools/libs/test_editor.py +++ b/tests/metagpt/tools/libs/test_editor.py @@ -13,17 +13,24 @@ def test_function_for_fm(): # this is the 7th line """.strip() -TEST_FILE_PATH = TEST_DATA_PATH / "tools/test_script_for_editor.py" WINDOW = 100 @pytest.fixture -def test_file(): - with open(TEST_FILE_PATH, "w") as f: - f.write(TEST_FILE_CONTENT) - yield - with open(TEST_FILE_PATH, "w") as f: - f.write("") +def temp_file_path(tmp_path): + assert tmp_path is not None + temp_file_path = tmp_path / "a.txt" + yield temp_file_path + temp_file_path.unlink() + + +@pytest.fixture +def temp_py_file(tmp_path): + assert tmp_path is not None + temp_file_path = tmp_path / "test_script_for_editor.py" + temp_file_path.write_text(TEST_FILE_CONTENT) + yield temp_file_path + temp_file_path.unlink() EXPECTED_CONTENT_AFTER_REPLACE = """ @@ -36,17 +43,17 @@ def test_function_for_fm(): """.strip() -def test_replace_content(test_file): +def test_replace_content(temp_py_file): editor = Editor() editor._edit_file_impl( - file_name=TEST_FILE_PATH, + file_name=temp_py_file, start=3, end=5, content=" # This is the new line A replacing lines 3 to 5.\n # This is the new line B.", is_insert=False, is_append=False, ) - with open(TEST_FILE_PATH, "r") as f: + with open(temp_py_file, "r") as f: new_content = f.read() assert new_content.strip() == EXPECTED_CONTENT_AFTER_REPLACE.strip() @@ -60,17 +67,17 @@ def test_function_for_fm(): """.strip() -def test_delete_content(test_file): +def test_delete_content(temp_py_file): editor = Editor() editor._edit_file_impl( - file_name=TEST_FILE_PATH, + file_name=temp_py_file, start=3, end=5, content="", is_insert=False, is_append=False, ) - with open(TEST_FILE_PATH, "r") as f: + with open(temp_py_file, "r") as f: new_content = f.read() assert new_content.strip() == EXPECTED_CONTENT_AFTER_DELETE.strip() @@ -87,17 +94,14 @@ def test_function_for_fm(): """.strip() -def test_insert_content(test_file): - editor = Editor() - editor._edit_file_impl( - file_name=TEST_FILE_PATH, - start=3, - end=3, +def test_insert_content(temp_py_file): + editor = Editor(enable_auto_lint=True) + editor.insert_content_at_line( + file_name=temp_py_file, + line_number=3, content=" # This is the new line to be inserted, at line 3", - is_insert=True, - is_append=False, ) - with open(TEST_FILE_PATH, "r") as f: + with open(temp_py_file, "r") as f: new_content = f.read() assert new_content.strip() == EXPECTED_CONTENT_AFTER_INSERT.strip() @@ -161,10 +165,8 @@ def test_open_file_unexist_path(): editor.open_file("/unexist/path/a.txt") -def test_open_file(tmp_path): +def test_open_file(temp_file_path): editor = Editor() - assert tmp_path is not None - temp_file_path = tmp_path / "a.txt" temp_file_path.write_text("Line 1\nLine 2\nLine 3\nLine 4\nLine 5") result = editor.open_file(str(temp_file_path)) @@ -183,9 +185,8 @@ def test_open_file(tmp_path): assert result.split("\n") == expected.split("\n") -def test_open_file_with_indentation(tmp_path): +def test_open_file_with_indentation(temp_file_path): editor = Editor() - temp_file_path = tmp_path / "a.txt" temp_file_path.write_text("Line 1\n Line 2\nLine 3\nLine 4\nLine 5") result = editor.open_file(str(temp_file_path)) @@ -203,9 +204,8 @@ def test_open_file_with_indentation(tmp_path): assert result.split("\n") == expected.split("\n") -def test_open_file_long(tmp_path): +def test_open_file_long(temp_file_path): editor = Editor() - temp_file_path = tmp_path / "a.txt" content = "\n".join([f"Line {i}" for i in range(1, 1001)]) temp_file_path.write_text(content) @@ -219,9 +219,8 @@ def test_open_file_long(tmp_path): assert result.split("\n") == expected.split("\n") -def test_open_file_long_with_lineno(tmp_path): +def test_open_file_long_with_lineno(temp_file_path): editor = Editor() - temp_file_path = tmp_path / "a.txt" content = "\n".join([f"Line {i}" for i in range(1, 1001)]) temp_file_path.write_text(content) @@ -250,18 +249,16 @@ def test_create_file_unexist_path(): editor.create_file("/unexist/path/a.txt") -def test_create_file(tmp_path): +def test_create_file(temp_file_path): editor = Editor() - temp_file_path = tmp_path / "a.txt" result = editor.create_file(str(temp_file_path)) expected = f"[File {temp_file_path} created.]" assert result.split("\n") == expected.split("\n") -def test_goto_line(tmp_path): +def test_goto_line(temp_file_path): editor = Editor() - temp_file_path = tmp_path / "a.txt" total_lines = 1000 content = "\n".join([f"Line {i}" for i in range(1, total_lines + 1)]) temp_file_path.write_text(content) @@ -296,9 +293,8 @@ def test_goto_line(tmp_path): assert result.split("\n") == expected.split("\n") -def test_goto_line_negative(tmp_path): +def test_goto_line_negative(temp_file_path): editor = Editor() - temp_file_path = tmp_path / "a.txt" content = "\n".join([f"Line {i}" for i in range(1, 5)]) temp_file_path.write_text(content) @@ -307,9 +303,8 @@ def test_goto_line_negative(tmp_path): editor.goto_line(-1) -def test_goto_line_out_of_bound(tmp_path): +def test_goto_line_out_of_bound(temp_file_path): editor = Editor() - temp_file_path = tmp_path / "a.txt" content = "\n".join([f"Line {i}" for i in range(1, 5)]) temp_file_path.write_text(content) @@ -318,9 +313,8 @@ def test_goto_line_out_of_bound(tmp_path): editor.goto_line(100) -def test_scroll_down(tmp_path): +def test_scroll_down(temp_file_path): editor = Editor() - temp_file_path = tmp_path / "a.txt" total_lines = 1000 content = "\n".join([f"Line {i}" for i in range(1, total_lines + 1)]) temp_file_path.write_text(content) @@ -360,9 +354,8 @@ def test_scroll_down(tmp_path): assert result.split("\n") == expected.split("\n") -def test_scroll_up(tmp_path): +def test_scroll_up(temp_file_path): editor = Editor() - temp_file_path = tmp_path / "a.txt" total_lines = 1000 content = "\n".join([f"Line {i}" for i in range(1, total_lines + 1)]) temp_file_path.write_text(content) @@ -405,9 +398,8 @@ def test_scroll_up(tmp_path): assert result.split("\n") == expected.split("\n") -def test_scroll_down_edge(tmp_path): +def test_scroll_down_edge(temp_file_path): editor = Editor() - temp_file_path = tmp_path / "a.txt" content = "\n".join([f"Line {i}" for i in range(1, 10)]) temp_file_path.write_text(content) @@ -426,36 +418,34 @@ def test_scroll_down_edge(tmp_path): assert result.split("\n") == expected.split("\n") -def test_print_window_internal(tmp_path): +def test_print_window_internal(temp_file_path): editor = Editor() - test_file_path = tmp_path / "a.txt" - editor.create_file(str(test_file_path)) - with open(test_file_path, "w") as file: + editor.create_file(str(temp_file_path)) + with open(temp_file_path, "w") as file: for i in range(1, 101): file.write(f"Line `{i}`\n") current_line = 50 window = 2 - result = editor._print_window(test_file_path, current_line, window) + result = editor._print_window(temp_file_path, current_line, window) expected = "(48 more lines above)\n" "49|Line `49`\n" "50|Line `50`\n" "51|Line `51`\n" "(49 more lines below)" assert result == expected -def test_open_file_large_line_number(tmp_path): +def test_open_file_large_line_number(temp_file_path): editor = Editor() - test_file_path = tmp_path / "a.txt" - editor.create_file(str(test_file_path)) - with open(test_file_path, "w") as file: + editor.create_file(str(temp_file_path)) + with open(temp_file_path, "w") as file: for i in range(1, 1000): file.write(f"Line `{i}`\n") current_line = 800 window = 100 - result = editor.open_file(str(test_file_path), current_line, window) + result = editor.open_file(str(temp_file_path), current_line, window) - expected = f"[File: {test_file_path} (999 lines total)]\n" + expected = f"[File: {temp_file_path} (999 lines total)]\n" expected += "(749 more lines above)\n" for i in range(750, 850 + 1): expected += f"{i}|Line `{i}`\n" @@ -463,21 +453,20 @@ def test_open_file_large_line_number(tmp_path): assert result == expected -def test_open_file_large_line_number_consecutive_diff_window(tmp_path): +def test_open_file_large_line_number_consecutive_diff_window(temp_file_path): editor = Editor() - test_file_path = tmp_path / "a.txt" - editor.create_file(str(test_file_path)) + editor.create_file(str(temp_file_path)) total_lines = 1000 - with open(test_file_path, "w") as file: + with open(temp_file_path, "w") as file: for i in range(1, total_lines + 1): file.write(f"Line `{i}`\n") current_line = 800 cur_window = 300 - result = editor.open_file(str(test_file_path), current_line, cur_window) + result = editor.open_file(str(temp_file_path), current_line, cur_window) - expected = f"[File: {test_file_path} ({total_lines} lines total)]\n" + expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" start, end = _calculate_window_bounds(current_line, total_lines, cur_window) if start == 1: expected += "(this is the beginning of the file)\n" @@ -495,7 +484,7 @@ def test_open_file_large_line_number_consecutive_diff_window(tmp_path): result = editor.scroll_up() - expected = f"[File: {test_file_path} ({total_lines} lines total)]\n" + expected = f"[File: {temp_file_path} ({total_lines} lines total)]\n" start, end = _calculate_window_bounds(current_line, total_lines, WINDOW) if start == 1: expected += "(this is the beginning of the file)\n" @@ -510,5 +499,116 @@ def test_open_file_large_line_number_consecutive_diff_window(tmp_path): assert result.split("\n") == expected.split("\n") +EXPECTED_CONTENT_AFTER_REPLACE_TEXT = """ +# this is line one +def test_function_for_fm(): + "some docstring" + a = 1 + b = 9 + c = 3 + # this is the 7th line +""".strip() + + +def test_edit_file_by_replace(temp_py_file): + editor = Editor() + editor.edit_file_by_replace(file_name=str(temp_py_file), to_replace=" b = 2", new_content=" b = 9") + with open(temp_py_file, "r") as f: + new_content = f.read() + assert new_content.strip() == EXPECTED_CONTENT_AFTER_REPLACE_TEXT.strip() + + +def test_search_dir(tmp_path): + editor = Editor() + dir_path = tmp_path / "test_dir" + dir_path.mkdir() + + # Create some files with specific content + (dir_path / "file1.txt").write_text("This is a test file with some content.") + (dir_path / "file2.txt").write_text("Another file with different content.") + sub_dir = dir_path / "sub_dir" + sub_dir.mkdir() + (sub_dir / "file3.txt").write_text("This file is inside a sub directory with some content.") + + search_term = "some content" + + result = editor.search_dir(search_term, str(dir_path)) + + assert "file1.txt" in result + assert "file3.txt" in result + assert "Another file with different content." not in result + + +def test_search_file(temp_file_path): + editor = Editor() + file_path = temp_file_path + file_path.write_text("This is a test file with some content.\nAnother line with more content.") + + search_term = "some content" + + result = editor.search_file(search_term, str(file_path)) + + assert "Line 1: This is a test file with some content." in result + assert "Line 2: Another line with more content." not in result + + +def test_find_file(tmp_path): + editor = Editor() + dir_path = tmp_path / "test_dir" + dir_path.mkdir() + + # Create some files with specific names + (dir_path / "file1.txt").write_text("Content of file 1.") + (dir_path / "file2.txt").write_text("Content of file 2.") + sub_dir = dir_path / "sub_dir" + sub_dir.mkdir() + (sub_dir / "file3.txt").write_text("Content of file 3.") + + file_name = "file1.txt" + + result = editor.find_file(file_name, str(dir_path)) + + assert "file1.txt" in result + assert "file2.txt" not in result + assert "file3.txt" not in result + + +# Test data for _append_impl method +TEST_LINES = ["First line\n", "Second line\n", "Third line\n"] + +NEW_CONTENT = "Appended line\n" + +EXPECTED_APPEND_NON_EMPTY_FILE = ["First line\n", "Second line\n", "Third line\n", "Appended line\n"] + +EXPECTED_APPEND_EMPTY_FILE = ["Appended line\n"] + + +def test_append_non_empty_file(): + editor = Editor() + lines = TEST_LINES.copy() + content, n_added_lines = editor._append_impl(lines, NEW_CONTENT) + + assert content.splitlines(keepends=True) == EXPECTED_APPEND_NON_EMPTY_FILE + assert n_added_lines == 1 + + +def test_append_empty_file(): + editor = Editor() + lines = [] + content, n_added_lines = editor._append_impl(lines, NEW_CONTENT) + + assert content.splitlines(keepends=True) == EXPECTED_APPEND_EMPTY_FILE + assert n_added_lines == 1 + + +def test_append_to_single_empty_line_file(): + editor = Editor() + lines = [""] + content, n_added_lines = editor._append_impl(lines, NEW_CONTENT) + + assert content.splitlines(keepends=True) == EXPECTED_APPEND_EMPTY_FILE + assert n_added_lines == 1 + + if __name__ == "__main__": pytest.main([__file__, "-s"]) From 08f7256b1f9488a8c4d997d421bdee6faa9af765 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Fri, 30 Aug 2024 17:56:24 +0800 Subject: [PATCH 09/25] update comment --- metagpt/roles/di/engineer2.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 8e1ffbff9..1714069da 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -5,7 +5,6 @@ from pathlib import Path from pydantic import Field -from metagpt.actions.write_code_review import ValidateAndRewriteCode from metagpt.logs import logger # from metagpt.actions.write_code_review import ValidateAndRewriteCode @@ -55,7 +54,7 @@ class Engineer2(RoleZero): async def _format_instruction(self): """ - Formats the instruction message for the SWE agent. + Formats the instruction message for the Engineer2. Runs the "state" command in the terminal, parses its output as JSON, and uses it to format the `_instruction` template. """ @@ -64,7 +63,6 @@ class Engineer2(RoleZero): self.cmd_prompt_current_state = CURRENT_BASH_STATE.format(**bash_state).strip() def _update_tool_execution(self): - ValidateAndRewriteCode() self.tool_execution_map.update( { "Bash.run": self.eval_terminal_run if self.run_eval else self.terminal.run, @@ -78,11 +76,11 @@ class Engineer2(RoleZero): async def eval_terminal_run(self, cmd): """change command pull/push/commit to end.""" if any([cmd_key_word in cmd for cmd_key_word in ["pull", "push", "commit"]]): - # The SWEAgent attempts to submit the repository after fixing the bug, thereby reaching the end of the fixing process. + # The Engineer2 attempts to submit the repository after fixing the bug, thereby reaching the end of the fixing process. # Set self.rc.todo to None to stop the engineer and then will trigger _save_git_diff funcion to save difference. - logger.info("SWEAgent use cmd:{cmd}") + logger.info("Engineer2 use cmd:{cmd}") logger.info("Current test case is finished.") - # stop the sweagent + # stop the Engineer2 self._set_state(-1) command_output = "Current test case is finished." else: @@ -117,7 +115,7 @@ class Engineer2(RoleZero): This function is specifically added for SWE bench evaluation. """ - # If todo switches to None, it indicates that this is the final round of reactions, and the Swe-Agent will stop. Use git diff to store any changes made. + # If todo switches to None, it indicates that this is the final round of reactions, and the Engineer2 will stop. Use git diff to store any changes made. if not self.rc.todo: print("finish current task *******************************************************") from metagpt.tools.swe_agent_commands.swe_agent_utils import extract_patch From 8ca577f22e4323f00d0b1d4db13c0826668268bb Mon Sep 17 00:00:00 2001 From: liushaojie Date: Fri, 30 Aug 2024 18:20:26 +0800 Subject: [PATCH 10/25] update: editor --- metagpt/roles/di/role_zero.py | 5 ++-- metagpt/roles/di/swe_agent.py | 13 ++------ metagpt/tools/libs/editor.py | 7 ++--- requirements.txt | 4 +-- tests/data/tools/test_script_for_editor.py | 0 tests/metagpt/tools/libs/test_editor.py | 35 ++++++++++++++++++++++ 6 files changed, 46 insertions(+), 18 deletions(-) delete mode 100644 tests/data/tools/test_script_for_editor.py diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index 81ce63539..378e9db2c 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -74,7 +74,7 @@ class RoleZero(Role): tools: list[str] = [] # Use special symbol [""] to indicate use of all registered tools tool_recommender: Optional[ToolRecommender] = None tool_execution_map: Annotated[dict[str, Callable], Field(exclude=True)] = {} - special_tool_commands: list[str] = ["Plan.finish_current_task", "end"] + special_tool_commands: list[str] = ["Plan.finish_current_task", "end", "Bash.run"] # Equipped with three basic tools by default for optional use editor: Editor = Editor() browser: Browser = Browser() @@ -144,11 +144,12 @@ class RoleZero(Role): "goto_line", "insert_content_at_line", "open_file", - "scroll_down", + "read" "scroll_down", "scroll_up", "search_dir", "search_file", "set_workdir", + "write", ] } ) diff --git a/metagpt/roles/di/swe_agent.py b/metagpt/roles/di/swe_agent.py index cd15fc5af..f147e80f3 100644 --- a/metagpt/roles/di/swe_agent.py +++ b/metagpt/roles/di/swe_agent.py @@ -11,7 +11,7 @@ from metagpt.prompts.di.swe_agent import ( from metagpt.roles.di.role_zero import RoleZero from metagpt.schema import Message from metagpt.tools.libs.git import git_create_pull -from metagpt.tools.libs.terminal import Bash +from metagpt.tools.libs.terminal import Terminal class SWEAgent(RoleZero): @@ -19,14 +19,8 @@ class SWEAgent(RoleZero): profile: str = "Issue Solver" goal: str = "Resolve GitHub issue or bug in any existing codebase" _instruction: str = NEXT_STEP_TEMPLATE - tools: list[str] = [ - # "Bash", - "Browser:goto,scroll", - "RoleZero", - "git_create_pull", - "Editor", - ] - terminal: Bash = Field(default_factory=Bash, exclude=True) + tools: list[str] = ["Browser:goto,scroll", "RoleZero", "git_create_pull", "Editor", "Terminal"] + terminal: Terminal = Field(default_factory=Terminal, exclude=True) output_diff: str = "" max_react_loop: int = 40 run_eval: bool = False @@ -39,7 +33,6 @@ class SWEAgent(RoleZero): def _update_tool_execution(self): self.tool_execution_map.update( { - "Bash.run": self.terminal.run, "git_create_pull": git_create_pull, } ) diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index 81f2bd4a7..8013b99c9 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -42,9 +42,8 @@ class LineNumberError(Exception): @register_tool() class Editor(BaseModel): """ - A state-of-state tool for open/reading, understanding, and editing/writing files. - Args: - working_dir: The working directory to use for the editor. + A tool for reading, understanding, writing, and editing files. + Support local file including text-based files (txt, md, json, py, html, js, css, etc.), pdf, docx, excluding images, csv, excel, or online links """ model_config = ConfigDict(arbitrary_types_allowed=True) @@ -281,7 +280,7 @@ class Editor(BaseModel): def set_workdir(self, path: str) -> None: """ Sets the working directory to the given path. eg: repo directory. - You need to set it up before operating the file. + You MUST to set it up before open the file. Args: path: str: The path to set as the working directory. diff --git a/requirements.txt b/requirements.txt index e669da46d..ed8965b46 100644 --- a/requirements.txt +++ b/requirements.txt @@ -74,5 +74,5 @@ pylint~=3.0.3 pygithub~=2.3 htmlmin fsspec -grep-ast~=0.3.3 -tree-sitter~=0.21.3 \ No newline at end of file +grep-ast~=0.3.3 # linter +tree-sitter~=0.21.3 # linter \ No newline at end of file diff --git a/tests/data/tools/test_script_for_editor.py b/tests/data/tools/test_script_for_editor.py deleted file mode 100644 index e69de29bb..000000000 diff --git a/tests/metagpt/tools/libs/test_editor.py b/tests/metagpt/tools/libs/test_editor.py index e2774ddc5..bcef2b74e 100644 --- a/tests/metagpt/tools/libs/test_editor.py +++ b/tests/metagpt/tools/libs/test_editor.py @@ -518,6 +518,41 @@ def test_edit_file_by_replace(temp_py_file): assert new_content.strip() == EXPECTED_CONTENT_AFTER_REPLACE_TEXT.strip() +def test_append_file(temp_file_path): + editor = Editor() + # 写入初始内容 + initial_content = "Line 1\nLine 2\nLine 3\n" + temp_file_path.write_text(initial_content) + + # 追加内容到文件 + append_content = "Line 4\nLine 5\n" + + result = editor.append_file(str(temp_file_path), append_content) + + # 预期内容 + expected_content = initial_content + append_content + + # 读取文件并断言内容与预期一致 + with open(temp_file_path, "r") as f: + new_content = f.read() + assert new_content == expected_content + + # 输出的预期结果 + expected_output = ( + f"[File: {temp_file_path.resolve()} (5 lines total after edit)]\n" + "(this is the beginning of the file)\n" + "1|Line 1\n" + "2|Line 2\n" + "3|Line 3\n" + "4|Line 4\n" + "5|Line 5\n" + "(this is the end of the file)\n" + "[File updated (edited at line 3). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]" + ) + + assert result.split("\n") == expected_output.split("\n") + + def test_search_dir(tmp_path): editor = Editor() dir_path = tmp_path / "test_dir" From d3f8664ddd80ce758d142af6cc7b1ff00d25cfae Mon Sep 17 00:00:00 2001 From: liushaojie Date: Fri, 30 Aug 2024 18:25:46 +0800 Subject: [PATCH 11/25] fix --- metagpt/roles/di/role_zero.py | 3 ++- metagpt/tools/libs/linter.py | 5 +++++ 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index 378e9db2c..94b21a76f 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -144,7 +144,8 @@ class RoleZero(Role): "goto_line", "insert_content_at_line", "open_file", - "read" "scroll_down", + "read", + "scroll_down", "scroll_up", "search_dir", "search_file", diff --git a/metagpt/tools/libs/linter.py b/metagpt/tools/libs/linter.py index 9f3ab7fd0..c8760a53b 100644 --- a/metagpt/tools/libs/linter.py +++ b/metagpt/tools/libs/linter.py @@ -1,3 +1,8 @@ +""" +This file is borrowed from OpenDevin +You can find the original repository here: +https://github.com/All-Hands-AI/OpenHands/blob/main/openhands/runtime/plugins/agent_skills/utils/aider/linter.py +""" import os import subprocess import sys From 7b11bfabc1118b77632dae7e0b69059882ed6a15 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Mon, 2 Sep 2024 14:49:35 +0800 Subject: [PATCH 12/25] Update the engineer's prompt to align with the editor's functionality --- metagpt/prompts/di/engineer2.py | 77 ++++++++++------------ metagpt/prompts/di/role_zero.py | 1 + metagpt/roles/di/engineer2.py | 18 +++--- metagpt/roles/di/swe_agent.py | 4 +- metagpt/strategy/experience_retriever.py | 81 +++++++++++++++--------- 5 files changed, 98 insertions(+), 83 deletions(-) diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index 0d95122a9..e0fe54972 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -5,32 +5,27 @@ You are an autonomous programmer The special interface consists of a file editor that shows you 100 lines of a file at a time. -You can use any bash commands you want (e.g., find, grep, cat, ls, cd) or any custom special tools (including `edit`) by calling Bash.run. -Edit all the files you need. +You can use any bash commands you want (e.g., find, grep, cat, ls, cd) by calling Bash.run. You should carefully observe the behavior and results of the previous action, and avoid triggering repeated errors. -However, the Bash.run does NOT support interactive session commands (e.g. python, vim), so please do not invoke them. - In addition to the terminal, I also provide additional tools. If provided an issue link, you MUST navigate to the issue page using Browser tool to understand the issue, before starting your fix. Your first action must be to check if the repository exists at the current path. If it exists, navigate to the repository path. If the repository doesn't exist, please download it and then navigate to it. All subsequent actions must be performed within this repository path. Do not leave this directory to execute any actions at any time. -Your terminal session has started, and you can use any bash commands or the special interface to help you. Edit all the files you need. Note: -1. If you open a file and need to get to an area around a specific line that is not in the first 100 lines, say line 583, don't just use the scroll_down command multiple times. Instead, use the goto 583 command. It's much quicker. -2. Always make sure to look at the currently open file and the current working directory (which appears right after the currently open file). The currently open file might be in a different directory than the working directory! Note that some commands, such as 'create', open files, so they might change the current open file. -3. When editing files, it is easy to accidentally specify a wrong line number or to write code with incorrect indentation. Always check the code after you issue an edit to make sure that it reflects what you wanted to accomplish. If it didn't, issue another command to fix it. +1. If you open a file and need to get to an area around a specific line that is not in the first 100 lines, say line 583, don't just use the scroll_down command multiple times. Instead, use the Editor.goto_line command. It's much quicker. +2. Always make sure to look at the currently open file and the current working directory (which appears right after the currently open file). The currently open file might be in a different directory than the working directory! Note that some commands, such as 'create', open files, so they might change the current open file. +3. When using Editor.edit_file_by_replace, if there is no exact match, take the difference in indentation into consideration. 4. After editing, verify the changes to ensure correct line numbers and proper indentation. Adhere to PEP8 standards for Python code. 5. NOTE ABOUT THE EDIT COMMAND: Indentation really matters! When editing a file, make sure to insert appropriate indentation before each line! Ensuring the code adheres to PEP8 standards. If a edit command fails, you can try to edit the file again to correct the indentation, but don't repeat the same command without changes. 6. YOU CAN ONLY ENTER ONE COMMAND AT A TIME and must wait for feedback, plan your commands carefully. -7. You cannot use any interactive session commands (e.g. python, vim) in this environment, but you can write scripts and run them. E.g. you can write a python script and then run it with `python .py`. -8. To avoid syntax errors when editing files multiple times, consider opening the file to view the surrounding code related to the error line and make modifications based on this context. -9. When using the `edit` command, remember it operates within a closed range. This is crucial to prevent accidental deletion of non-targeted code during code replacement. -10. Ensure to observe the currently open file and the current working directory, which is displayed right after the open file. The open file might be in a different directory than the working directory. Remember, commands like 'create' open files and might alter the current open file. -11. Effectively using Use search commands (`search_dir`, `search_file`, `find_file`) and navigation commands (`open`, `goto`) to locate and modify files efficiently. Follow these steps and considerations for optimal results: +7. To avoid syntax errors when editing files multiple times, consider opening the file to view the surrounding code related to the error line and make modifications based on this context. +8. When using the Editor tool, remember it operates within a closed range. This is crucial to prevent accidental deletion of non-targeted code during code replacement. +9. Ensure to observe the currently open file and the current working directory, which is displayed right after the open file. The open file might be in a different directory than the working directory. Remember, commands like 'create' open files and might alter the current open file. +10. Effectively using Use search commands (`search_dir`, `search_file`, `find_file`) and navigation commands (`open_file`, `goto_line`) to locate and modify files efficiently. The Editor tool can fully satisfy the requirements. Follow these steps and considerations for optimal results: **General Search Guidelines:** - Ensure you are in the repository's root directory before starting your search. - Always double-check the current working directory and the currently open file to avoid confusion. @@ -39,59 +34,53 @@ Note: **Strategies for Searching and Navigating Files:** 1. **If you know the file's location:** - - Use the `open` command directly to open the file. + - Use the `open_file` command directly to open the file. - Use `search_file` to find the `search_term` within the currently open file. - - Alternatively, use the `goto` command to jump to the specified line. + - Alternatively, use the `goto_line` command to jump to the specified line. - **Boundary Consideration:** Ensure the file path is correctly specified and accessible. 2. **If you know the filename but not the exact location:** - Use `find_file` to locate the file in the directory. - - Use `open` to open the file once located. + - Use `open_file` to open the file once located. - Use `search_file` to find the `search_term` within the file. - - Use `goto` to jump to the specified line if needed. + - Use `goto_line` to jump to the specified line if needed. - **Boundary Consideration:** Handle cases where the file may exist in multiple directories by verifying the correct path before opening. 3. **If you know the symbol but not the file's location:** - - Use `search_dir_and_preview` to find files containing the symbol within the directory. + - Use "search_dir" to find files containing the symbol within the directory. - Review the search results to identify the relevant file(s). - - Use `open` to open the identified file. + - Use `open_file` to open the identified file. - Use `search_file` to locate the `search_term` within the open file. - - Use `goto` to jump to the specified line. + - Use `goto_line` to jump to the specified line. - **Boundary Consideration:** Be thorough in reviewing multiple search results to ensure you open the correct file. Consider using more specific search terms if initial searches return too many results. **Search Tips:** - - The `` for `search_dir_and_preview`, `find_file`, or `search_file` should be an existing class name, function name, or file name. - - Enclose terms like `def` or `class` in quotes when searching for functions or classes (e.g., `search_dir_and_preview 'def apow'` or `search_file 'class Pow'`). + - The `` for `search_dir`, `find_file`, or `search_file` should be an existing class name, function name, or file name. + - Enclose terms like `def` or `class` in quotes when searching for functions or classes (e.g., `search_dir 'def apow'` or `search_file 'class Pow'`). - Use wildcard characters (`*`, `?`) in search terms to broaden or narrow down your search scope. - If search commands return too many results, refine your search criteria or use more specific terms. - If a search command fails, modify the search criteria and check for typos or incorrect paths, then try again. - Based on feedback of observation or bash command in trajectory to guide adjustments in your search strategy. -12. Save the code change: - - If you need to submit changes to the remote repository, first use the regular git commit command to save the changes locally, then use git push for pushing, and if requested, `git_create_pull` in Available Commands for creating pull request. - - If you don't need to submit code changes to the remote repository. use the command Bash.run('submit') to commit the changes locally. -13. If provided an issue link, you MUST go to the issue page using Browser tool to understand the issue before starting your fix. -14. When the edit fails, try to enlarge the starting line. -18. You must use the Bash.run tool's open command to open a file before using the Bash.run tool's edit command to modify it. When you open a file, any currently open file will be automatically closed. -17. The 'Bash.run tool's edit command' and 'open' command can only be used once in a single response. If there are multiple places in the code that need modification, list all of them but only modify the first unmodified location. -18. Do not use the Bash.run tool's edit command when there is a same command in the response. Because when a edit command has completed, the line number in the file will be changed. -19. If the code file is created by you. DO NOT use command 'submit'. -20. When you use the 'Bash.run tool's edit command', pay attention to this: the start_line number and the end_line number must be an odd number and the line must be empty line. For exampe ""edit 30:30" if forbidden and "edit 29:31" is suit. +11. If provided an issue link, you MUST go to the issue page using Browser tool to understand the issue before starting your fix. +12. When the edit fails, try to enlarge the starting line. +13. You must use the Editor.open_file command to open a file before using the Bash.run tool's edit command to modify it. When you open a file, any currently open file will be automatically closed. +14. The Editor command can only be used once in a single response. If there are multiple places in the code that need modification, list all of them but only modify the first unmodified location. +15. Remember, when you use Editor.insert_content_at_line or Editor.edit_file_by_replace, the line numbers will change after the operation. Therefore, if there are multiple operations, perform only the first operation in the current response, and defer the subsequent operations to the next turn. +16. If you choose Editor.insert_content_at_line, you must ensure that there is no duplication between the inserted content and the original code. If there is overlap between the new code and the original code, use Editor.edit_file_by_replace instead. +17. If you choose Editor.edit_file_by_replace, the original code that needs to be replaced must start at the beginning of the line and end at the end of the line -17. When not specified, you should write files in a folder named "src". If you know the project path, then write in a "src" folder under the project path. -18. When provided system design or project schedule, you MUST read them first before making a plan, then adhere to them in your implementation, especially in the programming language, package, or framework. You MUST implement all code files prescribed in the system design or project schedule. You can create a plan first with each task corresponding to implementing one code file. -19. When planning, initially list the files for coding, then outline all coding and review tasks in your first response. -20. If you plan to read a file, do not include other plans in the same response. -21. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. -22. When the requirement is simple, you don't need to create a plan, just do it right away. -23. If the code exists, use the Bash.run tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. -24. Aways user absolute path as parameter. if no specific root path given, use "workspace/'project_name'" as default work space. +18. When not specified, you should write files in a folder named "src". If you know the project path, then write in a "src" folder under the project path. +19. When provided system design or project schedule, you MUST read them first before making a plan, then adhere to them in your implementation, especially in the programming language, package, or framework. You MUST implement all code files prescribed in the system design or project schedule. You can create a plan first with each task corresponding to implementing one code file. +20. When planning, initially list the files for coding, then outline all coding and review tasks in your first response. +21. If you plan to read a file, do not include other plans in the same response. +22. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. +23. When the requirement is simple, you don't need to create a plan, just do it right away. +24. If the code exists, use the Bash.run tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. +25. Aways user absolute path as parameter. if no specific root path given, use "workspace/'project_name'" as default work space. """ -""" -Do not attempt to modify all of them in one response. -""" -CURRENT_BASH_STATE = """ +CURRENT_EDITOR_STATE = """ # Output Next Step The current bash state is: (Open file: {open_file}) diff --git a/metagpt/prompts/di/role_zero.py b/metagpt/prompts/di/role_zero.py index 3356ab1c0..271e1af82 100644 --- a/metagpt/prompts/di/role_zero.py +++ b/metagpt/prompts/di/role_zero.py @@ -103,6 +103,7 @@ Fifth, describe if you should terminate, you should use **end** command to termi REGENERATE_PROMPT = """ Review and reflect on the history carefully, provide a different response. Describe if you should terminate using **end** command, or use **RoleZero.ask_human** to ask human for help, or try a different approach and output different commands. You are NOT allowed to provide the same commands again. +ou should use "end" to stop when all tasks have been completed and the requirements are satisfied. Your reflection, then the commands in a json array: """ ASK_HUMAN_COMMAND = """ diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 1714069da..c8f33b564 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -1,6 +1,5 @@ from __future__ import annotations -import json from pathlib import Path from pydantic import Field @@ -9,11 +8,12 @@ from metagpt.logs import logger # from metagpt.actions.write_code_review import ValidateAndRewriteCode from metagpt.prompts.di.engineer2 import ( - CURRENT_BASH_STATE, + CURRENT_EDITOR_STATE, ENGINEER2_INSTRUCTION, WRITE_CODE_PROMPT, WRITE_CODE_SYSTEM_PROMPT, ) +from metagpt.prompts.di.role_zero import CMD_PROMPT from metagpt.roles.di.role_zero import RoleZero from metagpt.schema import Message, UserMessage from metagpt.strategy.experience_retriever import ENGINEER_EXAMPLE @@ -30,14 +30,17 @@ class Engineer2(RoleZero): profile: str = "Engineer" goal: str = "Take on game, app, and web development." instruction: str = ENGINEER2_INSTRUCTION - + cmd_prompt: str = ( + CMD_PROMPT + + "\nWhen using the Editor tool, the command list must contain a single command. Because the command is mutually exclusive." + ) terminal: Terminal = Field(default_factory=Bash, exclude=True) tools: list[str] = [ "Plan", - "Editor:read", + "Editor", "RoleZero", - "Bash", + "Terminal", "Browser:goto,scroll", "git_create_pull", "Engineer2", @@ -58,9 +61,8 @@ class Engineer2(RoleZero): Runs the "state" command in the terminal, parses its output as JSON, and uses it to format the `_instruction` template. """ - state_output = await self.terminal.run("state") - bash_state = json.loads(state_output) - self.cmd_prompt_current_state = CURRENT_BASH_STATE.format(**bash_state).strip() + editor_state = {"open_file": self.editor.current_file, "working_dir": self.editor.working_dir} + self.cmd_prompt_current_state = CURRENT_EDITOR_STATE.format(**editor_state).strip() def _update_tool_execution(self): self.tool_execution_map.update( diff --git a/metagpt/roles/di/swe_agent.py b/metagpt/roles/di/swe_agent.py index 537995d9f..3d3ce4b57 100644 --- a/metagpt/roles/di/swe_agent.py +++ b/metagpt/roles/di/swe_agent.py @@ -33,7 +33,7 @@ class SWEAgent(RoleZero): def _update_tool_execution(self): self.tool_execution_map.update( { - "Bash.run": self.eval_terminal_run if self.run_eval else self.terminal.run, + "Terminal.run_command": self.eval_terminal_run if self.run_eval else self.terminal.run_command, "git_create_pull": git_create_pull, } ) @@ -49,7 +49,7 @@ class SWEAgent(RoleZero): self._set_state(-1) command_output = "Current test case is finished." else: - command_output = await self.terminal.run(cmd) + command_output = await self.terminal.run_command(cmd) return command_output async def _format_instruction(self): diff --git a/metagpt/strategy/experience_retriever.py b/metagpt/strategy/experience_retriever.py index 0977dc19f..9964d472b 100644 --- a/metagpt/strategy/experience_retriever.py +++ b/metagpt/strategy/experience_retriever.py @@ -902,7 +902,7 @@ Explanation: Take on one task, such as writing a file. Upon completion, finish c ] ``` -## example 5 +## example 4 I have received a GitHub issue URL. I will use browser to review the detailed information of this issue in order to understand the problem. ```json @@ -915,7 +915,8 @@ I will use browser to review the detailed information of this issue in order to } ] ``` -## example 6 + +## example 5 I need to locating the `openai_api.py` file, so I will search for the `openai_api.py` file. ```json [ @@ -928,44 +929,71 @@ I need to locating the `openai_api.py` file, so I will search for the `openai_ap ] ``` +## example 6 +The target working directory is "/workspace/MetaGPT/provider/", but the current working directory is different. I will use the set_workdir command to change the working directory. +```json +[ + { + "command_name": "Editor.set_workdir", + "args": { + "path": "/workspace/MetaGPT/provider" + } + } +] +``` + ## example 7 I have located the openai_api.py file. I want to edit this file, so I will open it first. ```json [ { - "command_name": "Bash.run", + "command_name": "Editor.open_file", "args": { - "cmd": "open '/workspace/MetaGPT/provider/openai_api.py'" + "path": "/workspace/MetaGPT/provider/openai_api.py" } } ] ``` ## example 8 -I've found the bug and will start fixing it. I'll pay close attention to the indentation. -Since I only need to modify a few lines in this file, I will use the Bash.run tool with the edit command. -Note that the edit command must be executed in a single response, so this step will only involve using the edit command. +I have opened the openai_api.py file. However, the range of lines shown is from 001 to 100, and I want to see more. Therefore, I want to use the scroll_down command to view additional lines. ```json [ { - "command_name": "Bash.run", - "args": { - "cmd": "edit 93:95 < Date: Mon, 2 Sep 2024 19:11:23 +0800 Subject: [PATCH 13/25] Update the prompt to include the editor and terminal state --- metagpt/prompts/di/engineer2.py | 44 +++++++++---------- metagpt/prompts/di/role_zero.py | 2 +- metagpt/roles/di/engineer2.py | 43 +++++++++++++----- metagpt/strategy/experience_retriever.py | 36 ++++++++------- metagpt/tools/libs/editor.py | 2 +- .../roles/di/run_swe_agent_for_benchmark.py | 22 ++++++---- 6 files changed, 89 insertions(+), 60 deletions(-) diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index e0fe54972..9b8af137e 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -5,7 +5,7 @@ You are an autonomous programmer The special interface consists of a file editor that shows you 100 lines of a file at a time. -You can use any bash commands you want (e.g., find, grep, cat, ls, cd) by calling Bash.run. +You can use any terminal commands you want (e.g., find, grep, cat, ls, cd) by calling Terminal.run_command. You should carefully observe the behavior and results of the previous action, and avoid triggering repeated errors. @@ -23,9 +23,8 @@ Note: 5. NOTE ABOUT THE EDIT COMMAND: Indentation really matters! When editing a file, make sure to insert appropriate indentation before each line! Ensuring the code adheres to PEP8 standards. If a edit command fails, you can try to edit the file again to correct the indentation, but don't repeat the same command without changes. 6. YOU CAN ONLY ENTER ONE COMMAND AT A TIME and must wait for feedback, plan your commands carefully. 7. To avoid syntax errors when editing files multiple times, consider opening the file to view the surrounding code related to the error line and make modifications based on this context. -8. When using the Editor tool, remember it operates within a closed range. This is crucial to prevent accidental deletion of non-targeted code during code replacement. -9. Ensure to observe the currently open file and the current working directory, which is displayed right after the open file. The open file might be in a different directory than the working directory. Remember, commands like 'create' open files and might alter the current open file. -10. Effectively using Use search commands (`search_dir`, `search_file`, `find_file`) and navigation commands (`open_file`, `goto_line`) to locate and modify files efficiently. The Editor tool can fully satisfy the requirements. Follow these steps and considerations for optimal results: +8. Ensure to observe the currently open file and the current working directory, which is displayed right after the open file. The open file might be in a different directory than the working directory. Remember, commands like 'create' open files and might alter the current open file. +9. Effectively using Use search commands (`search_dir`, `search_file`, `find_file`) and navigation commands (`open_file`, `goto_line`) to locate and modify files efficiently. The Editor tool can fully satisfy the requirements. Follow these steps and considerations for optimal results: **General Search Guidelines:** - Ensure you are in the repository's root directory before starting your search. - Always double-check the current working directory and the currently open file to avoid confusion. @@ -60,33 +59,34 @@ Note: - Use wildcard characters (`*`, `?`) in search terms to broaden or narrow down your search scope. - If search commands return too many results, refine your search criteria or use more specific terms. - If a search command fails, modify the search criteria and check for typos or incorrect paths, then try again. - - Based on feedback of observation or bash command in trajectory to guide adjustments in your search strategy. + - Based on feedback of observation or Terminal command in trajectory to guide adjustments in your search strategy. -11. If provided an issue link, you MUST go to the issue page using Browser tool to understand the issue before starting your fix. -12. When the edit fails, try to enlarge the starting line. -13. You must use the Editor.open_file command to open a file before using the Bash.run tool's edit command to modify it. When you open a file, any currently open file will be automatically closed. -14. The Editor command can only be used once in a single response. If there are multiple places in the code that need modification, list all of them but only modify the first unmodified location. -15. Remember, when you use Editor.insert_content_at_line or Editor.edit_file_by_replace, the line numbers will change after the operation. Therefore, if there are multiple operations, perform only the first operation in the current response, and defer the subsequent operations to the next turn. -16. If you choose Editor.insert_content_at_line, you must ensure that there is no duplication between the inserted content and the original code. If there is overlap between the new code and the original code, use Editor.edit_file_by_replace instead. -17. If you choose Editor.edit_file_by_replace, the original code that needs to be replaced must start at the beginning of the line and end at the end of the line +10. When the edit fails, try to enlarge the starting line. +11. You must use the Editor.open_file command to open a file before using the Editor tool's edit command to modify it. When you open a file, any currently open file will be automatically closed. +12. Remember, when you use Editor.insert_content_at_line or Editor.edit_file_by_replace, the line numbers will change after the operation. Therefore, if there are multiple operations, perform only the first operation in the current response, and defer the subsequent operations to the next turn. +13. If you choose Editor.insert_content_at_line, you must ensure that there is no duplication between the inserted content and the original code. If there is overlap between the new code and the original code, use Editor.edit_file_by_replace instead. +14. If you choose Editor.edit_file_by_replace, the original code that needs to be replaced must start at the beginning of the line and end at the end of the line -18. When not specified, you should write files in a folder named "src". If you know the project path, then write in a "src" folder under the project path. -19. When provided system design or project schedule, you MUST read them first before making a plan, then adhere to them in your implementation, especially in the programming language, package, or framework. You MUST implement all code files prescribed in the system design or project schedule. You can create a plan first with each task corresponding to implementing one code file. -20. When planning, initially list the files for coding, then outline all coding and review tasks in your first response. -21. If you plan to read a file, do not include other plans in the same response. -22. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. -23. When the requirement is simple, you don't need to create a plan, just do it right away. -24. If the code exists, use the Bash.run tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. -25. Aways user absolute path as parameter. if no specific root path given, use "workspace/'project_name'" as default work space. +15. When not specified, you should write files in a folder named "src". If you know the project path, then write in a "src" folder under the project path. +16. When provided system design or project schedule, you MUST read them first before making a plan, then adhere to them in your implementation, especially in the programming language, package, or framework. You MUST implement all code files prescribed in the system design or project schedule. You can create a plan first with each task corresponding to implementing one code file. +17. When planning, initially list the files for coding, then outline all coding and review tasks in your first response. +18. If you plan to read a file, do not include other plans in the same response. +19. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. +20. When the requirement is simple, you don't need to create a plan, just do it right away. +21. If the code exists, use the Editor tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. +22. Aways user absolute path as parameter. if no specific root path given, use "workspace/'project_name'" as default work space. """ CURRENT_EDITOR_STATE = """ -# Output Next Step -The current bash state is: +The current editor state is: (Open file: {open_file}) (Current directory: {working_dir}) """ +CURRENT_TERMINAL_STATE = """ +The current terminal state is: +(Current directory: {working_dir}) +""" ENGINEER2_INSTRUCTION = ROLE_INSTRUCTION + EXTRA_INSTRUCTION.strip() WRITE_CODE_SYSTEM_PROMPT = """ diff --git a/metagpt/prompts/di/role_zero.py b/metagpt/prompts/di/role_zero.py index 271e1af82..956f26834 100644 --- a/metagpt/prompts/di/role_zero.py +++ b/metagpt/prompts/di/role_zero.py @@ -103,7 +103,7 @@ Fifth, describe if you should terminate, you should use **end** command to termi REGENERATE_PROMPT = """ Review and reflect on the history carefully, provide a different response. Describe if you should terminate using **end** command, or use **RoleZero.ask_human** to ask human for help, or try a different approach and output different commands. You are NOT allowed to provide the same commands again. -ou should use "end" to stop when all tasks have been completed and the requirements are satisfied. +You should use "end" to stop when all tasks have been completed and the requirements are satisfied. Your reflection, then the commands in a json array: """ ASK_HUMAN_COMMAND = """ diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index c8f33b564..f63868100 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -1,14 +1,17 @@ from __future__ import annotations +import os from pathlib import Path from pydantic import Field +from metagpt.config2 import Config from metagpt.logs import logger # from metagpt.actions.write_code_review import ValidateAndRewriteCode from metagpt.prompts.di.engineer2 import ( CURRENT_EDITOR_STATE, + CURRENT_TERMINAL_STATE, ENGINEER2_INSTRUCTION, WRITE_CODE_PROMPT, WRITE_CODE_SYSTEM_PROMPT, @@ -18,7 +21,7 @@ from metagpt.roles.di.role_zero import RoleZero from metagpt.schema import Message, UserMessage from metagpt.strategy.experience_retriever import ENGINEER_EXAMPLE from metagpt.tools.libs.git import git_create_pull -from metagpt.tools.libs.terminal import Bash, Terminal +from metagpt.tools.libs.terminal import Terminal from metagpt.tools.tool_registry import register_tool from metagpt.utils.common import CodeParser, awrite from metagpt.utils.report import EditorReporter @@ -34,7 +37,7 @@ class Engineer2(RoleZero): CMD_PROMPT + "\nWhen using the Editor tool, the command list must contain a single command. Because the command is mutually exclusive." ) - terminal: Terminal = Field(default_factory=Bash, exclude=True) + terminal: Terminal = Field(default_factory=Terminal, exclude=True) tools: list[str] = [ "Plan", @@ -58,16 +61,19 @@ class Engineer2(RoleZero): async def _format_instruction(self): """ Formats the instruction message for the Engineer2. - Runs the "state" command in the terminal, parses its output as JSON, - and uses it to format the `_instruction` template. + Uses Editor's state to format the `_instruction` template. """ + bash_working_dir = await self.terminal.run_command("pwd") + bash_state = {"working_dir": bash_working_dir} editor_state = {"open_file": self.editor.current_file, "working_dir": self.editor.working_dir} - self.cmd_prompt_current_state = CURRENT_EDITOR_STATE.format(**editor_state).strip() + self.cmd_prompt_current_state = CURRENT_EDITOR_STATE.format( + **editor_state + ).strip() + CURRENT_TERMINAL_STATE.format(**bash_state) def _update_tool_execution(self): self.tool_execution_map.update( { - "Bash.run": self.eval_terminal_run if self.run_eval else self.terminal.run, + "Terminal.run_command": self.eval_terminal_run if self.run_eval else self.terminal.run_command, "git_create_pull": git_create_pull, "Engineer2.write_new_code": self.write_new_code, # "ValidateAndRewriteCode.run": validate.run, @@ -86,7 +92,7 @@ class Engineer2(RoleZero): self._set_state(-1) command_output = "Current test case is finished." else: - command_output = await self.terminal.run(cmd) + command_output = await self.terminal.run_command(cmd) return command_output async def _act(self) -> Message: @@ -112,19 +118,19 @@ class Engineer2(RoleZero): """ Handles actions based on parsed commands. - Parses commands, checks for a "submit" action, and generates a patch using `git diff`. + When detecting engineer2 at the final action round, the process will stop immediately. + generates a patch using `git diff`. Stores the cleaned patch in `output_diff`. Logs any exceptions. This function is specifically added for SWE bench evaluation. """ # If todo switches to None, it indicates that this is the final round of reactions, and the Engineer2 will stop. Use git diff to store any changes made. if not self.rc.todo: - print("finish current task *******************************************************") from metagpt.tools.swe_agent_commands.swe_agent_utils import extract_patch try: - logger.info(await self.terminal.run("submit")) - diff_output = await self.terminal.run("git diff --cached") + logger.info(await self.submit()) + diff_output = await self.terminal.run_command("git diff --cached") clear_diff = extract_patch(diff_output) logger.info(f"Diff output: \n{clear_diff}") if clear_diff: @@ -132,9 +138,22 @@ class Engineer2(RoleZero): except Exception as e: logger.error(f"Error during submission: {e}") + async def submit(self): + if "SWE_CMD_WORK_DIR" not in os.environ: + os.environ["SWE_CMD_WORK_DIR"] = str(Config.default().workspace.path) + if os.path.exists(os.environ["SWE_CMD_WORK_DIR"] + "/test.patch"): + await self.terminal.run_command('git apply -R < "$SWE_CMD_WORK_DIR/test.patch"') + cmd = """ + git add -A + echo "<>" + """ + diff_output = await self.terminal.run_command(cmd) + return diff_output + async def write_new_code(self, path: str, instruction: str = "") -> str: """Write a new code file. - When used, make sure content arg contains the full content of the file. Args: path (str): The absolute path of the file to be created. instruction (optional, str): Further hints or notice other than the current task instruction, must be very concise and can be empty. Defaults to "". diff --git a/metagpt/strategy/experience_retriever.py b/metagpt/strategy/experience_retriever.py index 9964d472b..a6fe44368 100644 --- a/metagpt/strategy/experience_retriever.py +++ b/metagpt/strategy/experience_retriever.py @@ -917,19 +917,6 @@ I will use browser to review the detailed information of this issue in order to ``` ## example 5 -I need to locating the `openai_api.py` file, so I will search for the `openai_api.py` file. -```json -[ - { - "command_name": "Bash.run", - "args": { - "cmd": "find_file 'openai_api.py'" - } - } -] -``` - -## example 6 The target working directory is "/workspace/MetaGPT/provider/", but the current working directory is different. I will use the set_workdir command to change the working directory. ```json [ @@ -942,6 +929,21 @@ The target working directory is "/workspace/MetaGPT/provider/", but the current ] ``` +## example 6 +I need to locating the `openai_api.py` file, so I will search for the `openai_api.py` file. +```json +[ + { + "command_name": "Editor.find_file", + "args": { + "file_name": "openai_api.py" + } + } +] +``` + + + ## example 7 I have located the openai_api.py file. I want to edit this file, so I will open it first. ```json @@ -999,6 +1001,7 @@ Note that the edit command must be executed in a single response, so this step w ] ``` +## example 11 ``` #### Save the changes and commit them to the remote repository. @@ -1007,7 +1010,7 @@ Thought: All changes have been saved, let's push the code to the remote reposito ```json [ { - "command_name": "Bash.run", + "command_name": "Terminal.run_command", "args": { "cmd": "git push origin test-fix" } @@ -1034,9 +1037,11 @@ Thought: Now that the changes have been pushed to the remote repository, due to } ] ``` +""" +""" ## example 11 -I have finish all task, so I will use 'Plan.finish_current_task' and then fellowing the command "end" to stop. +I have finish all the tasks, so I will use 'Plan.finish_current_task' and then fellowing the command "end" to stop. ```json [ { @@ -1052,7 +1057,6 @@ I have finish all task, so I will use 'Plan.finish_current_task' and then fellow ] ``` """ - WEB_SCRAPING_EXAMPLE = """ ## action 1 User Requirement: Scrap and list the restaurant names of first page by searching for the keyword `beef` on the website https://www.yelp.com/. diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index 8013b99c9..46f0d5c0d 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -640,7 +640,7 @@ class Editor(BaseModel): ret_str += "[This is how your edit would have looked if applied]\n" ret_str += "-------------------------------------------------\n" - ret_str += self._print_window(file_name, show_line, editor_lines, return_str=True) + "\n" + ret_str += self._print_window(file_name, show_line, editor_lines) + "\n" ret_str += "-------------------------------------------------\n\n" ret_str += "[This is the original code before your edit]\n" diff --git a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py index 2bac9b823..72625c149 100644 --- a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py +++ b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py @@ -9,13 +9,14 @@ from metagpt.config2 import Config from metagpt.const import DEFAULT_WORKSPACE_ROOT, METAGPT_ROOT from metagpt.logs import logger from metagpt.roles.di.engineer2 import Engineer2 +from metagpt.tools.libs.editor import Editor from metagpt.tools.libs.terminal import Terminal from metagpt.tools.swe_agent_commands.swe_agent_utils import load_hf_dataset config = Config.default() # Specify by yourself Role = Engineer2 -MAX_MINUTES_PRE_INSTANCE = 20 +MAX_MINUTES_PRE_INSTANCE = 5 TEST_REPO_DIR = METAGPT_ROOT / "data" / "test_repo" DATA_DIR = METAGPT_ROOT / "data/hugging_face" @@ -75,8 +76,10 @@ async def run(instance, swe_result_dir): clone_command = f"git clone 'https://github.com/{repo_identifier}.git' {repo_path}" checkout_command = f"cd {repo_path} && git checkout -f {base_commit}" if base_commit else "" await terminal.run_command(clone_command) + ignore_temp_file_cmd = "echo '.backup.*' >> .gitignore" logger.info(await terminal.run_command(checkout_command)) logger.info(await terminal.run_command("git branch")) + await terminal.run_command(ignore_temp_file_cmd) user_requirement_and_issue = INSTANCE_TEMPLATE.format( issue=instance["problem_statement"], @@ -89,9 +92,10 @@ async def run(instance, swe_result_dir): logger.info(f"**** Starting to run {instance['instance_id']}****") logger.info("User Requirement", user_requirement_and_issue) try: - role = Role(run_eval=True) + role = Role(run_eval=True, editor=Editor(enable_auto_lint=True)) await asyncio.wait_for(role.run(user_requirement_and_issue), timeout=MAX_MINUTES_PRE_INSTANCE * 60) - except: + except Exception as e: + print(e) logger.info(f"**** exception lead to end: {instance['instance_id']}****") pass @@ -103,7 +107,7 @@ def save_predictions(role, instance, swe_result_dir): output_file = swe_result_dir / "all_preds.jsonl" instance["model_name_or_path"] = role.config.llm.model instance["model_patch"] = role.output_diff - logger.info("model_patch", role.output_diff) + logger.info("model_patch:" + role.output_diff) logger.info(f"Preparing to save predictions to {output_file}") # Save the predictions to a JSONL file @@ -122,12 +126,14 @@ async def async_main(): exp_name = f"nano_mgx_{date_time}_{_round}" - # now = datetime.now() - # formatted_time = now.strftime("%Y_%m_%d_%H_%M_%S") + now = datetime.now() + formatted_time = now.strftime("%Y_%m_%d_%H_%M_%S") + swe_result_dir = ( + DEFAULT_WORKSPACE_ROOT / f"result_{config.llm.model.replace('/', '_')}_start_time_{formatted_time}" / exp_name + ) # swe_result_dir = ( - # DEFAULT_WORKSPACE_ROOT / f"result_{config.llm.model.replace('/', '_')}_start_time_{formatted_time}" / exp_name + # DEFAULT_WORKSPACE_ROOT / f"result_{config.llm.model.replace('/', '_')}" / exp_name # ) - swe_result_dir = DEFAULT_WORKSPACE_ROOT / f"result_{config.llm.model.replace('/', '_')}" / exp_name swe_result_dir.mkdir(parents=True, exist_ok=True) for index, instance in enumerate(dataset): # switch to a new logger file From 68c10d05b0e8fe131dd60feffdca4b4004bba0b7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Mon, 2 Sep 2024 19:33:59 +0800 Subject: [PATCH 14/25] update engineer2 cmd prompt --- metagpt/prompts/di/engineer2.py | 6 +++++- metagpt/roles/di/engineer2.py | 7 ++----- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index 9b8af137e..559bb89e4 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -1,4 +1,4 @@ -from metagpt.prompts.di.role_zero import ROLE_INSTRUCTION +from metagpt.prompts.di.role_zero import CMD_PROMPT, ROLE_INSTRUCTION EXTRA_INSTRUCTION = """ You are an autonomous programmer @@ -76,6 +76,10 @@ Note: 21. If the code exists, use the Editor tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. 22. Aways user absolute path as parameter. if no specific root path given, use "workspace/'project_name'" as default work space. """ +ENGINEER2_CMD_PROMPT = ( + CMD_PROMPT + + "\nWhen using the Editor tool, the command list must contain a single command. Because the command is mutually exclusive." +) CURRENT_EDITOR_STATE = """ The current editor state is: diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index f63868100..56d520b36 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -12,11 +12,11 @@ from metagpt.logs import logger from metagpt.prompts.di.engineer2 import ( CURRENT_EDITOR_STATE, CURRENT_TERMINAL_STATE, + ENGINEER2_CMD_PROMPT, ENGINEER2_INSTRUCTION, WRITE_CODE_PROMPT, WRITE_CODE_SYSTEM_PROMPT, ) -from metagpt.prompts.di.role_zero import CMD_PROMPT from metagpt.roles.di.role_zero import RoleZero from metagpt.schema import Message, UserMessage from metagpt.strategy.experience_retriever import ENGINEER_EXAMPLE @@ -33,10 +33,7 @@ class Engineer2(RoleZero): profile: str = "Engineer" goal: str = "Take on game, app, and web development." instruction: str = ENGINEER2_INSTRUCTION - cmd_prompt: str = ( - CMD_PROMPT - + "\nWhen using the Editor tool, the command list must contain a single command. Because the command is mutually exclusive." - ) + cmd_prompt: str = ENGINEER2_CMD_PROMPT terminal: Terminal = Field(default_factory=Terminal, exclude=True) tools: list[str] = [ From 4ddabd0f1b17f9b6622fdd6cd370c0da92360dc2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Tue, 3 Sep 2024 22:14:02 +0800 Subject: [PATCH 15/25] guide engineer2 to use editor --- metagpt/prompts/di/engineer2.py | 39 ++++++++-------- metagpt/roles/di/role_zero.py | 57 +++++++++++++++--------- metagpt/strategy/experience_retriever.py | 15 +------ metagpt/tools/libs/editor.py | 27 ++++++++++- 4 files changed, 83 insertions(+), 55 deletions(-) diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index 559bb89e4..c597146a5 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -21,10 +21,9 @@ Note: 3. When using Editor.edit_file_by_replace, if there is no exact match, take the difference in indentation into consideration. 4. After editing, verify the changes to ensure correct line numbers and proper indentation. Adhere to PEP8 standards for Python code. 5. NOTE ABOUT THE EDIT COMMAND: Indentation really matters! When editing a file, make sure to insert appropriate indentation before each line! Ensuring the code adheres to PEP8 standards. If a edit command fails, you can try to edit the file again to correct the indentation, but don't repeat the same command without changes. -6. YOU CAN ONLY ENTER ONE COMMAND AT A TIME and must wait for feedback, plan your commands carefully. -7. To avoid syntax errors when editing files multiple times, consider opening the file to view the surrounding code related to the error line and make modifications based on this context. -8. Ensure to observe the currently open file and the current working directory, which is displayed right after the open file. The open file might be in a different directory than the working directory. Remember, commands like 'create' open files and might alter the current open file. -9. Effectively using Use search commands (`search_dir`, `search_file`, `find_file`) and navigation commands (`open_file`, `goto_line`) to locate and modify files efficiently. The Editor tool can fully satisfy the requirements. Follow these steps and considerations for optimal results: +6. To avoid syntax errors when editing files multiple times, consider opening the file to view the surrounding code related to the error line and make modifications based on this context. +7. Ensure to observe the currently open file and the current working directory, which is displayed right after the open file. The open file might be in a different directory than the working directory. Remember, commands like 'create' open files and might alter the current open file. +8. Effectively using Use search commands (`search_dir`, `search_file`, `find_file`) and navigation commands (`open_file`, `goto_line`) to locate and modify files efficiently. The Editor tool can fully satisfy the requirements. Follow these steps and considerations for optimal results: **General Search Guidelines:** - Ensure you are in the repository's root directory before starting your search. - Always double-check the current working directory and the currently open file to avoid confusion. @@ -61,24 +60,26 @@ Note: - If a search command fails, modify the search criteria and check for typos or incorrect paths, then try again. - Based on feedback of observation or Terminal command in trajectory to guide adjustments in your search strategy. -10. When the edit fails, try to enlarge the starting line. -11. You must use the Editor.open_file command to open a file before using the Editor tool's edit command to modify it. When you open a file, any currently open file will be automatically closed. -12. Remember, when you use Editor.insert_content_at_line or Editor.edit_file_by_replace, the line numbers will change after the operation. Therefore, if there are multiple operations, perform only the first operation in the current response, and defer the subsequent operations to the next turn. -13. If you choose Editor.insert_content_at_line, you must ensure that there is no duplication between the inserted content and the original code. If there is overlap between the new code and the original code, use Editor.edit_file_by_replace instead. -14. If you choose Editor.edit_file_by_replace, the original code that needs to be replaced must start at the beginning of the line and end at the end of the line +9. When the edit fails, try to enlarge the range of code. +10. You must use the Editor.open_file command to open a file before using the Editor tool's edit command to modify it. When you open a file, any currently open file will be automatically closed. +11. Remember, when you use Editor.insert_content_at_line or Editor.edit_file_by_replace, the line numbers will change after the operation. Therefore, if there are multiple operations, perform only the first operation in the current response, and defer the subsequent operations to the next turn. +11.1 Using Editor.insert_content_at_line and Editor.edit_file_by_replace more than once in the current command list is forbidden. +12. If you choose Editor.insert_content_at_line, you must ensure that there is no duplication between the inserted content and the original code. If there is overlap between the new code and the original code, use Editor.edit_file_by_replace instead. +13. If you choose Editor.edit_file_by_replace, the original code that needs to be replaced must start at the beginning of the line and end at the end of the line -15. When not specified, you should write files in a folder named "src". If you know the project path, then write in a "src" folder under the project path. -16. When provided system design or project schedule, you MUST read them first before making a plan, then adhere to them in your implementation, especially in the programming language, package, or framework. You MUST implement all code files prescribed in the system design or project schedule. You can create a plan first with each task corresponding to implementing one code file. -17. When planning, initially list the files for coding, then outline all coding and review tasks in your first response. -18. If you plan to read a file, do not include other plans in the same response. -19. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. -20. When the requirement is simple, you don't need to create a plan, just do it right away. -21. If the code exists, use the Editor tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. -22. Aways user absolute path as parameter. if no specific root path given, use "workspace/'project_name'" as default work space. +14. When not specified, you should write files in a folder named "src". If you know the project path, then write in a "src" folder under the project path. +15. When provided system design or project schedule, you MUST read them first before making a plan, then adhere to them in your implementation, especially in the programming language, package, or framework. You MUST implement all code files prescribed in the system design or project schedule. You can create a plan first with each task corresponding to implementing one code file. +16. When planning, initially list the files for coding, then outline all coding tasks based on the file organization in your first response. +17. If you plan to read a file, do not include other plans in the same response. +18. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. If you only need to code one file, provide all the necessary information in one response. +19. When the requirement is simple, you don't need to create a plan, just do it right away. +20. If the code exists, use the Editor tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. +21. Aways user absolute path as parameter. if no specific root path given, use "workspace/'project_name'" as default work space. +22. Running the Python code in the terminal is strictly forbidden. """ ENGINEER2_CMD_PROMPT = ( CMD_PROMPT - + "\nWhen using the Editor tool, the command list must contain a single command. Because the command is mutually exclusive." + + "\nUsing Editor.insert_content_at_line and Editor.edit_file_by_replace more than once in the current command list is forbidden. Because the command is mutually exclusive and will change the line number after execution." ) CURRENT_EDITOR_STATE = """ @@ -99,7 +100,7 @@ You are a world-class engineer, your goal is to write google-style, elegant, mod Pay attention to the conversation history and the following constraints: 1. When provided system design, YOU MUST FOLLOW "Data structures and interfaces". DONT CHANGE ANY DESIGN. Do not use public member functions that do not exist in your design. 2. When modifying a code, rewrite the full code instead of updating or inserting a snippet. -3. Write out EVERY CODE DETAIL, DON'T LEAVE TODO. +3. Write out EVERY CODE DETAIL, DON'T LEAVE TODO OR PLACEHOLDER. """ WRITE_CODE_PROMPT = """ diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index 94b21a76f..ed84d24d4 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -75,8 +75,14 @@ class RoleZero(Role): tool_recommender: Optional[ToolRecommender] = None tool_execution_map: Annotated[dict[str, Callable], Field(exclude=True)] = {} special_tool_commands: list[str] = ["Plan.finish_current_task", "end", "Bash.run"] + exclusive_tool_commands: list[str] = [ + "Editor.edit_file_by_replace", + "Editor.insert_content_at_line", + "Editor.append_file", + ] + exclusive_command_enable_flag: bool = True # Equipped with three basic tools by default for optional use - editor: Editor = Editor() + editor: Editor = Editor(enable_auto_lint=True) browser: Browser = Browser() # Experience @@ -149,7 +155,7 @@ class RoleZero(Role): "scroll_up", "search_dir", "search_file", - "set_workdir", + # "set_workdir", "write", ] } @@ -221,6 +227,7 @@ class RoleZero(Role): self.command_rsp = await self._check_duplicates(req, self.command_rsp) self.rc.memory.add(AIMessage(content=self.command_rsp)) + self.exclusive_command_enable_flag = True return True @exp_cache(context_builder=RoleZeroContextBuilder(), serializer=RoleZeroSerializer()) @@ -420,14 +427,14 @@ class RoleZero(Role): for cmd in commands: output = f"Command {cmd['command_name']} executed" # handle special command first - if self._is_special_command(cmd): - special_command_output = await self._run_special_command(cmd) - outputs.append(output + ":" + special_command_output) - continue - # run command as specified by tool_execute_map - if cmd["command_name"] in self.tool_execution_map: - tool_obj = self.tool_execution_map[cmd["command_name"]] - try: + try: + if self._is_special_command(cmd): + special_command_output = await self._run_special_command(cmd) + outputs.append(output + ":" + special_command_output) + continue + # run command as specified by tool_execute_map + if cmd["command_name"] in self.tool_execution_map: + tool_obj = self.tool_execution_map[cmd["command_name"]] if inspect.iscoroutinefunction(tool_obj): tool_output = await tool_obj(**cmd["args"]) else: @@ -435,20 +442,20 @@ class RoleZero(Role): if tool_output: output += f": {str(tool_output)}" outputs.append(output) - except Exception as e: - tb = traceback.format_exc() - logger.exception(str(e) + tb) - outputs.append(output + f": {tb}") - break # Stop executing if any command fails - else: - outputs.append(f"Command {cmd['command_name']} not found.") - break + else: + outputs.append(f"Command {cmd['command_name']} not found.") + break + except Exception as e: + tb = traceback.format_exc() + logger.exception(str(e) + tb) + outputs.append(output + f": {tb}") + break # Stop executing if any command fails outputs = "\n\n".join(outputs) return outputs def _is_special_command(self, cmd) -> bool: - return cmd["command_name"] in self.special_tool_commands + return cmd["command_name"] in self.special_tool_commands or cmd["command_name"] in self.exclusive_tool_commands async def _run_special_command(self, cmd) -> str: """command requiring special check or parsing""" @@ -472,6 +479,14 @@ class RoleZero(Role): ) else: command_output += f"\n[command]: {cmd['args']['cmd']} \n[command output] : {tool_output}" + + elif cmd["command_name"] in self.exclusive_tool_commands: + if self.exclusive_command_enable_flag is True: + tool_obj = self.tool_execution_map[cmd["command_name"]] + command_output += tool_obj(**cmd["args"]) + else: + command_output += "This command has not been executed." + self.exclusive_command_enable_flag = False return command_output def _get_plan_status(self) -> Tuple[str, str]: @@ -518,7 +533,9 @@ class RoleZero(Role): if not isinstance(self.rc.env, MGXEnv): return "Not in MGXEnv, command will not be executed." - return await self.rc.env.reply_to_human(content, sent_from=self) + rsp = await self.rc.env.reply_to_human(content, sent_from=self) + rsp += " If all tasks are finished, use 'end' to stop." + return async def _end(self): self._set_state(-1) diff --git a/metagpt/strategy/experience_retriever.py b/metagpt/strategy/experience_retriever.py index a6fe44368..a751a0810 100644 --- a/metagpt/strategy/experience_retriever.py +++ b/metagpt/strategy/experience_retriever.py @@ -842,7 +842,7 @@ Explanation: I will first need to read the system design document and the projec ## example 2 Consider this example only after you have obtained the content of system design and project schedule documents. -Suppose the system design and project schedule prescribes three files index.html, style.css, script.js, to follow the design and schedule, I will create a plan consisting of three tasks, each corresponding to the creation of one of the required files: `index.html`, `style.css`, and `script.js`. Following the completion of these tasks, I will add a code review task for each file to ensure the implementation aligns with the provided system design and project schedule documents. +Suppose the system design and project schedule prescribes three files index.html, style.css, script.js, to follow the design and schedule, I will create a plan consisting of three tasks, each corresponding to the creation of one of the required files: `index.html`, `style.css`, and `script.js`. Here's the plan: @@ -916,19 +916,6 @@ I will use browser to review the detailed information of this issue in order to ] ``` -## example 5 -The target working directory is "/workspace/MetaGPT/provider/", but the current working directory is different. I will use the set_workdir command to change the working directory. -```json -[ - { - "command_name": "Editor.set_workdir", - "args": { - "path": "/workspace/MetaGPT/provider" - } - } -] -``` - ## example 6 I need to locating the `openai_api.py` file, so I will search for the `openai_api.py` file. ```json diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index 46f0d5c0d..ca23984de 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -277,7 +277,7 @@ class Editor(BaseModel): return "" return f"[File: {current_file.resolve()} ({total_lines} lines total)]\n" - def set_workdir(self, path: str) -> None: + def _set_workdir(self, path: str) -> None: """ Sets the working directory to the given path. eg: repo directory. You MUST to set it up before open the file. @@ -499,6 +499,17 @@ class Editor(BaseModel): content = "".join(new_lines) return content, n_added_lines + def get_indentation_infromation(self, content, first_error_line): + content_lines = content.split("\n") + previous_line = content_lines[first_error_line - 2] if first_error_line - 2 >= 0 else "" + first_insert_line = content_lines[first_error_line - 1] + ret_str = f'the privous line is "{previous_line}", the indentation has {len(previous_line)-len(previous_line.lstrip())} space\n' + insert_line_indentation = len(first_insert_line) - len(first_insert_line.lstrip()) + ret_str += f'the error line is "{first_insert_line}", the indentation has {insert_line_indentation} space\n' + ret_str += "Please check the indentation of the code to ensure that it is not causing any errors.\n" + ret_str += f"Try to use indentation that has {insert_line_indentation-4 if insert_line_indentation-4 >0 else 0} or {insert_line_indentation+4} space" + return ret_str + def _edit_file_impl( self, file_name: Path, @@ -655,6 +666,8 @@ class Editor(BaseModel): ) ret_str += "-------------------------------------------------\n" + ret_str += "\n" + self.get_indentation_infromation(content, first_error_line) + ret_str += ( "Your changes have NOT been applied. Please fix your edit command and try again.\n" "You either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\n" @@ -674,11 +687,21 @@ class Editor(BaseModel): except ValueError as e: ret_str += f"Invalid input: {e}\n" except Exception as e: + error_str = "" + if is_append: + error_str += self.get_indentation_infromation(content, len(lines)) + else: + # insert or replace + error_str += self.get_indentation_infromation(content, start) # Clean up the temporary file if an error occurs + with original_file_backup_path.open() as fin, file_name.open("w") as fout: + fout.write(fin.read()) if temp_file_path and Path(temp_file_path).exists(): Path(temp_file_path).unlink() + logger.warning(f"An unexpected error occurred: {e}") - raise e + raise Exception(f"{error_str}") from e + # raise e # Update the file information and print the updated content with file_name.open("r", encoding="utf-8") as file: From 33b9c57756d0993b660e6ee5b623a6a5707e8d11 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Wed, 4 Sep 2024 20:36:49 +0800 Subject: [PATCH 16/25] fix/insert_more_than_one_pre_rsp --- metagpt/prompts/di/engineer2.py | 7 ++-- metagpt/roles/di/engineer2.py | 7 ++++ metagpt/roles/di/role_zero.py | 48 +++++++++++++----------- metagpt/strategy/experience_retriever.py | 25 +++++++++--- metagpt/tools/libs/editor.py | 37 ++++++++++++------ 5 files changed, 83 insertions(+), 41 deletions(-) diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index c597146a5..3108a7cbd 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -5,7 +5,8 @@ You are an autonomous programmer The special interface consists of a file editor that shows you 100 lines of a file at a time. -You can use any terminal commands you want (e.g., find, grep, cat, ls, cd) by calling Terminal.run_command. +You can use terminal commands (e.g., cat, ls, cd) by calling Terminal.run_command. +Do Not run the code. You should carefully observe the behavior and results of the previous action, and avoid triggering repeated errors. @@ -63,7 +64,7 @@ Note: 9. When the edit fails, try to enlarge the range of code. 10. You must use the Editor.open_file command to open a file before using the Editor tool's edit command to modify it. When you open a file, any currently open file will be automatically closed. 11. Remember, when you use Editor.insert_content_at_line or Editor.edit_file_by_replace, the line numbers will change after the operation. Therefore, if there are multiple operations, perform only the first operation in the current response, and defer the subsequent operations to the next turn. -11.1 Using Editor.insert_content_at_line and Editor.edit_file_by_replace more than once in the current command list is forbidden. +11.1 Do not use Editor.insert_content_at_line or Editor.edit_file_by_replace more than once per command list. 12. If you choose Editor.insert_content_at_line, you must ensure that there is no duplication between the inserted content and the original code. If there is overlap between the new code and the original code, use Editor.edit_file_by_replace instead. 13. If you choose Editor.edit_file_by_replace, the original code that needs to be replaced must start at the beginning of the line and end at the end of the line @@ -75,7 +76,7 @@ Note: 19. When the requirement is simple, you don't need to create a plan, just do it right away. 20. If the code exists, use the Editor tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. 21. Aways user absolute path as parameter. if no specific root path given, use "workspace/'project_name'" as default work space. -22. Running the Python code in the terminal is strictly forbidden. +22. Forbidden to run code in the terminal. """ ENGINEER2_CMD_PROMPT = ( CMD_PROMPT diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 56d520b36..bee5aa04d 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -77,6 +77,13 @@ class Engineer2(RoleZero): # "ValidateAndRewriteCode": validate.run, } ) + if self.run_eval: + self.tool_execution_map.update( + { + "RoleZero.ask_human": self._end, + "RoleZero.reply_to_human": self._end, + } + ) async def eval_terminal_run(self, cmd): """change command pull/push/commit to end.""" diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index ed84d24d4..d28f27138 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -80,7 +80,6 @@ class RoleZero(Role): "Editor.insert_content_at_line", "Editor.append_file", ] - exclusive_command_enable_flag: bool = True # Equipped with three basic tools by default for optional use editor: Editor = Editor(enable_auto_lint=True) browser: Browser = Browser() @@ -223,11 +222,8 @@ class RoleZero(Role): async with ThoughtReporter(enable_llm_stream=True) as reporter: await reporter.async_report({"type": "react"}) self.command_rsp = await self.llm_cached_aask(req=req, system_msgs=[system_prompt], state_data=state_data) - self.command_rsp = await self._check_duplicates(req, self.command_rsp) - self.rc.memory.add(AIMessage(content=self.command_rsp)) - self.exclusive_command_enable_flag = True return True @exp_cache(context_builder=RoleZeroContextBuilder(), serializer=RoleZeroSerializer()) @@ -267,7 +263,8 @@ class RoleZero(Role): if self.use_fixed_sop: return await super()._act() - commands, ok = await self._parse_commands(self.command_rsp) + commands, ok, self.command_rsp = await self._parse_commands(self.command_rsp) + self.rc.memory.add(AIMessage(content=self.command_rsp)) if not ok: error_msg = commands self.rc.memory.add(UserMessage(content=error_msg)) @@ -415,12 +412,25 @@ class RoleZero(Role): tb = traceback.format_exc() print(tb) error_msg = str(e) - return error_msg, False + return error_msg, False, command_rsp # 为了对LLM不按格式生成进行容错 if isinstance(commands, dict): commands = commands["commands"] if "commands" in commands else [commands] - return commands, True + + # Set the exclusive command flag to False. + command_flag = [command["command_name"] not in self.exclusive_tool_commands for command in commands] + if command_flag.count(False) > 1: + # Set the flag of the first exclusive command to True. + index_of_first_exclusive = command_flag.index(False) + command_flag[index_of_first_exclusive] = True + # Select command which flag is True. + commands = [commands[index] for index, flag in enumerate(command_flag) if flag is True] + command_rsp = "```json\n" + json.dumps(commands, indent=4, ensure_ascii=False) + "\n```json" + logger.info( + "exclusive command more than one in current command list. change the command list.\n" + command_rsp + ) + return commands, True, command_rsp async def _run_commands(self, commands) -> str: outputs = [] @@ -455,7 +465,7 @@ class RoleZero(Role): return outputs def _is_special_command(self, cmd) -> bool: - return cmd["command_name"] in self.special_tool_commands or cmd["command_name"] in self.exclusive_tool_commands + return cmd["command_name"] in self.special_tool_commands async def _run_special_command(self, cmd) -> str: """command requiring special check or parsing""" @@ -464,7 +474,9 @@ class RoleZero(Role): if cmd["command_name"] == "Plan.finish_current_task": if not self.planner.plan.is_plan_finished(): self.planner.plan.finish_current_task() - command_output = "Current task is finished. If all tasks are finished, use 'end' to stop." + command_output = ( + "Current task is finished. If you no longer need to take action, use the command ‘end’ to stop." + ) elif cmd["command_name"] == "end": command_output = await self._end() @@ -480,13 +492,6 @@ class RoleZero(Role): else: command_output += f"\n[command]: {cmd['args']['cmd']} \n[command output] : {tool_output}" - elif cmd["command_name"] in self.exclusive_tool_commands: - if self.exclusive_command_enable_flag is True: - tool_obj = self.tool_execution_map[cmd["command_name"]] - command_output += tool_obj(**cmd["args"]) - else: - command_output += "This command has not been executed." - self.exclusive_command_enable_flag = False return command_output def _get_plan_status(self) -> Tuple[str, str]: @@ -532,12 +537,13 @@ class RoleZero(Role): from metagpt.environment.mgx.mgx_env import MGXEnv # avoid circular import if not isinstance(self.rc.env, MGXEnv): - return "Not in MGXEnv, command will not be executed." - rsp = await self.rc.env.reply_to_human(content, sent_from=self) - rsp += " If all tasks are finished, use 'end' to stop." - return + rsp = "Not in MGXEnv, command will not be executed." + else: + rsp = await self.rc.env.reply_to_human(content, sent_from=self) + rsp += " If you no longer need to take action, use the command ‘end’ to stop." + return rsp - async def _end(self): + async def _end(self, **kwarg): self._set_state(-1) memory = self.rc.memory.get(self.memory_k) # Ensure reply to the human before the "end" command is executed. Hard code k=5 for checking. diff --git a/metagpt/strategy/experience_retriever.py b/metagpt/strategy/experience_retriever.py index a751a0810..65fb0c53a 100644 --- a/metagpt/strategy/experience_retriever.py +++ b/metagpt/strategy/experience_retriever.py @@ -988,6 +988,24 @@ Note that the edit command must be executed in a single response, so this step w ] ``` +## example 10.1 +To enhance the functionality of the 2048 game, including game end detection and score tracking, we need to add these features to the existing game_2048.py file. First, we will add a score tracking feature, and then we will insert game end detection logic into the game loop. +We will use the Editor.insert_content_at_line command to insert new code into the file for adding score tracking and game end detection. +Since Editor.insert_content_at_line can only be used once per response, this time I will use it to create the variable self.score +```json +[ + { + "command_name": "Editor.insert_content_at_line", + "args": { + "file_name": "/home/mgx/mgx/MetaGPT/workspace/2048_game_py/game_2048.py", + "line_number": 4, + "content": " self.score = 0\n" + } + } +] +``` +In the next turn, I will try to add another code snippet + ## example 11 ``` #### Save the changes and commit them to the remote repository. @@ -1028,14 +1046,9 @@ Thought: Now that the changes have been pushed to the remote repository, due to """ ## example 11 -I have finish all the tasks, so I will use 'Plan.finish_current_task' and then fellowing the command "end" to stop. +I have finished all the tasks, so I will use Plan.finish_current_task and then follow the command ‘end’ to stop. ```json [ - { - "command_name": "Plan.finish_current_task", - "args": { - } - }, { "command_name": "end", "args": { diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index ca23984de..12af8611f 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -633,9 +633,11 @@ class Editor(BaseModel): first_error_line = None if lint_error is not None: - if first_error_line is not None: - show_line = int(first_error_line) - elif is_append: + # if first_error_line is not None: + # show_line = int(first_error_line) + + # show the first insert line. + if is_append: # original end-of-file show_line = len(lines) # insert OR edit WILL provide meaningful line numbers @@ -666,7 +668,7 @@ class Editor(BaseModel): ) ret_str += "-------------------------------------------------\n" - ret_str += "\n" + self.get_indentation_infromation(content, first_error_line) + ret_str += self.get_indentation_infromation(content, start or len(lines)) ret_str += ( "Your changes have NOT been applied. Please fix your edit command and try again.\n" @@ -687,12 +689,14 @@ class Editor(BaseModel): except ValueError as e: ret_str += f"Invalid input: {e}\n" except Exception as e: - error_str = "" - if is_append: - error_str += self.get_indentation_infromation(content, len(lines)) - else: - # insert or replace - error_str += self.get_indentation_infromation(content, start) + error_str = "[This is how your edit would have looked if applied]\n" + error_str += "-------------------------------------------------\n" + error_str += self._print_window(file_name, start or len(lines), 40) + "\n" + error_str += "-------------------------------------------------\n" + error_str += self.get_indentation_infromation(content, start or len(lines)) + if not is_insert and not is_append: + error_str += "enlarge the range of original code." + error_str += "\nTry to enlarge the range of the orginal code" # Clean up the temporary file if an error occurs with original_file_backup_path.open() as fin, file_name.open("w") as fout: fout.write(fin.read()) @@ -720,7 +724,8 @@ class Editor(BaseModel): return ret_str def edit_file_by_replace(self, file_name: str, to_replace: str, new_content: str) -> str: - """Edit a file. This will search for `to_replace` in the given file and replace it with `new_content`. + """ + Edit a file. This will search for `to_replace` in the given file and replace it with `new_content`. Every *to_replace* must *EXACTLY MATCH* the existing source code, character for character, including all comments, docstrings, etc. @@ -764,6 +769,10 @@ class Editor(BaseModel): file_name: str: The name of the file to edit. to_replace: str: The content to search for and replace. new_content: str: The new content to replace the old content with. + + NOTE: + This tool is exclusive. If you use this tool, you cannot use any other commands in the current response. + If you need to use it multiple times, wait for the next turn. """ # FIXME: support replacing *all* occurrences if to_replace.strip() == "": @@ -839,6 +848,9 @@ class Editor(BaseModel): file_name: str: The name of the file to edit. line_number: int: The line number (starting from 1) to insert the content after. content: str: The content to insert. + NOTE: + This tool is exclusive. If you use this tool, you cannot use any other commands in the current response. + If you need to use it multiple times, wait for the next turn. """ file_name = self._try_fix_path(file_name) @@ -859,6 +871,9 @@ class Editor(BaseModel): Args: file_name: str: The name of the file to edit. content: str: The content to insert. + NOTE: + This tool is exclusive. If you use this tool, you cannot use any other commands in the current response. + If you need to use it multiple times, wait for the next turn. """ file_name = self._try_fix_path(file_name) From 5dcde29d73924ba3fa1fc87d774507eff5cdb7cd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Wed, 4 Sep 2024 21:27:17 +0800 Subject: [PATCH 17/25] update run swe bench script --- tests/metagpt/roles/di/run_swe_agent_for_benchmark.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py index 72625c149..86258edbc 100644 --- a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py +++ b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py @@ -16,7 +16,8 @@ from metagpt.tools.swe_agent_commands.swe_agent_utils import load_hf_dataset config = Config.default() # Specify by yourself Role = Engineer2 -MAX_MINUTES_PRE_INSTANCE = 5 +# 调整每个样例的执行时间,太低容易出现提交u数量少的情况 +MAX_MINUTES_PRE_INSTANCE = 20 TEST_REPO_DIR = METAGPT_ROOT / "data" / "test_repo" DATA_DIR = METAGPT_ROOT / "data/hugging_face" From c18353080d8932a6aabb208e37ff69cb802d310e Mon Sep 17 00:00:00 2001 From: shenchucheng Date: Thu, 5 Sep 2024 15:29:46 +0800 Subject: [PATCH 18/25] add editor reporter --- metagpt/tools/libs/editor.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index 8013b99c9..20f5b7836 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -321,6 +321,7 @@ class Editor(BaseModel): output = self._cur_file_header(path, total_lines) output += self._print_window(path, self.current_line, self._clamp(context_lines, 1, 2000)) + self.resource.report(path, "path") return output def goto_line(self, line_number: int) -> str: @@ -792,6 +793,7 @@ class Editor(BaseModel): ) # lint_error = bool(LINTER_ERROR_MSG in ret_str) # TODO: automatically tries to fix linter error (maybe involve some static analysis tools on the location near the edit to figure out indentation) + self.resource.report(file_name, "path") return ret_str def insert_content_at_line(self, file_name: str, line_number: int, content: str) -> str: @@ -914,6 +916,9 @@ class Editor(BaseModel): res_list.append(f'[End of matches for "{search_term}" in {file_path}]') else: res_list.append(f'[No matches found for "{search_term}" in {file_path}]') + + extra = {"type": "search", "symbol": search_term, "lines": [i[0] - 1 for i in matches]} if matches else None + self.resource.report(file_path, "path", extra=extra) return "\n".join(res_list) def find_file(self, file_name: str, dir_path: str = "./") -> str: From c2f9f010b7ef4eb13a641fb9b5a201d3704ae585 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Thu, 5 Sep 2024 16:48:46 +0800 Subject: [PATCH 19/25] update_engineer_and_editorprompt --- metagpt/environment/mgx/mgx_env.py | 2 +- metagpt/prompts/di/engineer2.py | 23 +-- metagpt/prompts/di/role_zero.py | 1 + metagpt/prompts/di/team_leader.py | 2 - metagpt/roles/di/engineer2.py | 92 +++-------- metagpt/roles/di/role_zero.py | 50 +++--- metagpt/strategy/experience_retriever.py | 4 +- metagpt/tools/libs/editor.py | 151 +++++++++++------- .../environment/mgx_env/run_mgx_env.py | 2 - .../roles/di/run_swe_agent_for_benchmark.py | 143 ++++++++++++----- 10 files changed, 251 insertions(+), 219 deletions(-) diff --git a/metagpt/environment/mgx/mgx_env.py b/metagpt/environment/mgx/mgx_env.py index 8bb3fc823..4df04d3ce 100644 --- a/metagpt/environment/mgx/mgx_env.py +++ b/metagpt/environment/mgx/mgx_env.py @@ -96,7 +96,7 @@ class MGXEnv(Environment, SerializationMixin): async def reply_to_human(self, content: str, sent_from: Role = None) -> str: # NOTE: Can be overwritten in remote setting - return "SUCCESS, human has received your reply. Refrain from resending duplicate messages." + return "SUCCESS, human has received your reply. Refrain from resending duplicate messages. If you no longer need to take action, use the command ‘end’ to stop." def message_within_software_sop(self, message: Message) -> bool: # Engineer, QaEngineer can be end of the SOP. Their msg requires routing outside. diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index 3108a7cbd..113551212 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -1,4 +1,4 @@ -from metagpt.prompts.di.role_zero import CMD_PROMPT, ROLE_INSTRUCTION +from metagpt.prompts.di.role_zero import ROLE_INSTRUCTION EXTRA_INSTRUCTION = """ You are an autonomous programmer @@ -58,7 +58,7 @@ Note: - Enclose terms like `def` or `class` in quotes when searching for functions or classes (e.g., `search_dir 'def apow'` or `search_file 'class Pow'`). - Use wildcard characters (`*`, `?`) in search terms to broaden or narrow down your search scope. - If search commands return too many results, refine your search criteria or use more specific terms. - - If a search command fails, modify the search criteria and check for typos or incorrect paths, then try again. + - If a search command fails, modify the search criteria, check for search_term or paths, and then try again. - Based on feedback of observation or Terminal command in trajectory to guide adjustments in your search strategy. 9. When the edit fails, try to enlarge the range of code. @@ -75,23 +75,16 @@ Note: 18. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. If you only need to code one file, provide all the necessary information in one response. 19. When the requirement is simple, you don't need to create a plan, just do it right away. 20. If the code exists, use the Editor tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. -21. Aways user absolute path as parameter. if no specific root path given, use "workspace/'project_name'" as default work space. -22. Forbidden to run code in the terminal. +21. Forbidden to run code in the terminal. +22. When using the editor, pay attention to the editor's current directory. When you use editor tools, the paths must be either absolute or relative to the editor's current directory. """ -ENGINEER2_CMD_PROMPT = ( - CMD_PROMPT - + "\nUsing Editor.insert_content_at_line and Editor.edit_file_by_replace more than once in the current command list is forbidden. Because the command is mutually exclusive and will change the line number after execution." -) -CURRENT_EDITOR_STATE = """ +CURRENT_STATE = """ The current editor state is: -(Open file: {open_file}) -(Current directory: {working_dir}) -""" - -CURRENT_TERMINAL_STATE = """ +(Editor current directory: {editor_current_directory}) +(Editor open file: {editor_open_file}) The current terminal state is: -(Current directory: {working_dir}) +(Terminal current directory: {terminal_current_directory}) """ ENGINEER2_INSTRUCTION = ROLE_INSTRUCTION + EXTRA_INSTRUCTION.strip() diff --git a/metagpt/prompts/di/role_zero.py b/metagpt/prompts/di/role_zero.py index 3029735ba..a6b111d95 100644 --- a/metagpt/prompts/di/role_zero.py +++ b/metagpt/prompts/di/role_zero.py @@ -71,6 +71,7 @@ Pay close attention to the Example provided, you can reuse the example for your You may use any of the available commands to create a plan or update the plan. You may output mutiple commands, they will be executed sequentially. If you finish current task, you will automatically take the next task in the existing plan, use Plan.finish_task, DON'T append a new task. Review the latest plan's outcome, focusing on achievements. If your completed task matches the current, consider it finished. +Using Editor.insert_content_at_line and Editor.edit_file_by_replace more than once in the current command list is forbidden. Because the command is mutually exclusive and will change the line number after execution. In your response, include at least one command. # Your commands in a json array, in the following output format with correct command_name and args. If there is nothing to do, use the pass or end command: diff --git a/metagpt/prompts/di/team_leader.py b/metagpt/prompts/di/team_leader.py index e5c119dc8..8d85a8cf3 100644 --- a/metagpt/prompts/di/team_leader.py +++ b/metagpt/prompts/di/team_leader.py @@ -24,7 +24,6 @@ Note: - XL: Social media platform, e-commerce app, real-time multiplayer game - For XS and S requirements, you don't need the standard software development process, you may directly ask Engineer to write the code. Otherwise, estimate if any part of the standard software development process may contribute to a better final code. If so, assign team members accordingly. 3.1 If the task involves code review (CR) or code checking, you should assign it to Engineer. -3.2. If the requirement is to fix a bug or issue, you should assign it to Issue Solver. However, if the code is written by Engineer, Engineer must maintain the code. 4. If the requirement is a common-sense, logical, or math problem, you should respond directly without assigning any task to team members. 5. If you think the requirement is not clear or ambiguous, you should ask the user for clarification immediately. Assign tasks only after all info is clear. 6. It is helpful for Engineer to have both the system design and the project schedule for writing the code, so include paths of both files (if available) and remind Engineer to definitely read them when publishing message to Engineer. @@ -43,7 +42,6 @@ Sixth, describe the requirements as they pertain to software development, data a Seventh, describe the technologies you must use. """ ) - TL_INFO = """ {role_info} Your team member: diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 92ecb633d..9ec22c077 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -1,18 +1,14 @@ from __future__ import annotations -import os from pathlib import Path from pydantic import Field -from metagpt.config2 import Config from metagpt.logs import logger # from metagpt.actions.write_code_review import ValidateAndRewriteCode from metagpt.prompts.di.engineer2 import ( - CURRENT_EDITOR_STATE, - CURRENT_TERMINAL_STATE, - ENGINEER2_CMD_PROMPT, + CURRENT_STATE, ENGINEER2_INSTRUCTION, WRITE_CODE_PROMPT, WRITE_CODE_SYSTEM_PROMPT, @@ -33,7 +29,6 @@ class Engineer2(RoleZero): profile: str = "Engineer" goal: str = "Take on game, app, and web development." instruction: str = ENGINEER2_INSTRUCTION - cmd_prompt: str = ENGINEER2_CMD_PROMPT terminal: Terminal = Field(default_factory=Terminal, exclude=True) tools: list[str] = [ @@ -58,52 +53,38 @@ class Engineer2(RoleZero): async def _format_instruction(self): """ - Formats the instruction message for the Engineer2. - Uses Editor's state to format the `_instruction` template. + Display the current terminal and editor state. + This information will be dynamically added to the command prompt. """ - bash_working_dir = await self.terminal.run_command("pwd") - bash_state = {"working_dir": bash_working_dir} - editor_state = {"open_file": self.editor.current_file, "working_dir": self.editor.working_dir} - self.cmd_prompt_current_state = CURRENT_EDITOR_STATE.format( - **editor_state - ).strip() + CURRENT_TERMINAL_STATE.format(**bash_state) + state = { + "editor_open_file": self.editor.current_file, + "editor_current_directory": self.editor.working_dir, + "terminal_current_directory": await self.terminal.run_command("pwd"), + } + self.cmd_prompt_current_state = CURRENT_STATE.format(**state).strip() def _update_tool_execution(self): self.tool_execution_map.update( { - "Terminal.run_command": self.eval_terminal_run if self.run_eval else self.terminal.run_command, + "Terminal.run_command": self.terminal.run_command, "git_create_pull": git_create_pull, "Engineer2.write_new_code": self.write_new_code, # "ValidateAndRewriteCode.run": validate.run, # "ValidateAndRewriteCode": validate.run, } ) + self.exclusive_tool_commands.append("Engineer2.write_new_code") if self.run_eval: self.tool_execution_map.update( { + "Terminal.run_command": self._eval_terminal_run, "RoleZero.ask_human": self._end, "RoleZero.reply_to_human": self._end, } ) - async def eval_terminal_run(self, cmd): - """change command pull/push/commit to end.""" - if any([cmd_key_word in cmd for cmd_key_word in ["pull", "push", "commit"]]): - # The Engineer2 attempts to submit the repository after fixing the bug, thereby reaching the end of the fixing process. - # Set self.rc.todo to None to stop the engineer and then will trigger _save_git_diff funcion to save difference. - logger.info("Engineer2 use cmd:{cmd}") - logger.info("Current test case is finished.") - # stop the Engineer2 - self._set_state(-1) - command_output = "Current test case is finished." - else: - command_output = await self.terminal.run_command(cmd) - return command_output - async def _act(self) -> Message: message = await super()._act() - if self.run_eval: - await self._save_git_diff() return message def _retrieve_experience(self) -> str: @@ -119,44 +100,6 @@ class Engineer2(RoleZero): command_output += await super()._run_special_command(cmd) return command_output - async def _save_git_diff(self): - """ - Handles actions based on parsed commands. - - When detecting engineer2 at the final action round, the process will stop immediately. - generates a patch using `git diff`. - Stores the cleaned patch in `output_diff`. Logs any exceptions. - - This function is specifically added for SWE bench evaluation. - """ - # If todo switches to None, it indicates that this is the final round of reactions, and the Engineer2 will stop. Use git diff to store any changes made. - if not self.rc.todo: - from metagpt.tools.swe_agent_commands.swe_agent_utils import extract_patch - - try: - logger.info(await self.submit()) - diff_output = await self.terminal.run_command("git diff --cached") - clear_diff = extract_patch(diff_output) - logger.info(f"Diff output: \n{clear_diff}") - if clear_diff: - self.output_diff = clear_diff - except Exception as e: - logger.error(f"Error during submission: {e}") - - async def submit(self): - if "SWE_CMD_WORK_DIR" not in os.environ: - os.environ["SWE_CMD_WORK_DIR"] = str(Config.default().workspace.path) - if os.path.exists(os.environ["SWE_CMD_WORK_DIR"] + "/test.patch"): - await self.terminal.run_command('git apply -R < "$SWE_CMD_WORK_DIR/test.patch"') - cmd = """ - git add -A - echo "<>" - """ - diff_output = await self.terminal.run_command(cmd) - return diff_output - async def write_new_code(self, path: str, instruction: str = "") -> str: """Write a new code file. Args: @@ -180,3 +123,14 @@ class Engineer2(RoleZero): # TODO: Consider adding line no to be ready for editing. return f"The file {path} has been successfully created, with content:\n{code}" + + async def _eval_terminal_run(self, cmd): + """change command pull/push/commit to end.""" + if any([cmd_key_word in cmd for cmd_key_word in ["pull", "push", "commit"]]): + # The Engineer2 attempts to submit the repository after fixing the bug, thereby reaching the end of the fixing process. + logger.info("Engineer2 use cmd:{cmd}\nCurrent test case is finished.") + # Set self.rc.todo to None to stop the engineer. + self._set_state(-1) + else: + command_output = await self.terminal.run_command(cmd) + return command_output diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index 0e8d005e7..9d309bfc6 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -73,6 +73,7 @@ class RoleZero(Role): tool_recommender: Optional[ToolRecommender] = None tool_execution_map: Annotated[dict[str, Callable], Field(exclude=True)] = {} special_tool_commands: list[str] = ["Plan.finish_current_task", "end", "Bash.run"] + # List of exclusive tool commands exclusive_tool_commands: list[str] = [ "Editor.edit_file_by_replace", "Editor.insert_content_at_line", @@ -420,11 +421,13 @@ class RoleZero(Role): # Set the exclusive command flag to False. command_flag = [command["command_name"] not in self.exclusive_tool_commands for command in commands] if command_flag.count(False) > 1: - # Set the flag of the first exclusive command to True. + # Keep only the first exclusive command index_of_first_exclusive = command_flag.index(False) - command_flag[index_of_first_exclusive] = True - # Select command which flag is True. - commands = [commands[index] for index, flag in enumerate(command_flag) if flag is True] + commands = [ + cmd + for index, cmd in enumerate(commands) + if index == index_of_first_exclusive or cmd["command_name"] not in self.exclusive_tool_commands + ] command_rsp = "```json\n" + json.dumps(commands, indent=4, ensure_ascii=False) + "\n```json" logger.info( "exclusive command more than one in current command list. change the command list.\n" + command_rsp @@ -436,14 +439,14 @@ class RoleZero(Role): for cmd in commands: output = f"Command {cmd['command_name']} executed" # handle special command first - try: - if self._is_special_command(cmd): - special_command_output = await self._run_special_command(cmd) - outputs.append(output + ":" + special_command_output) - continue - # run command as specified by tool_execute_map - if cmd["command_name"] in self.tool_execution_map: - tool_obj = self.tool_execution_map[cmd["command_name"]] + if self._is_special_command(cmd): + special_command_output = await self._run_special_command(cmd) + outputs.append(output + ":" + special_command_output) + continue + # run command as specified by tool_execute_map + if cmd["command_name"] in self.tool_execution_map: + tool_obj = self.tool_execution_map[cmd["command_name"]] + try: if inspect.iscoroutinefunction(tool_obj): tool_output = await tool_obj(**cmd["args"]) else: @@ -451,14 +454,14 @@ class RoleZero(Role): if tool_output: output += f": {str(tool_output)}" outputs.append(output) - else: - outputs.append(f"Command {cmd['command_name']} not found.") - break - except Exception as e: - tb = traceback.format_exc() - logger.exception(str(e) + tb) - outputs.append(output + f": {tb}") - break # Stop executing if any command fails + except Exception as e: + tb = traceback.format_exc() + logger.exception(str(e) + tb) + outputs.append(output + f": {tb}") + break # Stop executing if any command fails + else: + outputs.append(f"Command {cmd['command_name']} not found.") + break outputs = "\n\n".join(outputs) return outputs @@ -536,11 +539,8 @@ class RoleZero(Role): from metagpt.environment.mgx.mgx_env import MGXEnv # avoid circular import if not isinstance(self.rc.env, MGXEnv): - rsp = "Not in MGXEnv, command will not be executed." - else: - rsp = await self.rc.env.reply_to_human(content, sent_from=self) - rsp += " If you no longer need to take action, use the command ‘end’ to stop." - return rsp + return "Not in MGXEnv, command will not be executed." + return await self.rc.env.reply_to_human(content, sent_from=self) async def _end(self, **kwarg): self._set_state(-1) diff --git a/metagpt/strategy/experience_retriever.py b/metagpt/strategy/experience_retriever.py index 65fb0c53a..4c649f0d2 100644 --- a/metagpt/strategy/experience_retriever.py +++ b/metagpt/strategy/experience_retriever.py @@ -958,7 +958,7 @@ I have opened the openai_api.py file. However, the range of lines shown is from ## example 9 I've found the bug and will start fixing it. I'll pay close attention to the indentation. Since I only need to modify a few lines in this file, I will use Editor.edit_file_by_replace. The original content will be replaced by the new code. -Note that the edit command must be executed in a single response, so this step will only involve using the edit command. +Editor tool is exclusive. If I use this tool, I cannot use any other commands in the current response. ```json [ { @@ -974,7 +974,7 @@ Note that the edit command must be executed in a single response, so this step w ## example 10 I only need to add a few lines to the file, so I will use Editor.insert_content_at_line. The new code will not cover the original code. -Note that the edit command must be executed in a single response, so this step will only involve using the edit command. +Note that the Editor command must be executed in a single response, so this step will only involve using the Editor command. ```json [ { diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index e7fc5f0a1..173322a66 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -24,10 +24,47 @@ from metagpt.utils.repo_to_markdown import is_text_file from metagpt.utils.report import EditorReporter # This is also used in unit tests! -MSG_FILE_UPDATED = "[File updated (edited at line {line_number}). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.]" LINTER_ERROR_MSG = "[Your proposed edit has introduced new syntax error(s). Please understand the errors and retry your edit command.]\n" +INDENTATION_INFO = """ +The previous line is: +"{pre_line}" +The indentation has {pre_line_indent} spaces. + +The error line is: +"{insert_line}" +The indentation has {insert_line_indent} spaces. + +Please check the indentation of the code to ensure that it is not causing any errors. +Try using indentation with either {sub_4_space} or {add_4_space} spaces. +""" + +ERROR_GUIDANCE = """ +{linter_error_msg} + +[This is how your edit would have looked if applied] +------------------------------------------------- +{window_after_applied} +------------------------------------------------- + +[This is the original code before your edit] +------------------------------------------------- +{window_before_applied} +------------------------------------------------- + +Your changes have NOT been applied. Please fix your edit command and try again +{guidance_message} + +""" + +SUCCESS_EDITE_INFO = """ +[File: {file_name} ({n_total_lines} lines total after edit)] +{window_after_applied} +[File updated (edited at line {line_number}). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.] +""" + + class FileBlock(BaseModel): """A block of content in a file""" @@ -499,15 +536,24 @@ class Editor(BaseModel): content = "".join(new_lines) return content, n_added_lines - def get_indentation_infromation(self, content, first_error_line): + def _get_indentation_info(self, content, first_error_line): + """ + Information about the current edit's indentation. + Includes guidance on how to fix it. + """ content_lines = content.split("\n") - previous_line = content_lines[first_error_line - 2] if first_error_line - 2 >= 0 else "" - first_insert_line = content_lines[first_error_line - 1] - ret_str = f'the privous line is "{previous_line}", the indentation has {len(previous_line)-len(previous_line.lstrip())} space\n' - insert_line_indentation = len(first_insert_line) - len(first_insert_line.lstrip()) - ret_str += f'the error line is "{first_insert_line}", the indentation has {insert_line_indentation} space\n' - ret_str += "Please check the indentation of the code to ensure that it is not causing any errors.\n" - ret_str += f"Try to use indentation that has {insert_line_indentation-4 if insert_line_indentation-4 >0 else 0} or {insert_line_indentation+4} space" + pre_line = content_lines[first_error_line - 2] if first_error_line - 2 >= 0 else "" + pre_line_indent = len(pre_line) - len(pre_line.lstrip()) + insert_line = content_lines[first_error_line - 1] + insert_line_indent = len(insert_line) - len(insert_line.lstrip()) + ret_str = INDENTATION_INFO.format( + pre_line=pre_line, + pre_line_indent=pre_line_indent, + insert_line=insert_line, + insert_line_indent=insert_line_indent, + sub_4_space=max(insert_line_indent - 4, 0), + add_4_space=insert_line_indent + 4, + ) return ret_str def _edit_file_impl( @@ -529,7 +575,6 @@ class Editor(BaseModel): is_insert: bool = False: Whether to insert content at the given line number instead of editing. is_append: bool = False: Whether to append content to the file instead of editing. """ - ret_str = "" ERROR_MSG = f"[Error editing file {file_name}. Please confirm the file is correct.]" ERROR_MSG_SUFFIX = ( @@ -579,14 +624,12 @@ class Editor(BaseModel): try: content, n_added_lines = self._insert_impl(lines, start, content) except LineNumberError as e: - ret_str += (f"{ERROR_MSG}\n" f"{e}\n" f"{ERROR_MSG_SUFFIX}") + "\n" - return ret_str + return (f"{ERROR_MSG}\n" f"{e}\n" f"{ERROR_MSG_SUFFIX}") + "\n" else: try: content, n_added_lines = self._edit_impl(lines, start, end, content) except LineNumberError as e: - ret_str += (f"{ERROR_MSG}\n" f"{e}\n" f"{ERROR_MSG_SUFFIX}") + "\n" - return ret_str + return (f"{ERROR_MSG}\n" f"{e}\n" f"{ERROR_MSG_SUFFIX}") + "\n" if not content.endswith("\n"): content += "\n" @@ -646,66 +689,52 @@ class Editor(BaseModel): else: raise ValueError("Invalid state. This should never happen.") - ret_str += LINTER_ERROR_MSG - ret_str += lint_error + "\n" - - editor_lines = n_added_lines + 20 - - ret_str += "[This is how your edit would have looked if applied]\n" - ret_str += "-------------------------------------------------\n" - ret_str += self._print_window(file_name, show_line, editor_lines) + "\n" - ret_str += "-------------------------------------------------\n\n" - - ret_str += "[This is the original code before your edit]\n" - ret_str += "-------------------------------------------------\n" - ret_str += ( - self._print_window( - original_file_backup_path, - show_line, - editor_lines, - ) - + "\n" - ) - ret_str += "-------------------------------------------------\n" - - ret_str += self.get_indentation_infromation(content, start or len(lines)) - - ret_str += ( - "Your changes have NOT been applied. Please fix your edit command and try again.\n" + guidance_message = self._get_indentation_info(content, start or len(lines)) + guidance_message += ( "You either need to 1) Specify the correct start/end line arguments or 2) Correct your edit code.\n" "DO NOT re-run the same failed edit command. Running it again will lead to the same error." ) + lint_error_info = ERROR_GUIDANCE.format( + linter_error_msg=LINTER_ERROR_MSG + lint_error, + window_after_applied=self._print_window(file_name, show_line, n_added_lines + 20), + window_before_applied=self._print_window( + original_file_backup_path, show_line, n_added_lines + 20 + ), + guidance_message=guidance_message, + ).strip() # recover the original file with original_file_backup_path.open() as fin, file_name.open("w") as fout: fout.write(fin.read()) original_file_backup_path.unlink() - return ret_str + return lint_error_info except FileNotFoundError as e: - ret_str += f"File not found: {e}\n" + return f"File not found: {e}\n" except IOError as e: - ret_str += f"An error occurred while handling the file: {e}\n" + return f"An error occurred while handling the file: {e}\n" except ValueError as e: - ret_str += f"Invalid input: {e}\n" + return f"Invalid input: {e}\n" except Exception as e: - error_str = "[This is how your edit would have looked if applied]\n" - error_str += "-------------------------------------------------\n" - error_str += self._print_window(file_name, start or len(lines), 40) + "\n" - error_str += "-------------------------------------------------\n" - error_str += self.get_indentation_infromation(content, start or len(lines)) - if not is_insert and not is_append: - error_str += "enlarge the range of original code." - error_str += "\nTry to enlarge the range of the orginal code" + guidance_message = self._get_indentation_info(content, start or len(lines)) + guidance_message += ( + "You either need to 1) Specify the correct start/end line arguments or 2) Enlarge the range of original code.\n" + "DO NOT re-run the same failed edit command. Running it again will lead to the same error." + ) + error_info = ERROR_GUIDANCE.format( + linter_error_msg=LINTER_ERROR_MSG + str(e), + window_after_applied=self._print_window(file_name, start or len(lines), 40), + window_before_applied=self._print_window(original_file_backup_path, start or len(lines), 40), + guidance_message=guidance_message, + ).strip() # Clean up the temporary file if an error occurs with original_file_backup_path.open() as fin, file_name.open("w") as fout: fout.write(fin.read()) if temp_file_path and Path(temp_file_path).exists(): Path(temp_file_path).unlink() - logger.warning(f"An unexpected error occurred: {e}") - raise Exception(f"{error_str}") from e - # raise e + # logger.warning(f"An unexpected error occurred: {e}") + raise Exception(f"{error_info}") from e # Update the file information and print the updated content with file_name.open("r", encoding="utf-8") as file: @@ -717,11 +746,13 @@ class Editor(BaseModel): self.current_line = max(1, len(lines)) # end of original file else: self.current_line = start or n_total_lines or 1 - ret_str += f"[File: {file_name.resolve()} ({n_total_lines} lines total after edit)]\n" - CURRENT_FILE = file_name - ret_str += self._print_window(CURRENT_FILE, self.current_line, self.window) + "\n" - ret_str += MSG_FILE_UPDATED.format(line_number=self.current_line) - return ret_str + cuccess_edit_info = SUCCESS_EDITE_INFO.format( + file_name=file_name.resolve(), + n_total_lines=n_total_lines, + window_after_applied=self._print_window(file_name, self.current_line, self.window), + line_number=self.current_line, + ).strip() + return cuccess_edit_info def edit_file_by_replace(self, file_name: str, to_replace: str, new_content: str) -> str: """Edit a file. This will search for `to_replace` in the given file and replace it with `new_content`. diff --git a/tests/metagpt/environment/mgx_env/run_mgx_env.py b/tests/metagpt/environment/mgx_env/run_mgx_env.py index b495d376a..f0f561774 100644 --- a/tests/metagpt/environment/mgx_env/run_mgx_env.py +++ b/tests/metagpt/environment/mgx_env/run_mgx_env.py @@ -8,7 +8,6 @@ from metagpt.environment.mgx.mgx_env import MGXEnv from metagpt.roles import Architect, Engineer, ProductManager, ProjectManager from metagpt.roles.di.data_analyst import DataAnalyst from metagpt.roles.di.engineer2 import Engineer2 -from metagpt.roles.di.swe_agent import SWEAgent from metagpt.roles.di.team_leader import TeamLeader from metagpt.schema import Message @@ -29,7 +28,6 @@ async def main(requirement="", enable_human_input=False, use_fixed_sop=False, al engineer, # QaEngineer(), DataAnalyst(), - SWEAgent(), ] ) diff --git a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py index 86258edbc..1e6d94d4e 100644 --- a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py +++ b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py @@ -1,9 +1,11 @@ +import argparse import asyncio import json import os import shutil import sys from datetime import datetime +from pathlib import Path from metagpt.config2 import Config from metagpt.const import DEFAULT_WORKSPACE_ROOT, METAGPT_ROOT @@ -16,8 +18,7 @@ from metagpt.tools.swe_agent_commands.swe_agent_utils import load_hf_dataset config = Config.default() # Specify by yourself Role = Engineer2 -# 调整每个样例的执行时间,太低容易出现提交u数量少的情况 -MAX_MINUTES_PRE_INSTANCE = 20 +global_terminal = Terminal() TEST_REPO_DIR = METAGPT_ROOT / "data" / "test_repo" DATA_DIR = METAGPT_ROOT / "data/hugging_face" @@ -58,29 +59,53 @@ def check_instance_status(instance, swe_result_dir): return True -async def run(instance, swe_result_dir): - if not check_instance_status(instance, swe_result_dir): - logger.info(f"Instance {instance['instance_id']} already exists, skipping execution.") - return +async def terminal_run_command(cmd): + cmd_output = await global_terminal.run_command(cmd) + logger.info(f"command:{cmd} output:\n {cmd_output}") + return cmd_output - repo_path = TEST_REPO_DIR / (instance["repo"].replace("-", "_").replace("/", "__") + "_" + instance["version"]) - # 下载仓库 - logger.info(f"repo_path:{repo_path}") - if os.path.exists(repo_path): - # 删除已有的仓库 - logger.info(f"remove exist repo path:{repo_path}") - shutil.rmtree(repo_path) - # 下载仓库 并切换分支 - terminal = Terminal() + +async def refresh_repo(instance, test_repo_dir): + repo_path = Path(test_repo_dir) / ( + instance["repo"].replace("-", "_").replace("/", "__") + "_" + instance["version"] + ) repo_identifier = instance["repo"] base_commit = instance["base_commit"] clone_command = f"git clone 'https://github.com/{repo_identifier}.git' {repo_path}" checkout_command = f"cd {repo_path} && git checkout -f {base_commit}" if base_commit else "" - await terminal.run_command(clone_command) - ignore_temp_file_cmd = "echo '.backup.*' >> .gitignore" - logger.info(await terminal.run_command(checkout_command)) - logger.info(await terminal.run_command("git branch")) - await terminal.run_command(ignore_temp_file_cmd) + + if os.path.exists(repo_path): + # 删除已有的仓库 + logger.info(f"remove exist repo path:{repo_path}") + shutil.rmtree(repo_path) + + await terminal_run_command(clone_command) + await terminal_run_command(checkout_command) + await terminal_run_command("git branch") + await terminal_run_command("echo '.backup.*' >> .gitignore") + + return repo_path + + +async def get_git_diff(): + git_diff = "" + try: + await terminal_run_command("git add -A") + git_diff = await terminal_run_command("git diff --cached") + except Exception as e: + logger.error(f"Error during submission: {e}") + return git_diff + + +async def run(instance, swe_result_dir, args): + if not check_instance_status(instance, swe_result_dir) and not args.cover: + logger.info(f"Instance {instance['instance_id']} already exists, skipping execution.") + return + + # preparation for the repo + logger.info(f"**** Preparing to run {instance['instance_id']}****") + test_repo_dir = args.test_repo_dir + repo_path = await refresh_repo(instance, test_repo_dir) user_requirement_and_issue = INSTANCE_TEMPLATE.format( issue=instance["problem_statement"], @@ -94,21 +119,21 @@ async def run(instance, swe_result_dir): logger.info("User Requirement", user_requirement_and_issue) try: role = Role(run_eval=True, editor=Editor(enable_auto_lint=True)) - await asyncio.wait_for(role.run(user_requirement_and_issue), timeout=MAX_MINUTES_PRE_INSTANCE * 60) + await asyncio.wait_for(role.run(user_requirement_and_issue), timeout=args.max_wait_time_per_case * 60) except Exception as e: print(e) logger.info(f"**** exception lead to end: {instance['instance_id']}****") pass - - save_predictions(role, instance, swe_result_dir) + # save the difference of repo + await save_predictions(role, instance, swe_result_dir) logger.info(f"**** Finished running {instance['instance_id']}****") -def save_predictions(role, instance, swe_result_dir): +async def save_predictions(role, instance, swe_result_dir): output_file = swe_result_dir / "all_preds.jsonl" instance["model_name_or_path"] = role.config.llm.model - instance["model_patch"] = role.output_diff - logger.info("model_patch:" + role.output_diff) + instance["model_patch"] = await get_git_diff() + logger.info(f"{instance['model_patch']=}") logger.info(f"Preparing to save predictions to {output_file}") # Save the predictions to a JSONL file @@ -118,31 +143,63 @@ def save_predictions(role, instance, swe_result_dir): logger.info(f"Saved prediction of {instance['instance_id']} to {output_file}") -async def async_main(): +async def async_main(args): dataset_path = "manna-ai/SWE-bench_Nano" # "princeton-nlp/SWE-bench_Lite" #"manna-ai/SWE-bench_Nano" - dataset = load_hf_dataset(dataset_name_or_path=dataset_path, cache_dir=DATA_DIR, split="test") - date_time = datetime.now().strftime("%m%d") - _round = "first" + swe_result_dir = Path(args.save_folder) + if swe_result_dir.exists(): + if args.cover: + logger.info(f"{swe_result_dir} exists and original result remove") + shutil.rmtree(swe_result_dir.absolute()) + else: + logger.info(f"{swe_result_dir} exists and continue test") - exp_name = f"nano_mgx_{date_time}_{_round}" - - now = datetime.now() - formatted_time = now.strftime("%Y_%m_%d_%H_%M_%S") - swe_result_dir = ( - DEFAULT_WORKSPACE_ROOT / f"result_{config.llm.model.replace('/', '_')}_start_time_{formatted_time}" / exp_name - ) - # swe_result_dir = ( - # DEFAULT_WORKSPACE_ROOT / f"result_{config.llm.model.replace('/', '_')}" / exp_name - # ) swe_result_dir.mkdir(parents=True, exist_ok=True) for index, instance in enumerate(dataset): # switch to a new logger file + if index < args.ignore_first_n: + continue logger.remove() logger.add(sys.stderr, level="INFO") - logger.add(swe_result_dir / f"{index+1}_{instance['instance_id']}.log", level="DEBUG") - await run(instance, swe_result_dir) + logger.add(swe_result_dir / "logs" / f"{index+1}_{instance['instance_id']}.log", level="DEBUG") + await run(instance, swe_result_dir, args) if __name__ == "__main__": - asyncio.run(async_main()) + parser = argparse.ArgumentParser(description="the argument of scripts") + # 添加参数 + swe_result_dir = ( + DEFAULT_WORKSPACE_ROOT + / f"result_{config.llm.model.replace('/', '_')}_start_time_{datetime.now().strftime('%Y_%m_%d_%H_%M_%S') }" + ) + test_repo_dir = TEST_REPO_DIR.absolute() + swe_result_dir = swe_result_dir.absolute() + parser.add_argument( + "-rw", "--test_repo_dir", default=test_repo_dir, help="The directory to save temporary repositories", type=str + ) + parser.add_argument("-s", "--save_folder", default=swe_result_dir, help="Folder to save results and logs", type=str) + parser.add_argument( + "-mwtc", "--max_wait_time_per_case", help="Maximum wait time allowed per test case (in minutes)", type=int + ) + parser.add_argument("-n", "--ignore_first_n", default=0, help="Cover the original flag", type=int) + parser.add_argument("-c", "--cover", default=False, help="Cover the original flag", type=bool) + # 解析命令行参数 + args = parser.parse_args() + asyncio.run(async_main(args)) + + +""" +python tests/metagpt/roles/di/run_swe_agent_for_benchmark.py \ +--test_repo_dir "./data/test_repo" \ +--save_folder "./workspace/deepseek_coder_test1" \ +--max_wait_time_per_case 10 +""" + +""" +Cover Mode: +python tests/metagpt/roles/di/run_swe_agent_for_benchmark.py \ +--test_repo_dir "./data/test_repo" \ +--save_folder "./workspace/deepseek_coder_test1" \ +--max_wait_time_per_case 10 \ +--cover +""" From 5aab97554c960c9cd4165e48ae0922923d66cccd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Thu, 5 Sep 2024 19:42:16 +0800 Subject: [PATCH 20/25] update engineer prompt --- metagpt/prompts/di/engineer2.py | 9 ++++--- metagpt/strategy/experience_retriever.py | 31 +----------------------- metagpt/tools/libs/editor.py | 4 +-- 3 files changed, 9 insertions(+), 35 deletions(-) diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index 113551212..afe904c92 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -6,7 +6,7 @@ You are an autonomous programmer The special interface consists of a file editor that shows you 100 lines of a file at a time. You can use terminal commands (e.g., cat, ls, cd) by calling Terminal.run_command. -Do Not run the code. + You should carefully observe the behavior and results of the previous action, and avoid triggering repeated errors. @@ -75,10 +75,13 @@ Note: 18. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. If you only need to code one file, provide all the necessary information in one response. 19. When the requirement is simple, you don't need to create a plan, just do it right away. 20. If the code exists, use the Editor tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. -21. Forbidden to run code in the terminal. + 22. When using the editor, pay attention to the editor's current directory. When you use editor tools, the paths must be either absolute or relative to the editor's current directory. """ - +""" +21. Forbidden to run code in the terminal. +Do Not run the code. +""" CURRENT_STATE = """ The current editor state is: (Editor current directory: {editor_current_directory}) diff --git a/metagpt/strategy/experience_retriever.py b/metagpt/strategy/experience_retriever.py index 4c649f0d2..416e16279 100644 --- a/metagpt/strategy/experience_retriever.py +++ b/metagpt/strategy/experience_retriever.py @@ -1007,24 +1007,8 @@ Since Editor.insert_content_at_line can only be used once per response, this tim In the next turn, I will try to add another code snippet ## example 11 -``` -#### Save the changes and commit them to the remote repository. -##### Push the changes from the local repository to the remote repository. -Thought: All changes have been saved, let's push the code to the remote repository. -```json -[ - { - "command_name": "Terminal.run_command", - "args": { - "cmd": "git push origin test-fix" - } - } -] -``` - - -##### Create a pull request (Optional): Merge the changes from the new branch into the master branch. +Create a pull request (Optional): Merge the changes from the new branch into the master branch. Thought: Now that the changes have been pushed to the remote repository, due to the user's requirement, let's create a pull request to merge the changes into the master branch. ```json [ @@ -1044,19 +1028,6 @@ Thought: Now that the changes have been pushed to the remote repository, due to ``` """ -""" -## example 11 -I have finished all the tasks, so I will use Plan.finish_current_task and then follow the command ‘end’ to stop. -```json -[ - { - "command_name": "end", - "args": { - } - } -] -``` -""" WEB_SCRAPING_EXAMPLE = """ ## action 1 User Requirement: Scrap and list the restaurant names of first page by searching for the keyword `beef` on the website https://www.yelp.com/. diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index 46c9d3315..d35e97d07 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -747,13 +747,13 @@ class Editor(BaseModel): self.current_line = max(1, len(lines)) # end of original file else: self.current_line = start or n_total_lines or 1 - cuccess_edit_info = SUCCESS_EDITE_INFO.format( + success_edit_info = SUCCESS_EDITE_INFO.format( file_name=file_name.resolve(), n_total_lines=n_total_lines, window_after_applied=self._print_window(file_name, self.current_line, self.window), line_number=self.current_line, ).strip() - return cuccess_edit_info + return success_edit_info def edit_file_by_replace(self, file_name: str, to_replace: str, new_content: str) -> str: """Edit a file. This will search for `to_replace` in the given file and replace it with `new_content`. From b8e023d8bc0da054ffe13c9d9f82f2eeeeb2e397 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Thu, 5 Sep 2024 20:27:20 +0800 Subject: [PATCH 21/25] update engineer2 prompt --- metagpt/prompts/di/engineer2.py | 2 +- tests/metagpt/roles/di/run_swe_agent_for_benchmark.py | 9 ++++----- 2 files changed, 5 insertions(+), 6 deletions(-) diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index 2e8d2872e..0a42a7e42 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -72,7 +72,7 @@ Note: 15. When provided system design or project schedule, you MUST read them first before making a plan, then adhere to them in your implementation, especially in the programming language, package, or framework. You MUST implement all code files prescribed in the system design or project schedule. You can create a plan first with each task corresponding to implementing one code file. 16. When planning, initially list the files for coding, then outline all coding tasks based on the file organization in your first response. 17. If you plan to read a file, do not include other plans in the same response. -18. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. If you only need to code one file, provide all the necessary information in one response. +18. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. Write only one code file each time and provide its full implementation. 19. When the requirement is simple, you don't need to create a plan, just do it right away. 20. If the code exists, use the Editor tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. 21. When using the editor, pay attention to the editor's current directory. When you use editor tools, the paths must be either absolute or relative to the editor's current directory. diff --git a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py index 1e6d94d4e..2d9617442 100644 --- a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py +++ b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py @@ -17,8 +17,7 @@ from metagpt.tools.swe_agent_commands.swe_agent_utils import load_hf_dataset config = Config.default() # Specify by yourself -Role = Engineer2 -global_terminal = Terminal() +GLOBAL_TERMINAL = Terminal() TEST_REPO_DIR = METAGPT_ROOT / "data" / "test_repo" DATA_DIR = METAGPT_ROOT / "data/hugging_face" @@ -60,7 +59,7 @@ def check_instance_status(instance, swe_result_dir): async def terminal_run_command(cmd): - cmd_output = await global_terminal.run_command(cmd) + cmd_output = await GLOBAL_TERMINAL.run_command(cmd) logger.info(f"command:{cmd} output:\n {cmd_output}") return cmd_output @@ -118,7 +117,7 @@ async def run(instance, swe_result_dir, args): logger.info(f"**** Starting to run {instance['instance_id']}****") logger.info("User Requirement", user_requirement_and_issue) try: - role = Role(run_eval=True, editor=Editor(enable_auto_lint=True)) + role = Engineer2(run_eval=True, editor=Editor(enable_auto_lint=True)) await asyncio.wait_for(role.run(user_requirement_and_issue), timeout=args.max_wait_time_per_case * 60) except Exception as e: print(e) @@ -156,9 +155,9 @@ async def async_main(args): swe_result_dir.mkdir(parents=True, exist_ok=True) for index, instance in enumerate(dataset): - # switch to a new logger file if index < args.ignore_first_n: continue + # switch to a new logger file logger.remove() logger.add(sys.stderr, level="INFO") logger.add(swe_result_dir / "logs" / f"{index+1}_{instance['instance_id']}.log", level="DEBUG") From 32365ad85c740b45a1230bb43ab077a595b9284c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Thu, 5 Sep 2024 20:28:24 +0800 Subject: [PATCH 22/25] update engineer2 prompt --- metagpt/prompts/di/engineer2.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index 0a42a7e42..70e45acb5 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -72,7 +72,7 @@ Note: 15. When provided system design or project schedule, you MUST read them first before making a plan, then adhere to them in your implementation, especially in the programming language, package, or framework. You MUST implement all code files prescribed in the system design or project schedule. You can create a plan first with each task corresponding to implementing one code file. 16. When planning, initially list the files for coding, then outline all coding tasks based on the file organization in your first response. 17. If you plan to read a file, do not include other plans in the same response. -18. Use Engineer2.write_new_code to create or modify a file. Write only one code file each time. Write only one code file each time and provide its full implementation. +18. Write only one code file each time and provide its full implementation. 19. When the requirement is simple, you don't need to create a plan, just do it right away. 20. If the code exists, use the Editor tool's open and edit commands to modify it. Since it is not a new code, do not use write_new_code. 21. When using the editor, pay attention to the editor's current directory. When you use editor tools, the paths must be either absolute or relative to the editor's current directory. From 4063186836f3661f5f34397698769b451c5f5262 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Fri, 6 Sep 2024 12:04:40 +0800 Subject: [PATCH 23/25] update run_swe_bechmark script --- metagpt/roles/di/engineer2.py | 30 ++++---- metagpt/tools/libs/editor.py | 21 +++--- .../roles/di/run_swe_agent_for_benchmark.py | 70 ++++++++++--------- 3 files changed, 65 insertions(+), 56 deletions(-) diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 5b264e85e..2310650b3 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -68,26 +68,31 @@ class Engineer2(RoleZero): def _update_tool_execution(self): # validate = ValidateAndRewriteCode() cr = CodeReview() - self.tool_execution_map.update( - { - "Terminal.run_command": self.terminal.run_command, - "git_create_pull": git_create_pull, - "Engineer2.write_new_code": self.write_new_code, - "CodeReview.review": cr.review, - "CodeReview.fix": cr.fix, - # "ValidateAndRewriteCode.run": validate.run, - # "ValidateAndRewriteCode": validate.run, - } - ) self.exclusive_tool_commands.append("Engineer2.write_new_code") - if self.run_eval: + if self.run_eval is True: + # Evalute tool map self.tool_execution_map.update( { + "git_create_pull": git_create_pull, + "Engineer2.write_new_code": self.write_new_code, + "CodeReview.review": cr.review, + "CodeReview.fix": cr.fix, "Terminal.run_command": self._eval_terminal_run, "RoleZero.ask_human": self._end, "RoleZero.reply_to_human": self._end, } ) + else: + # Default tool map + self.tool_execution_map.update( + { + "git_create_pull": git_create_pull, + "Engineer2.write_new_code": self.write_new_code, + "CodeReview.review": cr.review, + "CodeReview.fix": cr.fix, + "Terminal.run_command": self.terminal.run_command, + } + ) async def _act(self) -> Message: message = await super()._act() @@ -108,6 +113,7 @@ class Engineer2(RoleZero): async def write_new_code(self, path: str, instruction: str = "") -> str: """Write a new code file. + Args: path (str): The absolute path of the file to be created. instruction (optional, str): Further hints or notice other than the current task instruction, must be very concise and can be empty. Defaults to "". diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index d35e97d07..d35fb124a 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -537,15 +537,14 @@ class Editor(BaseModel): content = "".join(new_lines) return content, n_added_lines - def _get_indentation_info(self, content, first_error_line): + def _get_indentation_info(self, content, first_line): """ - Information about the current edit's indentation. - Includes guidance on how to fix it. + The indentation of the first insert line and the previous line, along with guidance for the next attempt. """ content_lines = content.split("\n") - pre_line = content_lines[first_error_line - 2] if first_error_line - 2 >= 0 else "" + pre_line = content_lines[first_line - 2] if first_line - 2 >= 0 else "" pre_line_indent = len(pre_line) - len(pre_line.lstrip()) - insert_line = content_lines[first_error_line - 1] + insert_line = content_lines[first_line - 1] insert_line_indent = len(insert_line) - len(insert_line.lstrip()) ret_str = INDENTATION_INFO.format( pre_line=pre_line, @@ -802,8 +801,8 @@ class Editor(BaseModel): new_content: str: The new content to replace the old content with. NOTE: - This tool is exclusive. If you use this tool, you cannot use any other commands in the current response. - If you need to use it multiple times, wait for the next turn. + This tool is exclusive. If you use this tool, you cannot use any other commands in the current response. + If you need to use it multiple times, wait for the next turn. """ # FIXME: support replacing *all* occurrences if to_replace.strip() == "": @@ -881,8 +880,8 @@ class Editor(BaseModel): line_number: int: The line number (starting from 1) to insert the content after. content: str: The content to insert. NOTE: - This tool is exclusive. If you use this tool, you cannot use any other commands in the current response. - If you need to use it multiple times, wait for the next turn. + This tool is exclusive. If you use this tool, you cannot use any other commands in the current response. + If you need to use it multiple times, wait for the next turn. """ file_name = self._try_fix_path(file_name) @@ -904,8 +903,8 @@ class Editor(BaseModel): file_name: str: The name of the file to edit. content: str: The content to insert. NOTE: - This tool is exclusive. If you use this tool, you cannot use any other commands in the current response. - If you need to use it multiple times, wait for the next turn. + This tool is exclusive. If you use this tool, you cannot use any other commands in the current response. + If you need to use it multiple times, wait for the next turn. """ file_name = self._try_fix_path(file_name) diff --git a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py index 2d9617442..4071bcf8e 100644 --- a/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py +++ b/tests/metagpt/roles/di/run_swe_agent_for_benchmark.py @@ -64,23 +64,31 @@ async def terminal_run_command(cmd): return cmd_output -async def refresh_repo(instance, test_repo_dir): +async def refresh_repo(instance, test_repo_dir, reclone_existing_repo=False): repo_path = Path(test_repo_dir) / ( instance["repo"].replace("-", "_").replace("/", "__") + "_" + instance["version"] ) repo_identifier = instance["repo"] base_commit = instance["base_commit"] - clone_command = f"git clone 'https://github.com/{repo_identifier}.git' {repo_path}" - checkout_command = f"cd {repo_path} && git checkout -f {base_commit}" if base_commit else "" - - if os.path.exists(repo_path): - # 删除已有的仓库 + if os.path.exists(repo_path) and reclone_existing_repo is True: logger.info(f"remove exist repo path:{repo_path}") shutil.rmtree(repo_path) - await terminal_run_command(clone_command) - await terminal_run_command(checkout_command) + if os.path.exists(repo_path): + logger.info(f"reset exist repo path:{repo_path}") + await terminal_run_command(f"cd {repo_path} && git reset --hard && git clean -n -d && git clean -f -d") + await terminal_run_command("BRANCH=$(git remote show origin | awk '/HEAD branch/ {print $NF}')") + await terminal_run_command("echo $BRANCH") + await terminal_run_command('git checkout "$BRANCH"') + else: + logger.info(f"clone repo to path:{repo_path}") + clone_command = f"git clone 'https://github.com/{repo_identifier}.git' {repo_path}" + checkout_command = f"cd {repo_path} " + "&& git checkout -f {base_commit}" if base_commit else "" + await terminal_run_command(clone_command) + await terminal_run_command(checkout_command) + await terminal_run_command("git branch") + # ignore backup file await terminal_run_command("echo '.backup.*' >> .gitignore") return repo_path @@ -97,14 +105,14 @@ async def get_git_diff(): async def run(instance, swe_result_dir, args): - if not check_instance_status(instance, swe_result_dir) and not args.cover: + if not check_instance_status(instance, swe_result_dir): logger.info(f"Instance {instance['instance_id']} already exists, skipping execution.") return # preparation for the repo logger.info(f"**** Preparing to run {instance['instance_id']}****") test_repo_dir = args.test_repo_dir - repo_path = await refresh_repo(instance, test_repo_dir) + repo_path = await refresh_repo(instance, test_repo_dir, args.reclone_existing_repo) user_requirement_and_issue = INSTANCE_TEMPLATE.format( issue=instance["problem_statement"], @@ -117,22 +125,20 @@ async def run(instance, swe_result_dir, args): logger.info(f"**** Starting to run {instance['instance_id']}****") logger.info("User Requirement", user_requirement_and_issue) try: - role = Engineer2(run_eval=True, editor=Editor(enable_auto_lint=True)) - await asyncio.wait_for(role.run(user_requirement_and_issue), timeout=args.max_wait_time_per_case * 60) + engineer = Engineer2(run_eval=True, editor=Editor(enable_auto_lint=True)) + await asyncio.wait_for(engineer.run(user_requirement_and_issue), timeout=args.max_wait_time_per_case * 60) except Exception as e: - print(e) - logger.info(f"**** exception lead to end: {instance['instance_id']}****") - pass + logger.warning(f"**** exception lead to end: {instance['instance_id']}****\n\nerror:{e}") # save the difference of repo - await save_predictions(role, instance, swe_result_dir) + await save_predictions(engineer, instance, swe_result_dir) logger.info(f"**** Finished running {instance['instance_id']}****") -async def save_predictions(role, instance, swe_result_dir): +async def save_predictions(engineer, instance, swe_result_dir): output_file = swe_result_dir / "all_preds.jsonl" - instance["model_name_or_path"] = role.config.llm.model + instance["model_name_or_path"] = engineer.config.llm.model instance["model_patch"] = await get_git_diff() - logger.info(f"{instance['model_patch']=}") + logger.info(f"'model_patch':\n{instance['model_patch']}") logger.info(f"Preparing to save predictions to {output_file}") # Save the predictions to a JSONL file @@ -147,16 +153,9 @@ async def async_main(args): dataset = load_hf_dataset(dataset_name_or_path=dataset_path, cache_dir=DATA_DIR, split="test") swe_result_dir = Path(args.save_folder) if swe_result_dir.exists(): - if args.cover: - logger.info(f"{swe_result_dir} exists and original result remove") - shutil.rmtree(swe_result_dir.absolute()) - else: - logger.info(f"{swe_result_dir} exists and continue test") - + logger.info(f"{swe_result_dir} exists; resuming test from last checkpoint.") swe_result_dir.mkdir(parents=True, exist_ok=True) for index, instance in enumerate(dataset): - if index < args.ignore_first_n: - continue # switch to a new logger file logger.remove() logger.add(sys.stderr, level="INFO") @@ -180,25 +179,30 @@ if __name__ == "__main__": parser.add_argument( "-mwtc", "--max_wait_time_per_case", help="Maximum wait time allowed per test case (in minutes)", type=int ) - parser.add_argument("-n", "--ignore_first_n", default=0, help="Cover the original flag", type=int) - parser.add_argument("-c", "--cover", default=False, help="Cover the original flag", type=bool) + parser.add_argument( + "-o", + "--reclone_existing_repo", + action="store_true", + help="If set, the existing repository will be removed and recloned.", + ) # 解析命令行参数 args = parser.parse_args() asyncio.run(async_main(args)) """ +# python tests/metagpt/roles/di/run_swe_agent_for_benchmark.py \ --test_repo_dir "./data/test_repo" \ ---save_folder "./workspace/deepseek_coder_test1" \ +--save_folder "./workspace/deepseek_coder_0907" \ --max_wait_time_per_case 10 """ """ -Cover Mode: +# 重新克隆仓库 python tests/metagpt/roles/di/run_swe_agent_for_benchmark.py \ --test_repo_dir "./data/test_repo" \ ---save_folder "./workspace/deepseek_coder_test1" \ +--save_folder "./workspace/deepseek_coder_0907" \ --max_wait_time_per_case 10 \ ---cover +--reclone_existing_repo """ From a68f9efce5e3b90fbf0fb26b8159744715ed7e54 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Fri, 6 Sep 2024 14:08:04 +0800 Subject: [PATCH 24/25] Remove duplicate '_act' in engineer. --- metagpt/roles/di/engineer2.py | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 2310650b3..dd21dee53 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -14,7 +14,7 @@ from metagpt.prompts.di.engineer2 import ( WRITE_CODE_SYSTEM_PROMPT, ) from metagpt.roles.di.role_zero import RoleZero -from metagpt.schema import Message, UserMessage +from metagpt.schema import UserMessage from metagpt.strategy.experience_retriever import ENGINEER_EXAMPLE from metagpt.tools.libs.cr import CodeReview from metagpt.tools.libs.git import git_create_pull @@ -94,10 +94,6 @@ class Engineer2(RoleZero): } ) - async def _act(self) -> Message: - message = await super()._act() - return message - def _retrieve_experience(self) -> str: return ENGINEER_EXAMPLE From 6e49c2475f362941bb4c62b94c283dd504e88efd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=BB=84=E4=BC=9F=E9=9F=AC?= Date: Fri, 6 Sep 2024 14:33:09 +0800 Subject: [PATCH 25/25] update comment --- metagpt/roles/di/role_zero.py | 3 ++- metagpt/tools/libs/editor.py | 4 ++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index 386dbd43d..db8d2aa5f 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -73,7 +73,8 @@ class RoleZero(Role): tool_recommender: Optional[ToolRecommender] = None tool_execution_map: Annotated[dict[str, Callable], Field(exclude=True)] = {} special_tool_commands: list[str] = ["Plan.finish_current_task", "end", "Bash.run"] - # List of exclusive tool commands + # List of exclusive tool commands. + # If multiple instances of these commands appear, only the first occurrence will be retained. exclusive_tool_commands: list[str] = [ "Editor.edit_file_by_replace", "Editor.insert_content_at_line", diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index d35fb124a..e358c2288 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -58,7 +58,7 @@ Your changes have NOT been applied. Please fix your edit command and try again """ -SUCCESS_EDITE_INFO = """ +SUCCESS_EDIT_INFO = """ [File: {file_name} ({n_total_lines} lines total after edit)] {window_after_applied} [File updated (edited at line {line_number}). Please review the changes and make sure they are correct (correct indentation, no duplicate lines, etc). Edit the file again if necessary.] @@ -746,7 +746,7 @@ class Editor(BaseModel): self.current_line = max(1, len(lines)) # end of original file else: self.current_line = start or n_total_lines or 1 - success_edit_info = SUCCESS_EDITE_INFO.format( + success_edit_info = SUCCESS_EDIT_INFO.format( file_name=file_name.resolve(), n_total_lines=n_total_lines, window_after_applied=self._print_window(file_name, self.current_line, self.window),