Merge branch 'editor_fix_issue' into 'mgx_ops'

Add issue fixing ability

See merge request pub/MetaGPT!101
This commit is contained in:
林义章 2024-05-13 07:18:44 +00:00
commit 97034e00b5
14 changed files with 228 additions and 133 deletions

View file

@ -9,8 +9,8 @@ from metagpt.roles.di.data_interpreter import DataInterpreter
REQ = """
# Requirement
Below is a github issue, solve it. Use FileManager to search for the function, understand it, and modify the relevant code.
Write a new test file test.py with FileManager and use Terminal to python the test file to ensure you have fixed the issue.
Below is a github issue, solve it. Use Editor to search for the function, understand it, and modify the relevant code.
Write a new test file test.py with Editor and use Terminal to python the test file to ensure you have fixed the issue.
When writing test.py, you should import the function from the file you modified and test it with the given input.
Notice: Don't write all codes in one response, each time, just write code for one step.
@ -24,7 +24,7 @@ resulted in an infinite loop for the react mode.
async def main():
di = DataInterpreter(tools=["Terminal", "FileManager"], react_mode="react")
di = DataInterpreter(tools=["Terminal", "Editor"], react_mode="react")
await di.run(REQ)

View file

@ -11,7 +11,7 @@ Notice: Don't write all codes in one response, each time, just write code for on
async def main():
di = DataInterpreter(tools=["Terminal", "FileManager"])
di = DataInterpreter(tools=["Terminal", "Editor"])
await di.run(USE_GOT_REPO_REQ)

View file

@ -246,6 +246,8 @@ class ExecuteNbCode(Action):
if "!pip" in code:
success = False
outputs = outputs[-INSTALL_KEEPLEN:]
elif "git clone" in code:
outputs = outputs[:INSTALL_KEEPLEN] + "..." + outputs[-INSTALL_KEEPLEN:]
elif language == "markdown":
# add markdown content to markdown cell in a notebook.

View file

@ -25,6 +25,7 @@ from metagpt.utils.common import CodeParser
class DataAnalyst(DataInterpreter):
name: str = "David"
profile: str = "DataAnalyst"
goal: str = "Take on any data-related tasks, such as data analysis, machine learning, deep learning, web browsing, web scraping, web searching, web deployment, terminal operation, git and github operation, etc."
react_mode: Literal["react"] = "react"
max_react_loop: int = 20 # used for react mode
task_result: TaskResult = None
@ -55,7 +56,7 @@ class DataAnalyst(DataInterpreter):
self._set_state(0)
# HACK: Init Planner, control it through dynamic thinking; Consider formalizing as a react mode
self.planner = Planner(goal=self.goal, working_memory=self.rc.working_memory, auto_run=True)
self.planner = Planner(goal="", working_memory=self.rc.working_memory, auto_run=True)
return self
@ -69,6 +70,7 @@ class DataAnalyst(DataInterpreter):
example = KeywordExpRetriever().retrieve(self.user_requirement)
else:
self.working_memory.add_batch(self.rc.news)
# TODO: implement experience retrieval in multi-round setting
plan_status = self.planner.plan.model_dump(include=["goal", "tasks"])
for task in plan_status["tasks"]:
@ -92,10 +94,19 @@ class DataAnalyst(DataInterpreter):
async def _act(self) -> Message:
"""Useful in 'react' mode. Return a Message conforming to Role._act interface."""
logger.info(f"ready to take on task {self.planner.plan.current_task}")
# TODO: Consider an appropriate location to insert task experience formally
experience = KeywordExpRetriever().retrieve(self.planner.plan.current_task.instruction, exp_type="task")
if experience and experience not in [msg.content for msg in self.rc.working_memory.get()]:
exp_msg = Message(content=experience, role="assistant")
self.rc.working_memory.add(exp_msg)
code, result, is_success = await self._write_and_exec_code()
self.planner.plan.current_task.is_success = (
is_success # mark is_success, determine is_finished later in thinking
)
# FIXME: task result is always overwritten by the last act, whereas it can be made of of multiple acts
self.task_result = TaskResult(code=code, result=result, is_success=is_success)
return Message(content="Task completed", role="assistant", sent_from=self._setting, cause_by=WriteAnalysisCode)

View file

@ -514,6 +514,13 @@ class Plan(BaseModel):
if task_id in self.task_map:
task = self.task_map[task_id]
task.reset()
# reset all downstream tasks that are dependent on the reset task
for dep_task in self.tasks:
if task_id in dep_task.dependent_task_ids:
# FIXME: if LLM generates cyclic tasks, this will result in infinite recursion
self.reset_task(dep_task.task_id)
self._update_current_task()
def replace_task(self, new_task: Task):
"""

View file

@ -1,3 +1,5 @@
from typing import Literal
from pydantic import BaseModel
@ -159,44 +161,126 @@ class SimpleExpRetriever(ExpRetriever):
class KeywordExpRetriever(ExpRetriever):
"""An experience retriever that returns examples based on keywords in the context."""
EXAMPLE: dict = {
"deploy": """
## example 1
User Requirement: launch a service from workspace/web_snake_game/web_snake_game, and deploy it to public
Explanation: Launching a service requires Terminal tool with daemon mode, write this into task instruction.
```json
[
{
"command_name": "append_task",
"args": {
"task_id": "1",
"dependent_task_ids": [],
"instruction": "Use the Terminal tool to launch the service in daemon mode",
"assignee": "David"
}
},
{
"command_name": "append_task",
"args": {
"task_id": "2",
"dependent_task_ids": ["1"],
"instruction": "Test the service with a simple request",
"assignee": "David"
}
},
{
"command_name": "append_task",
"args": {
"task_id": "3",
"dependent_task_ids": ["2"],
"instruction": "Deploy the service to public",
"assignee": "David"
}
},
"""
}
def retrieve(self, context: str) -> str:
if "deploy" in context.lower():
return self.EXAMPLE["deploy"]
def retrieve(self, context: str, exp_type: Literal["plan", "task"] = "plan") -> str:
if exp_type == "plan":
if "deploy" in context.lower():
return DEPLOY_EXAMPLE
elif "issue" in context.lower():
return FIX_ISSUE_EXAMPLE
elif exp_type == "task":
if "diagnose" in context.lower():
return SEARCH_SYMBOL_EXAMPLE
return ""
DEPLOY_EXAMPLE = """
## example 1
User Requirement: launch a service from workspace/web_snake_game/web_snake_game, and deploy it to public
Explanation: Launching a service requires Terminal tool with daemon mode, write this into task instruction.
```json
[
{
"command_name": "append_task",
"args": {
"task_id": "1",
"dependent_task_ids": [],
"instruction": "Use the Terminal tool to launch the service in daemon mode",
"assignee": "David"
}
},
{
"command_name": "append_task",
"args": {
"task_id": "2",
"dependent_task_ids": ["1"],
"instruction": "Test the service with a simple request",
"assignee": "David"
}
},
{
"command_name": "append_task",
"args": {
"task_id": "3",
"dependent_task_ids": ["2"],
"instruction": "Deploy the service to public",
"assignee": "David"
}
},
"""
FIX_ISSUE_EXAMPLE = """
## example 1
User Requirement: Write a fix for this issue: https://github.com/xxx/xxx/issues/xxx, and commit and push your changes.
Explanation: The requirement is for software development, focusing on fixing an issue in an existing repository. The process is broken down into several steps, each demanding specific actions and tools.
```json
[
{
"command_name": "append_task",
"args": {
"task_id": "1",
"dependent_task_ids": [],
"instruction": "Read the issue description to understand the problem using the Browser tool.",
"assignee": "David"
}
},
{
"command_name": "append_task",
"args": {
"task_id": "2",
"dependent_task_ids": ["1"],
"instruction": "Clone the repository using the Terminal tool.",
"assignee": "David"
}
},
{
"command_name": "append_task",
"args": {
"task_id": "3",
"dependent_task_ids": ["2"],
"instruction": "Use Editor to search the relevant function(s), then diagnose and identify the source of the problem.",
"assignee": "David"
}
},
{
"command_name": "append_task",
"args": {
"task_id": "4",
"dependent_task_ids": ["3"],
"instruction": "Use Editor tool to fix the problem in the corresponding file(s).",
"assignee": "David"
}
},
{
"command_name": "append_task",
"args": {
"task_id": "5",
"dependent_task_ids": ["4"],
"instruction": "Commit, push the changes to the repository.",
"assignee": "David"
}
},
]
```
"""
SEARCH_SYMBOL_EXAMPLE = """
## Past Experience
Issue: PaiEasChatEndpoint._call_eas should return bytes type instead of str type
Explanation: To understand the issue, we first need to know the content of the method in the codebase. Therefore, we search the symbol `def _call_eas` in the cloned repo langchain.
```python
from metagpt.tools.libs.editor import Editor
# Initialize the Editor tool
editor = Editor()
# Search for the PaiEasChatEndpoint._call_eas method in the codebase to understand the issue
symbol_to_search = "def _call_eas"
file_block = editor.search_content(symbol=symbol_to_search, root_path="langchain")
# Output the file block containing the method to diagnose the problem
file_block
```
"""

View file

@ -96,7 +96,13 @@ def run_plan_command(role: Role, cmd: list[dict]):
elif cmd["command_name"] == Command.RESET_TASK.cmd_name:
role.planner.plan.reset_task(**cmd["args"])
elif cmd["command_name"] == Command.REPLACE_TASK.cmd_name:
role.planner.plan.replace_task(Task(**cmd["args"]))
new_task = Task(
task_id=cmd["args"]["task_id"],
dependent_task_ids=cmd["args"]["new_dependent_task_ids"],
instruction=cmd["args"]["new_instruction"],
assignee=cmd["args"]["new_assignee"],
)
role.planner.plan.replace_task(new_task)
elif cmd["command_name"] == Command.FINISH_CURRENT_TASK.cmd_name:
if role.planner.plan.is_plan_finished():
return

View file

@ -12,7 +12,7 @@ from metagpt.tools.libs import (
# web_scraping,
# email_login,
terminal,
file_manager,
editor,
browser,
deployer,
git,
@ -27,7 +27,7 @@ _ = (
# web_scraping,
# email_login,
terminal,
file_manager,
editor,
browser,
deployer,
git,

View file

@ -1,3 +1,5 @@
from __future__ import annotations
from playwright.async_api import async_playwright
from metagpt.const import DEFAULT_WORKSPACE_ROOT
@ -5,10 +7,10 @@ from metagpt.tools.tool_registry import register_tool
from metagpt.utils.report import BrowserReporter
@register_tool()
@register_tool(tags=["web", "browse", "scrape"])
class Browser:
"""
A tool for browsing the web. Don't initialize a new instance of this class if one already exists.
A tool for browsing the web and scraping. Don't initialize a new instance of this class if one already exists.
Note: Combine searching and scrolling together to achieve most effective browsing. DON'T stick to one method.
"""
@ -31,7 +33,7 @@ class Browser:
self.current_page = page
self.current_page_url = url
print("Now on page ", url)
print(await self._view())
await self._view()
async def open_new_page(self, url: str):
"""open a new page in the browser and view the page"""
@ -51,6 +53,12 @@ class Browser:
else:
print(f"Page not found: {url}")
async def _view_page_html(self, keep_len: int = 5000) -> str:
"""view the HTML content of current page, return the HTML content as a string. When executed, the content will be printed out"""
html = await self.current_page.content()
html_content = html.strip()[:keep_len]
return html_content
async def search_content_all(self, search_term: str) -> list[dict]:
"""search all occurences of search term in the current page and return the search results with their position.
Useful if you have a keyword or sentence in mind and want to quickly narrow down the content relevant to it.
@ -142,10 +150,12 @@ class Browser:
await self.current_page.screenshot(path=path)
print(f"Screenshot saved to: {path}")
async def _view(self) -> str:
async def _view(self, keep_len: int = 5000) -> str:
"""simulate human viewing the current page, return the visible text with links"""
visible_text_with_links = await self.current_page.evaluate(VIEW_CONTENT_JS)
return visible_text_with_links
print("The visible text and their links (if any): ", visible_text_with_links[:keep_len])
# html_content = await self._view_page_html(keep_len=keep_len)
# print("The html content: ", html_content)
async def scroll_current_page(self, offset: int = 500):
"""scroll the current page by offset pixels, negative value means scrolling up, will print out observed content after scrolling"""

View file

@ -16,11 +16,13 @@ class FileBlock(BaseModel):
block_start_line: int
block_end_line: int
symbol: str = Field(default="", description="The symbol of interest in the block, empty if not applicable.")
symbol_line: int = Field(default=-1, description="The line number of the symbol in the file, -1 if not applicable")
symbol_start_line: int = Field(
default=-1, description="The line number of the symbol in the file, -1 if not applicable"
)
@register_tool()
class FileManager:
class Editor:
"""A tool for reading, understanding, writing, and editing files"""
def __init__(self) -> None:
@ -38,14 +40,15 @@ class FileManager:
self.resource.report(path, "path")
return f.read()
def search_content(self, symbol: str, root_path: str = "", window: int = 20) -> FileBlock:
def search_content(self, symbol: str, root_path: str = ".", window: int = 20) -> 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. If not provided, search in the current directory. Defaults to "".
root_path (str, optional): The root path to search in. If not provided, search in the current directory. Defaults to ".".
window (int, optional): The window size to return. Defaults to 20.
Returns:
@ -56,8 +59,11 @@ class FileManager:
block_start_line: int
block_end_line: int
symbol: str = Field(default="", description="The symbol of interest in the block, empty if not applicable.")
symbol_line: int = Field(default=-1, description="The line number of the symbol in the file, -1 if not applicable")
symbol_start_line: int = Field(default=-1, description="The line number of the symbol in the file, -1 if not applicable")
"""
if not os.path.exists(root_path):
print(f"Currently at {os.getcwd()}. Path {root_path} does not exist.")
return None
for root, _, files in os.walk(root_path or "."):
for file in files:
file_path = os.path.join(root, file)
@ -79,10 +85,13 @@ class FileManager:
block_start_line=start + 1,
block_end_line=end + 1,
symbol=symbol,
symbol_line=i + 1,
symbol_start_line=i + 1,
)
self.resource.report(result.file_path, "path")
return result
print(
"symbol not found, you may try searching another one, or break down your search term to search a part of it"
)
return None
def write_content(self, file_path: str, start_line: int, end_line: int, new_block_content: str = "") -> str:
@ -91,7 +100,7 @@ class FileManager:
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. Think carefully if you want to use block_start_line or symbol_line in the FileBlock as your start_line input.
This function can sometimes be used given a FileBlock upstream. Think carefully if you want to use block_start_line or symbol_start_line in the FileBlock as your start_line input. Your new_block_content will be placed at the start_line.
Args:
file_path (str): The file path to write the new block content.
@ -112,8 +121,8 @@ class FileManager:
# 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 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)

View file

@ -12,55 +12,6 @@ from metagpt.tools.tool_registry import register_tool
from metagpt.utils.git_repository import GitBranch, GitRepository
# @register_tool(tags=["git"])
async def git_clone(url: str, output_dir: str | Path = None) -> Path:
"""
Clones a Git repository from the given URL.
Args:
url (Union[str, Path]): The URL or local path of the Git repository to clone.
output_dir (Union[str, Path], optional): The directory where the repository should be cloned.
If None, the repository will be cloned into the current working directory. Defaults to None.
Returns:
Path: The path to the cloned repository.
Example:
>>> url = "https://github.com/iorisa/snake-game.git"
>>> local_path = await git_clone(url=url)
>>> print(local_path)
/local/path/to/snake-game
"""
repo = await GitRepository.clone_from(url=url, output_dir=output_dir)
return repo.workdir
@register_tool(
tags=["software development", "git", "Checks out the specific commit/branch/tag of the local Git repository."]
)
async def git_checkout(repo_dir: str | Path, commit_id: str):
"""
Checks out a specific commit in a Git repository.
Args:
repo_dir (str or Path): The directory containing the Git repository.
commit_id (str): The ID of the commit or the name of branch/tag to check out.
Raises:
ValueError: If the specified Git root is invalid.
Example:
>>> repo_dir = '/TO/GIT/REPO'
>>> commit_id = 'main'
>>> await git_checkout(repo_dir=repo_dir, commit_id=commit_id)
git checkout main
"""
repo = GitRepository(local_path=repo_dir, auto_init=False)
if not repo.is_valid:
ValueError(f"Invalid git root: {repo_dir}")
await repo.checkout(commit_id)
@register_tool(tags=["software development", "git", "Commit the changes and push to remote git repository."])
async def git_push(
local_path: Union[str, Path],
@ -106,7 +57,7 @@ async def git_push(
return branch
@register_tool(tags=["software development", "git", "create a git pull/merge request"])
@register_tool(tags=["software development", "git", "create a git pull request or merge request"])
async def git_create_pull(
base: str,
head: str,

View file

@ -238,6 +238,8 @@ class GitRepository:
:param comments: Comments for the archive commit.
"""
logger.info(f"Archive: {list(self.changed_files.keys())}")
if not self.changed_files:
return
self.add_change(self.changed_files)
self.commit(comments)
@ -271,7 +273,7 @@ class GitRepository:
base = self.current_branch
head = base if not new_branch else self.new_branch(new_branch)
self.archive(comments)
self.archive(comments) # will skip committing if no changes
ctx = Context()
env = ctx.new_environ()
proxy = ["-c", f"http.proxy={ctx.config.proxy}"] if ctx.config.proxy else []
@ -288,7 +290,7 @@ class GitRepository:
raise BadCredentialsException(status=401, message=info)
info = f"{stdout}\n{stderr}\nexit: {return_code}\n"
info = info.replace(token, "<TOKEN>")
logger.info(info)
print(info)
return GitBranch(base=base, head=head, repo_name=self.repo_name)

View file

@ -1,20 +1,15 @@
import asyncio
import os
import threading
from metagpt.environment.mgx.mgx_env import MGXEnv
from metagpt.roles import (
Architect,
Engineer,
ProductManager,
ProjectManager,
QaEngineer,
)
from metagpt.roles import Architect, Engineer, ProductManager, ProjectManager
from metagpt.roles.di.data_analyst import DataAnalyst
from metagpt.roles.di.team_leader import TeamLeader
from metagpt.schema import Message
async def main(requirement, enable_human_input=False):
async def main(requirement="", enable_human_input=False):
env = MGXEnv()
env.add_roles(
[
@ -23,7 +18,7 @@ async def main(requirement, enable_human_input=False):
Architect(),
ProjectManager(),
Engineer(n_borg=5, use_code_review=False),
QaEngineer(),
# QaEngineer(),
DataAnalyst(tools=["<all>"]),
]
)
@ -32,7 +27,9 @@ async def main(requirement, enable_human_input=False):
# simulate human sending messages in chatbox
send_human_input(env)
env.publish_message(Message(content=requirement))
if requirement:
env.publish_message(Message(content=requirement))
# env.publish_message(Message(content=requirement, send_to={"David"}), user_defined_recipient="David")
while not env.is_idle:
await env.run()
@ -70,9 +67,25 @@ data_path = "data/titanic"
train_path = f"{data_path}/split_train.csv"
eval_path = f"{data_path}/split_eval.csv"
TITANIC_REQ = f"This is a titanic passenger survival dataset, your goal is to predict passenger survival outcome. The target column is Survived. Perform data analysis, data preprocessing, feature engineering, and modeling to predict the target. Report accuracy on the eval data. Train data path: '{train_path}', eval data path: '{eval_path}'."
FIX_ISSUE = """
Write a fix for this issue: https://github.com/langchain-ai/langchain/issues/20453,
you can fix it on this repo https://github.com/garylin2099/langchain,
checkout a branch named test-fix, commit your changes, push, and create a PR to the master branch of https://github.com/iorisa/langchain
"""
FIX_ISSUE_SIMPLE = """
Write a fix for this issue: https://github.com/mannaandpoem/simple_calculator/issues/1,
you can fix it on this repo https://github.com/garylin2099/simple_calculator,
checkout a branch named test, commit your changes, push, and create a PR to the original repo.
"""
PUSH_PR_REQ = """
clone https://github.com/garylin2099/simple_calculator, checkout a new branch named test-branch, add an empty file test_file.py to the repo.
Commit your changes and push, finally, create a PR to the master branch of https://github.com/mannaandpoem/simple_calculator.
"""
if __name__ == "__main__":
# NOTE: Add access_token to test github issue fixing
os.environ["access_token"] = "ghp_xxx"
# NOTE: Change the requirement to the one you want to test
# Set enable_human_input to True if you want to simulate sending messages in chatbox
asyncio.run(main(requirement=SIMPLE_REQ, enable_human_input=False))
asyncio.run(main(requirement=FIX_ISSUE, enable_human_input=False))

View file

@ -1,7 +1,7 @@
import pytest
from metagpt.const import TEST_DATA_PATH
from metagpt.tools.libs.file_manager import FileBlock, FileManager
from metagpt.tools.libs.editor import Editor, FileBlock
TEST_FILE_CONTENT = """
# this is line one
@ -13,7 +13,7 @@ def test_function_for_fm():
# this is the 7th line
""".strip()
TEST_FILE_PATH = TEST_DATA_PATH / "tools/test_script_for_file_manager.py"
TEST_FILE_PATH = TEST_DATA_PATH / "tools/test_script_for_editor.py"
@pytest.fixture
@ -36,7 +36,7 @@ EXPECTED_SEARCHED_BLOCK = FileBlock(
def test_search_content(test_file):
block = FileManager().search_content("def test_function_for_fm", root_path=TEST_DATA_PATH, window=3)
block = Editor().search_content("def test_function_for_fm", root_path=TEST_DATA_PATH, window=3)
assert block == EXPECTED_SEARCHED_BLOCK
@ -51,7 +51,7 @@ def test_function_for_fm():
def test_replace_content(test_file):
FileManager().write_content(
Editor().write_content(
file_path=str(TEST_FILE_PATH),
start_line=3,
end_line=5,
@ -71,7 +71,7 @@ def test_function_for_fm():
def test_delete_content(test_file):
FileManager().write_content(file_path=str(TEST_FILE_PATH), start_line=3, end_line=5)
Editor().write_content(file_path=str(TEST_FILE_PATH), start_line=3, end_line=5)
with open(TEST_FILE_PATH, "r") as f:
new_content = f.read()
assert new_content == EXPECTED_CONTENT_AFTER_DELETE
@ -90,7 +90,7 @@ def test_function_for_fm():
def test_insert_content(test_file):
FileManager().write_content(
Editor().write_content(
file_path=str(TEST_FILE_PATH),
start_line=3,
end_line=-1,
@ -102,7 +102,7 @@ def test_insert_content(test_file):
def test_new_content_wrong_indentation(test_file):
msg = FileManager().write_content(
msg = Editor().write_content(
file_path=str(TEST_FILE_PATH),
start_line=3,
end_line=-1,
@ -112,7 +112,7 @@ def test_new_content_wrong_indentation(test_file):
def test_new_content_format_issue(test_file):
msg = FileManager().write_content(
msg = Editor().write_content(
file_path=str(TEST_FILE_PATH),
start_line=3,
end_line=-1,