diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py index 73127a2be..26ef526cb 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/engineer2.py @@ -77,13 +77,12 @@ Note: 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. 22. The default programming languages are HTML (.html), CSS (.css), and Pure JavaScript (.js). +23. When planning, consider whether images are needed. If you are developing a showcase website, start by using ImageGetter.get_image to obtain the necessary images. """ CURRENT_STATE = """ The current editor state is: -(Editor current directory: {editor_current_directory}) -(Editor open file: {editor_open_file}) -The current terminal state is: -(Terminal current directory: {terminal_current_directory}) +(Current directory: {current_directory}) +(Open file: {editor_open_file}) """ ENGINEER2_INSTRUCTION = ROLE_INSTRUCTION + EXTRA_INSTRUCTION.strip() @@ -103,6 +102,9 @@ WRITE_CODE_PROMPT = """ # Plan Status {plan_status} +# Current Coding File +{file_path} + # Further Instruction {instruction} diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 1a224623b..b4fcd34c8 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -18,6 +18,7 @@ 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 +from metagpt.tools.libs.image_getter import ImageGetter from metagpt.tools.libs.terminal import Terminal from metagpt.tools.tool_registry import register_tool from metagpt.utils.common import CodeParser, awrite @@ -42,6 +43,7 @@ class Engineer2(RoleZero): "SearchEnhancedQA", "Engineer2", "CodeReview", + "ImageGetter", ] # SWE Agent parameter run_eval: bool = False @@ -58,10 +60,13 @@ class Engineer2(RoleZero): Display the current terminal and editor state. This information will be dynamically added to the command prompt. """ + current_directory = (await self.terminal.run_command("pwd")).strip() + # Synchronize Terminal and Editor Working Directories + if str(self.editor.working_dir.absolute()).strip() != current_directory: + self.editor._set_workdir(current_directory) state = { "editor_open_file": self.editor.current_file, - "editor_current_directory": self.editor.working_dir.absolute(), - "terminal_current_directory": (await self.terminal.run_command("pwd")).strip(), + "current_directory": current_directory, } self.cmd_prompt_current_state = CURRENT_STATE.format(**state).strip() @@ -84,10 +89,12 @@ class Engineer2(RoleZero): ) else: # Default tool map + image_getter = ImageGetter() self.tool_execution_map.update( { "git_create_pull": git_create_pull, "Engineer2.write_new_code": self.write_new_code, + "ImageGetter.get_image": image_getter.get_image, "CodeReview.review": cr.review, "CodeReview.fix": cr.fix, "Terminal.run_command": self.terminal.run_command, @@ -117,10 +124,14 @@ class Engineer2(RoleZero): plan_status, _ = self._get_plan_status() prompt = WRITE_CODE_PROMPT.format( user_requirement=self.planner.plan.goal, + file_path=path, plan_status=plan_status, instruction=instruction, ) - context = self.llm.format_msg(self.rc.memory.get(self.memory_k) + [UserMessage(content=prompt)]) + # Sometimes the Engineer repeats the last command to respond. + # Replace the last command with a manual prompt to guide the Engineer to write new code. + memory = self.rc.memory.get(self.memory_k)[:-1] + context = self.llm.format_msg(memory + [UserMessage(content=prompt)]) async with EditorReporter(enable_llm_stream=True) as reporter: await reporter.async_report({"type": "code", "filename": Path(path).name, "src_path": path}, "meta") diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index 6a36dc65e..ad5546123 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -79,6 +79,7 @@ class RoleZero(Role): "Editor.edit_file_by_replace", "Editor.insert_content_at_line", "Editor.append_file", + "Editor.open_file", ] # Equipped with three basic tools by default for optional use editor: Editor = Editor(enable_auto_lint=True) @@ -430,7 +431,7 @@ class RoleZero(Role): 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" + command_rsp = "```json\n" + json.dumps(commands, indent=4, ensure_ascii=False) + "\n```" logger.info( "exclusive command more than one in current command list. change the command list.\n" + command_rsp ) diff --git a/metagpt/schema.py b/metagpt/schema.py index ce64d130a..13814b1d6 100644 --- a/metagpt/schema.py +++ b/metagpt/schema.py @@ -238,7 +238,7 @@ class Message(BaseModel): cause_by: str = Field(default="", validate_default=True) sent_from: str = Field(default="", validate_default=True) send_to: set[str] = Field(default={MESSAGE_ROUTE_TO_ALL}, validate_default=True) - metadata: Dict[str, str] = Field(default_factory=dict) # metadata for `content` and `instruct_content` + metadata: Dict[str, Any] = Field(default_factory=dict) # metadata for `content` and `instruct_content` @field_validator("id", mode="before") @classmethod diff --git a/metagpt/strategy/experience_retriever.py b/metagpt/strategy/experience_retriever.py index 0f51f60db..2a80d7cab 100644 --- a/metagpt/strategy/experience_retriever.py +++ b/metagpt/strategy/experience_retriever.py @@ -845,7 +845,7 @@ Consider this example only after you have obtained the content of system design 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: - +[Optional] 0. **Task 0**: Obtain images before coding. 1. **Task 1**: Create `index.html` - This file will contain the HTML structure necessary for the game's UI. 2. **Task 2**: Create `style.css` - This file will define the CSS styles to make the game visually appealing and responsive. 3. **Task 3**: Create `script.js` - This file will contain the Pure JavaScript code for the game logic and UI interactions. @@ -1026,6 +1026,21 @@ Thought: Now that the changes have been pushed to the remote repository, due to } ] ``` + +## example 12 +The requirement is to create a product website featuring goods such as caps, dresses, and T-shirts. +I believe pictures would improve the site, so I will get the images first. +```json +[ + { + "command_name": "ImageGetter.get_image", + "args": { + "search_term": "cap", + "save_file_path": "/tmp/workspace/images/cap.png", + } + } +] +``` """ WEB_SCRAPING_EXAMPLE = """ diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index a37c01421..7fff568dc 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -520,6 +520,8 @@ class Editor(BaseModel): temp_file_path = "" src_abs_path = file_name.resolve() first_error_line = None + # The file to store previous content and will be removed automatically. + temp_backup_file = tempfile.NamedTemporaryFile("w", delete=True) try: # lint the original file @@ -562,10 +564,8 @@ class Editor(BaseModel): # because the env var will be set AFTER the agentskills is imported 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: - f.writelines(lines) - + temp_backup_file.writelines(lines) + temp_backup_file.flush() lint_error, first_error_line = self._lint_file(file_name) # Select the errors caused by the modification @@ -615,15 +615,13 @@ class Editor(BaseModel): 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 + Path(temp_backup_file.name), 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() + shutil.move(temp_backup_file.name, src_abs_path) return lint_error_info except FileNotFoundError as e: @@ -641,18 +639,16 @@ class Editor(BaseModel): 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), + window_before_applied=self._print_window(Path(temp_backup_file.name), 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()) + shutil.move(temp_backup_file.name, src_abs_path) 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_info}") from e - # Update the file information and print the updated content with file_name.open("r", encoding="utf-8") as file: n_total_lines = max(1, len(file.readlines())) @@ -722,8 +718,6 @@ class Editor(BaseModel): If you need to use it multiple times, wait for the next turn. """ # 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.") @@ -735,6 +729,11 @@ class Editor(BaseModel): with file_name.open("r") as file: file_content = file.read() + if to_replace.strip() == "": + if file_content.strip() == "": + raise ValueError(f"The file '{file_name}' is empty. Please use the append method to add content.") + raise ValueError("`to_replace` must not be empty.") + 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." @@ -801,7 +800,6 @@ class Editor(BaseModel): If you need to use it multiple times, wait for the next turn. """ file_name = self._try_fix_path(file_name) - ret_str = self._edit_file_impl( file_name, start=line_number, @@ -810,6 +808,7 @@ class Editor(BaseModel): is_insert=True, is_append=False, ) + self.resource.report(file_name, "path") return ret_str def append_file(self, file_name: str, content: str) -> str: @@ -824,7 +823,6 @@ class Editor(BaseModel): If you need to use it multiple times, wait for the next turn. """ file_name = self._try_fix_path(file_name) - ret_str = self._edit_file_impl( file_name, start=None, @@ -833,6 +831,7 @@ class Editor(BaseModel): is_insert=False, is_append=True, ) + self.resource.report(file_name, "path") return ret_str def search_dir(self, search_term: str, dir_path: str = "./") -> str: diff --git a/metagpt/tools/libs/git.py b/metagpt/tools/libs/git.py index 9a33ee4c1..8a3e464f0 100644 --- a/metagpt/tools/libs/git.py +++ b/metagpt/tools/libs/git.py @@ -2,6 +2,8 @@ # -*- coding: utf-8 -*- from __future__ import annotations +import urllib +from pathlib import Path from typing import Optional from github.Issue import Issue @@ -62,11 +64,22 @@ async def git_create_pull( Returns: PullRequest: The created pull request. """ - - from metagpt.tools.libs import get_env from metagpt.utils.git_repository import GitRepository - access_token = await get_env(key="access_token", app_name=app_name) + git_credentials_path = Path.home() / ".git-credentials" + with open(git_credentials_path, "r", encoding="utf-8") as f: + lines = f.readlines() + + for line in lines: + line = line.strip() + if not line: + continue + parsed_url = urllib.parse.urlparse(line) + if app_name in parsed_url.hostname: + colon_index = parsed_url.netloc.find(":") + at_index = parsed_url.netloc.find("@") + access_token = parsed_url.netloc[colon_index + 1 : at_index] + break return await GitRepository.create_pull( base=base, head=head, diff --git a/metagpt/tools/libs/image_getter.py b/metagpt/tools/libs/image_getter.py new file mode 100644 index 000000000..ecbaaf510 --- /dev/null +++ b/metagpt/tools/libs/image_getter.py @@ -0,0 +1,85 @@ +from __future__ import annotations + +from typing import Optional + +from playwright.async_api import Browser as Browser_ +from playwright.async_api import BrowserContext, Page, Playwright, async_playwright +from pydantic import BaseModel, ConfigDict, Field + +from metagpt.tools.tool_registry import register_tool +from metagpt.utils.common import decode_image +from metagpt.utils.proxy_env import get_proxy_from_env +from metagpt.utils.report import BrowserReporter + +DOWNLOAD_PICTURE_JAVASCRIPT = """ +async () => {{ + var img = document.querySelector('{img_element_selector}'); + if (img && img.src) {{ + const response = await fetch(img.src); + if (response.ok) {{ + const blob = await response.blob(); + return await new Promise(resolve => {{ + const reader = new FileReader(); + reader.onloadend = () => resolve(reader.result); + reader.readAsDataURL(blob); + }}); + }} + }} + return null; +}} +""" + + +@register_tool(include_functions=["get_image"]) +class ImageGetter(BaseModel): + """ + A tool to get images. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + playwright: Optional[Playwright] = Field(default=None, exclude=True) + browser_instance: Optional[Browser_] = Field(default=None, exclude=True) + browser_ctx: Optional[BrowserContext] = Field(default=None, exclude=True) + page: Optional[Page] = Field(default=None, exclude=True) + headless: bool = Field(default=True) + proxy: Optional[dict] = Field(default_factory=get_proxy_from_env) + reporter: BrowserReporter = Field(default_factory=BrowserReporter) + url: str = "https://unsplash.com/s/photos/{search_term}/" + img_element_selector: str = ".zNNw1 > div > img:nth-of-type(2)" + + async def start(self) -> None: + """Starts Playwright and launches a browser""" + if self.playwright is None: + self.playwright = playwright = await async_playwright().start() + browser = self.browser_instance = await playwright.chromium.launch(headless=self.headless, proxy=self.proxy) + browser_ctx = self.browser_ctx = await browser.new_context() + self.page = await browser_ctx.new_page() + + async def get_image(self, search_term, image_save_path): + """ + Get an image related to the search term. + + Args: + search_term (str): The term to search for the image. The search term must be in English. Using any other language may lead to a mismatch. + image_save_path (str): The file path where the image will be saved. + """ + # Search for images from https://unsplash.com/s/photos/ + + if self.page is None: + await self.start() + await self.page.goto(self.url.format(search_term=search_term), wait_until="domcontentloaded") + # Wait until the image element is loaded + try: + await self.page.wait_for_selector(self.img_element_selector) + except TimeoutError: + return f"{search_term} not found. Please broaden the search term." + # Get the base64 code of the first retrieved image + image_base64 = await self.page.evaluate( + DOWNLOAD_PICTURE_JAVASCRIPT.format(img_element_selector=self.img_element_selector) + ) + if image_base64: + image = decode_image(image_base64) + image.save(image_save_path) + return f"{search_term} found. The image is saved in {image_save_path}." + return f"{search_term} not found. Please broaden the search term." diff --git a/metagpt/tools/libs/linter.py b/metagpt/tools/libs/linter.py index c8760a53b..61de7acce 100644 --- a/metagpt/tools/libs/linter.py +++ b/metagpt/tools/libs/linter.py @@ -32,6 +32,8 @@ class Linter: self.languages = dict( python=self.py_lint, + sql=self.fake_lint, # base_lint lacks support for full SQL syntax. Use fake_lint to bypass the validation. + css=self.fake_lint, # base_lint lacks support for css syntax. Use fake_lint to bypass the validation. ) self.all_lint_cmd = None @@ -112,6 +114,9 @@ class Linter: error = basic_lint(rel_fname, code) return error + def fake_lint(self, fname, rel_fname, code): + return None + def lint_python_compile(fname, code): try: diff --git a/tests/metagpt/tools/libs/test_editor.py b/tests/metagpt/tools/libs/test_editor.py index 40b6d1426..e5b900504 100644 --- a/tests/metagpt/tools/libs/test_editor.py +++ b/tests/metagpt/tools/libs/test_editor.py @@ -44,6 +44,15 @@ def temp_py_file(tmp_path): temp_file_path.unlink() +@pytest.fixture +def empty_file(tmp_path): + assert tmp_path is not None + temp_file_path = tmp_path / "test_script_empty_file_for_editor.py" + temp_file_path.write_text("") + yield temp_file_path + temp_file_path.unlink() + + EXPECTED_CONTENT_AFTER_REPLACE = """ # this is line one def test_function_for_fm(): @@ -529,6 +538,15 @@ def test_edit_file_by_replace(temp_py_file): assert new_content.strip() == EXPECTED_CONTENT_AFTER_REPLACE_TEXT.strip() +def test_edit_file_by_replace_to_palce_empty(empty_file): + editor = Editor() + with pytest.raises(ValueError) as exc_info: + editor.edit_file_by_replace( + file_name=str(empty_file), to_replace="", new_content=EXPECTED_CONTENT_AFTER_REPLACE_TEXT.strip() + ) + assert "is empty. Use the append method to add content." in str(exc_info.value) + + def test_append_file(temp_file_path): editor = Editor() # 写入初始内容 @@ -585,6 +603,27 @@ def test_search_dir(tmp_path): assert "Another file with different content." not in result +def test_search_dir_in_default_dir(tmp_path): + editor = Editor() + dir_path = editor.working_dir / "test_dir" + dir_path.mkdir(exist_ok=True) + + # 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(exist_ok=True) + (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) + + 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