From 8df76ce612a585b0c6f6ebf1fd51b4092c1ad228 Mon Sep 17 00:00:00 2001 From: garylin2099 Date: Mon, 3 Jun 2024 17:57:24 +0800 Subject: [PATCH 01/10] engineer2 first draft, allow fine-grained tool reg, reg plan as tool --- metagpt/prompts/di/engineer2.py | 49 +++++++ metagpt/roles/di/engineer2.py | 218 ++++++++++++++++++++++++++++++++ metagpt/schema.py | 37 +++++- metagpt/tools/tool_registry.py | 22 +++- 4 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 metagpt/prompts/di/engineer2.py create mode 100644 metagpt/roles/di/engineer2.py diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py new file mode 100644 index 000000000..656b48eef --- /dev/null +++ b/metagpt/prompts/di/engineer2.py @@ -0,0 +1,49 @@ +CMD_PROMPT = """ +# Data Structure +class Task(BaseModel): + task_id: str = "" + dependent_task_ids: list[str] = [] + instruction: str = "" + task_type: str = "" + assignee: str = "David" + +# Available Commands +{available_commands} +Special Command: Use {{"command_name": "Common.pass"}} to do nothing and {{"command_name": "Common.end"}} to indicate completion of all requirements and the end of actions. + +# Current Plan +{plan_status} + +# Current Task +{current_task} + +# Example +{example} + +# Instructions +Based on the context, write a plan or modify an existing plan to achieve the goal. A plan consists of one to 3 tasks. +If plan is created, you should track the progress and update the plan accordingly, such as Plan.finish_current_task, Plan.append_task, Plan.reset_task, Plan.replace_task, etc. +When presented a current task, tackle the task using the available commands. +Pay close attention to new user message, review the conversation history, use MGXEnv.reply_to_human to respond to new user requirement. +Note: +1. If you keeping encountering errors, unexpected situation, or you are not sure of proceeding, use MGXEnv.ask_human to ask for help. +2. Carefully review your progress at the current task, if your actions so far has not fulfilled the task instruction, you should continue with current task. Otherwise, finish current task. +3. Each time you finish a task, use MGXEnv.reply_to_human to report your progress. +4. Each time you write a code in your response, write with the Editor directly without preparing a repetitive code block beforehand. +Pay close attention to the Example provided, you can reuse the example for your current situation if it fits. + +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. + +# Your commands in a json array, in the following output format, always output ONE and ONLY ONE json array, if there is nothing to do, use the pass command: +Some text indicating your thoughts, such as how you should update the plan status, respond to inquiry, or seek for help. Then a json array of commands. +```json +[ + {{ + "command_name": str, + "args": {{"arg_name": arg_value, ...}} + }}, + ... +] +``` +""" diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py new file mode 100644 index 000000000..aaadbf191 --- /dev/null +++ b/metagpt/roles/di/engineer2.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import asyncio +import json +import traceback +from typing import Literal + +from pydantic import model_validator + +from metagpt.actions import Action +from metagpt.actions.di.write_analysis_code import WriteAnalysisCode +from metagpt.logs import logger +from metagpt.prompts.di.engineer2 import CMD_PROMPT +from metagpt.roles.di.data_interpreter import DataInterpreter +from metagpt.schema import Message, TaskResult +from metagpt.strategy.experience_retriever import KeywordExpRetriever +from metagpt.strategy.planner import Planner +from metagpt.tools.libs.editor import Editor +from metagpt.tools.tool_recommend import BM25ToolRecommender +from metagpt.utils.common import CodeParser +from test3 import design_doc_2048, design_doc_snake, task_doc_2048, task_doc_snake + + +class Engineer2(DataInterpreter): + name: str = "Alex" + profile: str = "Engineer" + goal: str = "" + react_mode: Literal["react"] = "react" + max_react_loop: int = 20 # used for react mode + # task_result: TaskResult = None + command_rsp: str = "" # the raw string containing the commands + commands: list[dict] = [] # commands to be executed + editor: Editor = Editor() + + @model_validator(mode="after") + def set_plan_and_tool(self) -> "DataInterpreter": + # We force using this parameter for DataAnalyst + assert self.react_mode == "react" + assert self.auto_run + assert self.use_plan + + # Roughly the same part as DataInterpreter.set_plan_and_tool + self._set_react_mode(react_mode=self.react_mode, max_react_loop=self.max_react_loop, auto_run=self.auto_run) + if self.tools and not self.tool_recommender: + self.tool_recommender = BM25ToolRecommender(tools=self.tools, force=True) + self.set_actions([WriteAnalysisCode]) + self._set_state(0) + + # HACK: Init Planner, control it through dynamic thinking; Consider formalizing as a react mode + self.planner = Planner(goal="", working_memory=self.rc.working_memory, auto_run=True) + + return self + + async def _think(self) -> bool: + """Useful in 'react' mode. Use LLM to decide whether and what to do next.""" + if not self.rc.todo and not self.rc.news: + return False + + self._set_state(0) + example = "" + if not self.planner.plan.goal: + self.user_requirement = self.get_memories()[-1].content + self.planner.plan.goal = self.user_requirement + example = KeywordExpRetriever().retrieve(self.user_requirement) + else: + # self.working_memory.add_batch(self.rc.news) + self.rc.memory.add_batch(self.rc.news) + # TODO: implement experience retrieval in multi-round setting + # if self.planner.plan.current_task: + # 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.memory.get()]: + # exp_msg = Message(content=experience, role="assistant") + # self.rc.memory.add(exp_msg) + # example = KeywordExpRetriever().retrieve(self.planner.plan.current_task.instruction, exp_type="task") + + plan_status = self.planner.plan.model_dump(include=["goal", "tasks"]) + for task in plan_status["tasks"]: + task.pop("code") + task.pop("result") + task.pop("is_success") + # print(plan_status) + current_task = ( + self.planner.plan.current_task.model_dump(exclude=["code", "result", "is_success"]) + if self.planner.plan.current_task + else "" + ) + + tools = await self.tool_recommender.recommend_tools() + tool_info = json.dumps({tool.name: tool.schemas for tool in tools}) + prompt = CMD_PROMPT.format( + plan_status=plan_status, + current_task=current_task, + example=example, + # available_commands=prepare_command_prompt(self.available_commands), + available_commands=tool_info, + ) + # context = self.llm.format_msg(self.working_memory.get() + [Message(content=prompt, role="user")]) + context = self.llm.format_msg(self.rc.memory.get(10) + [Message(content=prompt, role="user")]) + + print(*context, sep="\n" + "*" * 5 + "\n") + + self.command_rsp = await self.llm.aask(context) + + # self.rc.working_memory.add(Message(content=rsp, role="assistant")) + self.rc.memory.add(Message(content=self.command_rsp, role="assistant")) + + return True + + async def _act(self) -> Message: + try: + commands = json.loads(CodeParser.parse_code(block=None, lang="json", text=self.command_rsp)) + except Exception as e: + tb = traceback.format_exc() + print(tb) + error_msg = Message(content=str(e), role="user") + self.rc.memory.add(error_msg) + return error_msg + outputs = await self.run_commands(commands) + # self.rc.working_memory.add(Message(content=outputs, role="user")) + self.rc.memory.add(Message(content=outputs, role="user")) + return Message(content="Task completed", role="assistant", sent_from=self._setting, cause_by=WriteAnalysisCode) + + async def _react(self) -> Message: + actions_taken = 0 + rsp = Message(content="No actions taken yet", cause_by=Action) # will be overwritten after Role _act + while actions_taken < self.rc.max_react_loop: + # NOTE: difference here, keep observing within react + await self._observe() + # think + has_todo = await self._think() + if not has_todo: + break + # act + logger.debug(f"{self._setting}: {self.rc.state=}, will do {self.rc.todo}") + rsp = await self._act() + actions_taken += 1 + return rsp # return output from the last action + + async def run_commands(self, commands) -> list: + tool_execute_map = { + "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, + } + + # print(*commands, sep="\n") + + is_success = True + outputs = ["Commands executed."] + for cmd in commands: + if cmd["command_name"] in tool_execute_map: + try: + output = tool_execute_map[cmd["command_name"]](**cmd["args"]) + if output: + outputs.append(f"Output for {cmd['command_name']}: {str(output)}") + except Exception as e: + tb = traceback.format_exc() + print(e, tb) + outputs.append(tb) + is_success = False + break # Stop executing if any command fails + outputs = "\n\n".join(outputs) + + # Handle finish_current_task and end individually as a last step + for cmd in commands: + if ( + is_success + and cmd["command_name"] == "Plan.finish_current_task" + and not self.planner.plan.is_plan_finished() + ): + task_result = TaskResult(code=str(commands), result=outputs, is_success=is_success) + self.planner.plan.current_task.update_task_result(task_result=task_result) + self.planner.plan.finish_current_task() + # self.rc.working_memory.clear() + + elif cmd["command_name"] == "Common.end": + self._set_state(-1) + + return outputs + + +WINE_REQ = "Run data analysis on sklearn Wine recognition dataset, and train a model to predict wine class (20% as validation), and show validation accuracy." + +GAME_REQ_2048 = f""" +Create a 2048 game, follow the design doc and task doc. Write your code under /Users/gary/Files/temp/workspace/2048_game/src. +After writing all codes, write a code review for the codes, make improvement or adjustment based on the review. +Notice: You MUST implement the full code, don't leave comment without implementation! +Design doc: +{task_doc_2048} +Task doc: +{design_doc_2048} +""" +GAME_REQ_SNAKE = f""" +Create a snake game, follow the design doc and task doc. Write your code under /Users/gary/Files/temp/workspace/snake_game/src. +After writing all codes, write a code review for the codes, make improvement or adjustment based on the review. +Notice: You MUST implement the full code, don't leave comment without implementation! +Design doc: +{task_doc_snake} +Task doc: +{design_doc_snake} +""" +GAME_INC_REQ_2048 = """ +I found an issue with the 2048 code: when tiles are merged, no new tiles pop up. +Write code review for the codes (game.py, main.py, ui.py) under under /Users/gary/Files/temp/workspace/2048_game_bugs/src. +Then correct any issues you find. You can review all code in one time, and solve issues in one time. +""" +GAME_INC_REQ_SNAKE = """ +Based on the design doc at /Users/gary/Files/temp/workspace/snake_game_bugs/docs/20240513200737.json, +Write code review for the codes (food.py, game.py, main.py, snake.py, ui.py) under under /Users/gary/Files/temp/workspace/snake_game_bugs/src. +Then correct any issues you find. You can read the design doc first, then review all code in one time, and solve issues in one time. +""" + +if __name__ == "__main__": + engineer2 = Engineer2(tools=["Plan", "Editor:write,read,write_content", "MGXEnv:ask_human,reply_to_human"]) + asyncio.run(engineer2.run(GAME_INC_REQ_2048)) diff --git a/metagpt/schema.py b/metagpt/schema.py index 5af16bc38..d867ef125 100644 --- a/metagpt/schema.py +++ b/metagpt/schema.py @@ -49,6 +49,7 @@ from metagpt.const import ( ) from metagpt.logs import logger from metagpt.repo_parser import DotClassInfo +from metagpt.tools.tool_registry import register_tool from metagpt.utils.common import CodeParser, any_to_str, any_to_str_set, import_class from metagpt.utils.exceptions import handle_exception from metagpt.utils.report import TaskReporter @@ -430,7 +431,17 @@ class TaskResult(BaseModel): is_success: bool +@register_tool( + include_functions=[ + "append_task", + "reset_task", + "replace_task", + "finish_current_task", + ] +) class Plan(BaseModel): + """Plan is a sequence of tasks towards a goal.""" + goal: str context: str = "" tasks: list[Task] = [] @@ -503,13 +514,10 @@ class Plan(BaseModel): def reset_task(self, task_id: str): """ - Clear code and result of the task based on task_id, and set the task as unfinished. + Reset a task based on task_id, i.e. set Task.is_finished=False and request redo. This also resets all tasks depending on it. Args: task_id (str): The ID of the task to be reset. - - Returns: - None """ if task_id in self.task_map: task = self.task_map[task_id] @@ -522,7 +530,7 @@ class Plan(BaseModel): self._update_current_task() - def replace_task(self, new_task: Task): + def _replace_task(self, new_task: Task): """ Replace an existing task with the new input task based on task_id, and reset all tasks depending on it. @@ -547,7 +555,7 @@ class Plan(BaseModel): self._update_current_task() - def append_task(self, new_task: Task): + def _append_task(self, new_task: Task): """ Append a new task to the end of existing task sequences @@ -615,6 +623,23 @@ class Plan(BaseModel): """ return [task for task in self.tasks if task.is_finished] + def append_task(self, task_id: str, dependent_task_ids: list[str], instruction: str, assignee: str): + """Append a new task with task_id (number) to the end of existing task sequences. If dependent_task_ids is not empty, the task will depend on the tasks with the ids in the list.""" + new_task = Task( + task_id=task_id, dependent_task_ids=dependent_task_ids, instruction=instruction, assignee=assignee + ) + return self._append_task(new_task) + + def replace_task(self, task_id: str, new_dependent_task_ids: list[str], new_instruction: str, new_assignee: str): + """Replace an existing task (can be current task) based on task_id, and reset all tasks depending on it.""" + new_task = Task( + task_id=task_id, + dependent_task_ids=new_dependent_task_ids, + instruction=new_instruction, + assignee=new_assignee, + ) + return self._replace_task(new_task) + class MessageQueue(BaseModel): """Message queue which supports asynchronous updates.""" diff --git a/metagpt/tools/tool_registry.py b/metagpt/tools/tool_registry.py index 50875e235..e264a1b94 100644 --- a/metagpt/tools/tool_registry.py +++ b/metagpt/tools/tool_registry.py @@ -137,8 +137,26 @@ def validate_tool_names(tools: list[str]) -> dict[str, Tool]: # if tool paths are provided, they will be registered on the fly if os.path.isdir(key) or os.path.isfile(key): valid_tools.update(register_tools_from_path(key)) - elif TOOL_REGISTRY.has_tool(key): - valid_tools.update({key: TOOL_REGISTRY.get_tool(key)}) + elif TOOL_REGISTRY.has_tool(key.split(":")[0]): + if ":" in key: + # handle class tools with methods specified, such as Editor:read,write + class_tool_name = key.split(":")[0] + method_names = key.split(":")[1].split(",") + class_tool = TOOL_REGISTRY.get_tool(class_tool_name) + + methods_filtered = {} + for method_name in method_names: + if method_name in class_tool.schemas["methods"]: + methods_filtered[method_name] = class_tool.schemas["methods"][method_name] + else: + logger.warning(f"invalid method {method_name} under tool {class_tool_name}, skipped") + class_tool_filtered = class_tool.model_copy(deep=True) + class_tool_filtered.schemas["methods"] = methods_filtered + + valid_tools.update({class_tool_name: class_tool_filtered}) + + else: + valid_tools.update({key: TOOL_REGISTRY.get_tool(key)}) elif TOOL_REGISTRY.has_tool_tag(key): valid_tools.update(TOOL_REGISTRY.get_tools_by_tag(key)) else: From 9a9d342bbb66847bf647db1d50650ffce77ec7ba Mon Sep 17 00:00:00 2001 From: garylin2099 Date: Mon, 3 Jun 2024 22:45:27 +0800 Subject: [PATCH 02/10] abstract role zero from engineer2, config engineer2 --- .../prompts/di/{engineer2.py => role_zero.py} | 8 +- metagpt/roles/di/engineer2.py | 188 +++--------------- metagpt/roles/di/role_zero.py | 184 +++++++++++++++++ 3 files changed, 211 insertions(+), 169 deletions(-) rename metagpt/prompts/di/{engineer2.py => role_zero.py} (82%) create mode 100644 metagpt/roles/di/role_zero.py diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/role_zero.py similarity index 82% rename from metagpt/prompts/di/engineer2.py rename to metagpt/prompts/di/role_zero.py index 656b48eef..4fececf7f 100644 --- a/metagpt/prompts/di/engineer2.py +++ b/metagpt/prompts/di/role_zero.py @@ -5,11 +5,11 @@ class Task(BaseModel): dependent_task_ids: list[str] = [] instruction: str = "" task_type: str = "" - assignee: str = "David" + assignee: str = "" # Available Commands {available_commands} -Special Command: Use {{"command_name": "Common.pass"}} to do nothing and {{"command_name": "Common.end"}} to indicate completion of all requirements and the end of actions. +Special Command: Use {{"command_name": "pass"}} to do nothing and {{"command_name": "end"}} to indicate completion of all requirements and the end of actions. # Current Plan {plan_status} @@ -35,8 +35,8 @@ 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. -# Your commands in a json array, in the following output format, always output ONE and ONLY ONE json array, if there is nothing to do, use the pass command: -Some text indicating your thoughts, such as how you should update the plan status, respond to inquiry, or seek for help. Then a json array of commands. +# Your commands in a json array, in the following output format. If there is nothing to do, use the pass or end command: +Some text indicating your thoughts, such as how you should update the plan status, respond to inquiry, or seek for help. Then a json array of commands. You must output ONE and ONLY ONE json array. DON'T output multiple json arrays with thoughts between them. ```json [ {{ diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index aaadbf191..8618e0c47 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -1,188 +1,40 @@ from __future__ import annotations import asyncio -import json -import traceback -from typing import Literal from pydantic import model_validator -from metagpt.actions import Action -from metagpt.actions.di.write_analysis_code import WriteAnalysisCode -from metagpt.logs import logger -from metagpt.prompts.di.engineer2 import CMD_PROMPT -from metagpt.roles.di.data_interpreter import DataInterpreter -from metagpt.schema import Message, TaskResult -from metagpt.strategy.experience_retriever import KeywordExpRetriever -from metagpt.strategy.planner import Planner +from metagpt.roles.di.role_zero import RoleZero from metagpt.tools.libs.editor import Editor -from metagpt.tools.tool_recommend import BM25ToolRecommender -from metagpt.utils.common import CodeParser from test3 import design_doc_2048, design_doc_snake, task_doc_2048, task_doc_snake -class Engineer2(DataInterpreter): +def dummy_func(**kwargs): + pass + + +class Engineer2(RoleZero): name: str = "Alex" profile: str = "Engineer" goal: str = "" - react_mode: Literal["react"] = "react" - max_react_loop: int = 20 # used for react mode - # task_result: TaskResult = None - command_rsp: str = "" # the raw string containing the commands - commands: list[dict] = [] # commands to be executed + tools: str = ["Plan", "Editor:write,read,write_content", "MGXEnv:ask_human,reply_to_human"] + editor: Editor = Editor() @model_validator(mode="after") - def set_plan_and_tool(self) -> "DataInterpreter": - # We force using this parameter for DataAnalyst - assert self.react_mode == "react" - assert self.auto_run - assert self.use_plan - - # Roughly the same part as DataInterpreter.set_plan_and_tool - self._set_react_mode(react_mode=self.react_mode, max_react_loop=self.max_react_loop, auto_run=self.auto_run) - if self.tools and not self.tool_recommender: - self.tool_recommender = BM25ToolRecommender(tools=self.tools, force=True) - self.set_actions([WriteAnalysisCode]) - self._set_state(0) - - # HACK: Init Planner, control it through dynamic thinking; Consider formalizing as a react mode - self.planner = Planner(goal="", working_memory=self.rc.working_memory, auto_run=True) - - return self - - async def _think(self) -> bool: - """Useful in 'react' mode. Use LLM to decide whether and what to do next.""" - if not self.rc.todo and not self.rc.news: - return False - - self._set_state(0) - example = "" - if not self.planner.plan.goal: - self.user_requirement = self.get_memories()[-1].content - self.planner.plan.goal = self.user_requirement - example = KeywordExpRetriever().retrieve(self.user_requirement) - else: - # self.working_memory.add_batch(self.rc.news) - self.rc.memory.add_batch(self.rc.news) - # TODO: implement experience retrieval in multi-round setting - # if self.planner.plan.current_task: - # 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.memory.get()]: - # exp_msg = Message(content=experience, role="assistant") - # self.rc.memory.add(exp_msg) - # example = KeywordExpRetriever().retrieve(self.planner.plan.current_task.instruction, exp_type="task") - - plan_status = self.planner.plan.model_dump(include=["goal", "tasks"]) - for task in plan_status["tasks"]: - task.pop("code") - task.pop("result") - task.pop("is_success") - # print(plan_status) - current_task = ( - self.planner.plan.current_task.model_dump(exclude=["code", "result", "is_success"]) - if self.planner.plan.current_task - else "" - ) - - tools = await self.tool_recommender.recommend_tools() - tool_info = json.dumps({tool.name: tool.schemas for tool in tools}) - prompt = CMD_PROMPT.format( - plan_status=plan_status, - current_task=current_task, - example=example, - # available_commands=prepare_command_prompt(self.available_commands), - available_commands=tool_info, - ) - # context = self.llm.format_msg(self.working_memory.get() + [Message(content=prompt, role="user")]) - context = self.llm.format_msg(self.rc.memory.get(10) + [Message(content=prompt, role="user")]) - - print(*context, sep="\n" + "*" * 5 + "\n") - - self.command_rsp = await self.llm.aask(context) - - # self.rc.working_memory.add(Message(content=rsp, role="assistant")) - self.rc.memory.add(Message(content=self.command_rsp, role="assistant")) - - return True - - async def _act(self) -> Message: - try: - commands = json.loads(CodeParser.parse_code(block=None, lang="json", text=self.command_rsp)) - except Exception as e: - tb = traceback.format_exc() - print(tb) - error_msg = Message(content=str(e), role="user") - self.rc.memory.add(error_msg) - return error_msg - outputs = await self.run_commands(commands) - # self.rc.working_memory.add(Message(content=outputs, role="user")) - self.rc.memory.add(Message(content=outputs, role="user")) - return Message(content="Task completed", role="assistant", sent_from=self._setting, cause_by=WriteAnalysisCode) - - async def _react(self) -> Message: - actions_taken = 0 - rsp = Message(content="No actions taken yet", cause_by=Action) # will be overwritten after Role _act - while actions_taken < self.rc.max_react_loop: - # NOTE: difference here, keep observing within react - await self._observe() - # think - has_todo = await self._think() - if not has_todo: - break - # act - logger.debug(f"{self._setting}: {self.rc.state=}, will do {self.rc.todo}") - rsp = await self._act() - actions_taken += 1 - return rsp # return output from the last action - - async def run_commands(self, commands) -> list: - tool_execute_map = { + def set_tool_execution_map(self) -> "RoleZero": + self.tool_execute_map = { "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, + "MGXEnv.ask_human": dummy_func, + "MGXEnv.reply_to_human": dummy_func, } + return self - # print(*commands, sep="\n") - - is_success = True - outputs = ["Commands executed."] - for cmd in commands: - if cmd["command_name"] in tool_execute_map: - try: - output = tool_execute_map[cmd["command_name"]](**cmd["args"]) - if output: - outputs.append(f"Output for {cmd['command_name']}: {str(output)}") - except Exception as e: - tb = traceback.format_exc() - print(e, tb) - outputs.append(tb) - is_success = False - break # Stop executing if any command fails - outputs = "\n\n".join(outputs) - - # Handle finish_current_task and end individually as a last step - for cmd in commands: - if ( - is_success - and cmd["command_name"] == "Plan.finish_current_task" - and not self.planner.plan.is_plan_finished() - ): - task_result = TaskResult(code=str(commands), result=outputs, is_success=is_success) - self.planner.plan.current_task.update_task_result(task_result=task_result) - self.planner.plan.finish_current_task() - # self.rc.working_memory.clear() - - elif cmd["command_name"] == "Common.end": - self._set_state(-1) - - return outputs - - -WINE_REQ = "Run data analysis on sklearn Wine recognition dataset, and train a model to predict wine class (20% as validation), and show validation accuracy." GAME_REQ_2048 = f""" Create a 2048 game, follow the design doc and task doc. Write your code under /Users/gary/Files/temp/workspace/2048_game/src. @@ -202,17 +54,23 @@ Design doc: Task doc: {design_doc_snake} """ +GAME_REQ_2048_NO_DOC = """ +Create a 2048 game with pygame. Write your code under /Users/gary/Files/temp/workspace/2048_game/src. +Consider what files you will write, break down the requests to multiple tasks and write one file in each task. +After writing all codes, write a code review for the codes, make improvement or adjustment based on the review. +Notice: You MUST implement the full code, don't leave comment without implementation! +""" GAME_INC_REQ_2048 = """ I found an issue with the 2048 code: when tiles are merged, no new tiles pop up. Write code review for the codes (game.py, main.py, ui.py) under under /Users/gary/Files/temp/workspace/2048_game_bugs/src. Then correct any issues you find. You can review all code in one time, and solve issues in one time. """ GAME_INC_REQ_SNAKE = """ -Based on the design doc at /Users/gary/Files/temp/workspace/snake_game_bugs/docs/20240513200737.json, +Found this issue, TypeError: generate_new_position() missing 1 required positional argument: 'snake_body' Write code review for the codes (food.py, game.py, main.py, snake.py, ui.py) under under /Users/gary/Files/temp/workspace/snake_game_bugs/src. -Then correct any issues you find. You can read the design doc first, then review all code in one time, and solve issues in one time. +Then correct any issues you find. You can review all code in one time, and solve issues in one time. """ if __name__ == "__main__": - engineer2 = Engineer2(tools=["Plan", "Editor:write,read,write_content", "MGXEnv:ask_human,reply_to_human"]) - asyncio.run(engineer2.run(GAME_INC_REQ_2048)) + engineer2 = Engineer2() + asyncio.run(engineer2.run(GAME_REQ_2048_NO_DOC)) diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py new file mode 100644 index 000000000..8d6455d90 --- /dev/null +++ b/metagpt/roles/di/role_zero.py @@ -0,0 +1,184 @@ +from __future__ import annotations + +import inspect +import json +import traceback +from typing import Literal + +from pydantic import model_validator + +from metagpt.actions import Action +from metagpt.actions.di.run_command import RunCommand +from metagpt.logs import logger +from metagpt.prompts.di.role_zero import CMD_PROMPT +from metagpt.roles import Role +from metagpt.schema import Message +from metagpt.strategy.experience_retriever import KeywordExpRetriever +from metagpt.strategy.planner import Planner +from metagpt.tools.tool_recommend import BM25ToolRecommender, ToolRecommender +from metagpt.utils.common import CodeParser + + +class RoleZero(Role): + name: str = "Zero" + profile: str = "RoleZero" + goal: str = "" + system_msg: str = "" + cmd_prompt: str = CMD_PROMPT + + react_mode: Literal["react"] = "react" + max_react_loop: int = 20 # used for react mode + + user_requirement: str = "" + command_rsp: str = "" # the raw string containing the commands + commands: list[dict] = [] # commands to be executed + memory_k: int = 20 # number of memories (messages) to use as historical context + + tools: list[str] = [] # Use special symbol [""] to indicate use of all registered tools + tool_recommender: ToolRecommender = None + tool_execution_map: dict[str, callable] = {} + + @model_validator(mode="after") + def set_plan_and_tool(self) -> "RoleZero": + # We force using this parameter for DataAnalyst + assert self.react_mode == "react" + + # Roughly the same part as DataInterpreter.set_plan_and_tool + self._set_react_mode(react_mode=self.react_mode, max_react_loop=self.max_react_loop) + if self.tools and not self.tool_recommender: + self.tool_recommender = BM25ToolRecommender(tools=self.tools, force=True) + self.set_actions([RunCommand]) + self._set_state(0) + + # HACK: Init Planner, control it through dynamic thinking; Consider formalizing as a react mode + self.planner = Planner(goal="", working_memory=self.rc.working_memory, auto_run=True) + + return self + + @model_validator(mode="after") + def set_tool_execution_map(self) -> "RoleZero": + raise NotImplementedError + + async def _think(self) -> bool: + """Useful in 'react' mode. Use LLM to decide whether and what to do next.""" + if not self.rc.todo and not self.rc.news: + return False + + self._set_state(0) + example = "" + if not self.planner.plan.goal: + self.user_requirement = self.get_memories()[-1].content + self.planner.plan.goal = self.user_requirement + example = KeywordExpRetriever().retrieve(self.user_requirement) + else: + self.rc.memory.add_batch(self.rc.news) + # TODO: implement experience retrieval in multi-round setting + # if self.planner.plan.current_task: + # 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.memory.get()]: + # exp_msg = Message(content=experience, role="assistant") + # self.rc.memory.add(exp_msg) + # example = KeywordExpRetriever().retrieve(self.planner.plan.current_task.instruction, exp_type="task") + + plan_status = self.planner.plan.model_dump(include=["goal", "tasks"]) + for task in plan_status["tasks"]: + task.pop("code") + task.pop("result") + task.pop("is_success") + # print(plan_status) + current_task = ( + self.planner.plan.current_task.model_dump(exclude=["code", "result", "is_success"]) + if self.planner.plan.current_task + else "" + ) + + tools = await self.tool_recommender.recommend_tools() + tool_info = json.dumps({tool.name: tool.schemas for tool in tools}) + prompt = self.cmd_prompt.format( + plan_status=plan_status, + current_task=current_task, + example=example, + available_commands=tool_info, + ) + context = self.llm.format_msg(self.rc.memory.get(self.memory_k) + [Message(content=prompt, role="user")]) + + print(*context, sep="\n" + "*" * 5 + "\n") + + self.command_rsp = await self.llm.aask(context) + + self.rc.memory.add(Message(content=self.command_rsp, role="assistant")) + + return True + + async def _act(self) -> Message: + try: + commands = json.loads(CodeParser.parse_code(block=None, lang="json", text=self.command_rsp)) + except Exception as e: + tb = traceback.format_exc() + print(tb) + error_msg = Message(content=str(e), role="user") + self.rc.memory.add(error_msg) + return error_msg + outputs = await self._run_commands(commands) + self.rc.memory.add(Message(content=outputs, role="user")) + return Message(content="Task completed", role="assistant", sent_from=self._setting, cause_by=RunCommand) + + async def _react(self) -> Message: + actions_taken = 0 + rsp = Message(content="No actions taken yet", cause_by=Action) # will be overwritten after Role _act + while actions_taken < self.rc.max_react_loop: + # NOTE: difference here, keep observing within react + await self._observe() + # think + has_todo = await self._think() + if not has_todo: + break + # act + logger.debug(f"{self._setting}: {self.rc.state=}, will do {self.rc.todo}") + rsp = await self._act() + actions_taken += 1 + return rsp # return output from the last action + + async def _run_commands(self, commands) -> list: + outputs = [] + for cmd in commands: + # handle special command first + if await self._run_special_command(cmd): + continue + # run command as specified by tool_execute_map + if cmd["command_name"] in self.tool_execute_map: + tool_obj = self.tool_execute_map[cmd["command_name"]] + output = f"Command {cmd['command_name']} executed" + try: + if inspect.iscoroutinefunction(tool_obj): + tool_output = await tool_obj(**cmd["args"]) + else: + tool_output = tool_obj(**cmd["args"]) + if tool_output: + output += f": {str(tool_output)}" + outputs.append(output) + except Exception as e: + tb = traceback.format_exc() + print(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 + + async def _run_special_command(self, cmd) -> bool: + """command requiring special check or parsing""" + is_special_cmd = cmd["command_name"] in ["Plan.finish_current_task", "Common.end"] + + if cmd["command_name"] == "Plan.finish_current_task" and not self.planner.plan.is_plan_finished(): + # task_result = TaskResult(code=str(commands), result=outputs, is_success=is_success) + # self.planner.plan.current_task.update_task_result(task_result=task_result) + self.planner.plan.finish_current_task() + + elif cmd["command_name"] == "end": + self._set_state(-1) + + return is_special_cmd From beaf11cc89c7d91840be2678b25f495361aee6d1 Mon Sep 17 00:00:00 2001 From: garylin2099 Date: Mon, 3 Jun 2024 22:47:50 +0800 Subject: [PATCH 03/10] update editor --- metagpt/tools/libs/editor.py | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index e032dcef5..354e5330d 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -25,8 +25,11 @@ class Editor: self.resource = EditorReporter() def write(self, path: str, content: str): - """Write the whole content to a file.""" - with open(path, "w") as f: + """Write the whole content to a file. When used, make sure content arg contains the full content of the file.""" + directory = os.path.dirname(path) + if 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") @@ -117,7 +120,7 @@ class Editor: 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. + 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. @@ -161,7 +164,9 @@ class Editor: if new_block_content: # Split the new_block_content by newline and ensure each line ends with a newline character - new_content_lines = [line + "\n" for line in new_block_content.split("\n")] + 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. From c6b28643bdd27d5079c287365240a31fa78b08a8 Mon Sep 17 00:00:00 2001 From: garylin2099 Date: Tue, 4 Jun 2024 20:59:21 +0800 Subject: [PATCH 04/10] refactor team leader, use RoleZero framework --- metagpt/prompts/di/engineer2.py | 6 ++ metagpt/prompts/di/role_zero.py | 25 +++--- metagpt/prompts/di/team_leader.py | 49 ++--------- metagpt/roles/di/engineer2.py | 19 ++-- metagpt/roles/di/role_zero.py | 77 +++++++++++----- metagpt/roles/di/team_leader.py | 107 +++++++++-------------- metagpt/strategy/experience_retriever.py | 53 ++++++----- 7 files changed, 158 insertions(+), 178 deletions(-) create mode 100644 metagpt/prompts/di/engineer2.py diff --git a/metagpt/prompts/di/engineer2.py b/metagpt/prompts/di/engineer2.py new file mode 100644 index 000000000..346f2fc5a --- /dev/null +++ b/metagpt/prompts/di/engineer2.py @@ -0,0 +1,6 @@ +from metagpt.prompts.di.role_zero import ROLE_INSTRUCTION + +ENGINEER2_INSTRUCTION = ( + ROLE_INSTRUCTION + + "4. Each time you write a code in your response, write with the Editor directly without preparing a repetitive code block beforehand." +) diff --git a/metagpt/prompts/di/role_zero.py b/metagpt/prompts/di/role_zero.py index 4fececf7f..f098d2c4b 100644 --- a/metagpt/prompts/di/role_zero.py +++ b/metagpt/prompts/di/role_zero.py @@ -1,3 +1,14 @@ +ROLE_INSTRUCTION = """ +Based on the context, write a plan or modify an existing plan to achieve the goal. A plan consists of one to 3 tasks. +If plan is created, you should track the progress and update the plan accordingly, such as Plan.finish_current_task, Plan.append_task, Plan.reset_task, Plan.replace_task, etc. +When presented a current task, tackle the task using the available commands. +Pay close attention to new user message, review the conversation history, use RoleZero.reply_to_human to respond to new user requirement. +Note: +1. If you keeping encountering errors, unexpected situation, or you are not sure of proceeding, use RoleZero.ask_human to ask for help. +2. Carefully review your progress at the current task, if your actions so far has not fulfilled the task instruction, you should continue with current task. Otherwise, finish current task. +3. Each time you finish a task, use RoleZero.reply_to_human to report your progress. +""" + CMD_PROMPT = """ # Data Structure class Task(BaseModel): @@ -20,18 +31,10 @@ Special Command: Use {{"command_name": "pass"}} to do nothing and {{"command_nam # Example {example} -# Instructions -Based on the context, write a plan or modify an existing plan to achieve the goal. A plan consists of one to 3 tasks. -If plan is created, you should track the progress and update the plan accordingly, such as Plan.finish_current_task, Plan.append_task, Plan.reset_task, Plan.replace_task, etc. -When presented a current task, tackle the task using the available commands. -Pay close attention to new user message, review the conversation history, use MGXEnv.reply_to_human to respond to new user requirement. -Note: -1. If you keeping encountering errors, unexpected situation, or you are not sure of proceeding, use MGXEnv.ask_human to ask for help. -2. Carefully review your progress at the current task, if your actions so far has not fulfilled the task instruction, you should continue with current task. Otherwise, finish current task. -3. Each time you finish a task, use MGXEnv.reply_to_human to report your progress. -4. Each time you write a code in your response, write with the Editor directly without preparing a repetitive code block beforehand. -Pay close attention to the Example provided, you can reuse the example for your current situation if it fits. +# Instruction +{instruction} +Pay close attention to the Example provided, you can reuse the example for your current situation if it fits. 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. diff --git a/metagpt/prompts/di/team_leader.py b/metagpt/prompts/di/team_leader.py index dacd3d876..14297b026 100644 --- a/metagpt/prompts/di/team_leader.py +++ b/metagpt/prompts/di/team_leader.py @@ -4,62 +4,27 @@ When drafting and routing tasks, ALWAYS include necessary or important info insi Each time you do something, reply to human letting them know what you did. """ -CMD_PROMPT = """ -# Data Structure -class Task(BaseModel): - task_id: str = "" - dependent_task_ids: list[str] = [] - instruction: str = "" - task_type: str = "" - assignee: str = "" - -# Team Member Info -{team_info} - -# Available Commands -{available_commands} - -# Current Plan -{plan_status} - -# Example -{example} - -# Instructions +TL_INSTRUCTION = """ You are a team leader, and you are responsible for drafting tasks and routing tasks to your team members. +Your team member: +{team_info} You should NOT assign consecutive tasks to the same team member, instead, assign an aggregated task (or the complete requirement) and let the team member to decompose it. When creating a new plan involving multiple members, create all tasks at once. -If plan is created, you should track the progress based on team member feedback message, and update plan accordingly, such as finish_current_task, reset_task, replace_task, etc. -You should publish_message to team members, asking them to start their task. -Pay close attention to new user message, review the conversation history, use reply_to_human to respond to the user directly, DON'T ask your team members. +If plan is created, you should track the progress based on team member feedback message, and update plan accordingly, such as Plan.finish_current_task, Plan.reset_task, Plan.replace_task, etc. +You should use TeamLeader.publish_team_message to team members, asking them to start their task. +Pay close attention to new user message, review the conversation history, use RoleZero.reply_to_human to respond to the user directly, DON'T ask your team members. Note: 1. If the requirement is a pure DATA-RELATED requirement, such as bug fixes, issue reporting, environment setup, terminal operations, pip install, web browsing, web scraping, web searching, web imitation, data science, data analysis, machine learning, deep learning, text-to-image etc. DON'T decompose it, assign a single task with the original user requirement as instruction directly to Data Analyst. 2. If the requirement is developing a software, game, app, or website, excluding the above data-related tasks, you should decompose the requirement into multiple tasks and assign them to different team members based on their expertise, usually the sequence of Product Manager -> Architect -> Project Manager -> Engineer -> (optional: QaEngine if present) -> (optional: DataAnalyst if user requests deployment), each assigned ONE task. When publishing message to Product Manager, you should directly copy the full original user requirement. 3. If the requirement contains both DATA-RELATED part mentioned in 1 and software development part mentioned in 2, you should decompose the software development part and assign them to different team members based on their expertise, and assign the DATA-RELATED part to Data Analyst David directly. -Pay close attention to the Example provided - -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 finish_task, DON'T append a new task. - -# Your commands in a json array, in the following output format, always output a json array, if there is nothing to do, use the pass command: -Some text indicating your thoughts, such as how you categorize the requirement based on Note (is it 1., 2., or 3.?) or how you should update the plan status. Then a json array of commands. -```json -[ - {{ - "command_name": str, - "args": {{"arg_name": arg_value, ...}} - }}, - ... -] -``` """ FINISH_CURRENT_TASK_CMD = """ ```json [ { - "command_name": "finish_current_task", + "command_name": "Plan.finish_current_task", "args": {{}} } ``` diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 8618e0c47..673f3ff64 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -4,34 +4,32 @@ import asyncio from pydantic import model_validator +from metagpt.prompts.di.engineer2 import ENGINEER2_INSTRUCTION from metagpt.roles.di.role_zero import RoleZero from metagpt.tools.libs.editor import Editor from test3 import design_doc_2048, design_doc_snake, task_doc_2048, task_doc_snake -def dummy_func(**kwargs): - pass - - class Engineer2(RoleZero): name: str = "Alex" profile: str = "Engineer" - goal: str = "" - tools: str = ["Plan", "Editor:write,read,write_content", "MGXEnv:ask_human,reply_to_human"] + goal: str = "Take on game, app, and web development" + tools: str = ["Plan", "Editor:write,read,write_content", "RoleZero"] + instruction: str = ENGINEER2_INSTRUCTION editor: Editor = Editor() @model_validator(mode="after") - def set_tool_execution_map(self) -> "RoleZero": - self.tool_execute_map = { + def set_tool_execution(self) -> "RoleZero": + self.tool_execution_map = { "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, - "MGXEnv.ask_human": dummy_func, - "MGXEnv.reply_to_human": dummy_func, + "RoleZero.ask_human": self.ask_human, + "RoleZero.reply_to_human": self.reply_to_human, } return self @@ -70,6 +68,7 @@ Found this issue, TypeError: generate_new_position() missing 1 required position Write code review for the codes (food.py, game.py, main.py, snake.py, ui.py) under under /Users/gary/Files/temp/workspace/snake_game_bugs/src. Then correct any issues you find. You can review all code in one time, and solve issues in one time. """ +CASUAL_CHAT = """what's your name?""" if __name__ == "__main__": engineer2 = Engineer2() diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index 8d6455d90..f1518f8be 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -9,22 +9,28 @@ from pydantic import model_validator from metagpt.actions import Action from metagpt.actions.di.run_command import RunCommand +from metagpt.environment.mgx.mgx_env import MGXEnv from metagpt.logs import logger -from metagpt.prompts.di.role_zero import CMD_PROMPT +from metagpt.prompts.di.role_zero import CMD_PROMPT, ROLE_INSTRUCTION from metagpt.roles import Role from metagpt.schema import Message -from metagpt.strategy.experience_retriever import KeywordExpRetriever +from metagpt.strategy.experience_retriever import DummyExpRetriever, ExpRetriever from metagpt.strategy.planner import Planner from metagpt.tools.tool_recommend import BM25ToolRecommender, ToolRecommender +from metagpt.tools.tool_registry import register_tool from metagpt.utils.common import CodeParser +@register_tool(include_functions=["ask_human", "reply_to_human"]) class RoleZero(Role): + """A role serving as the basis for other MGX roles.""" + name: str = "Zero" profile: str = "RoleZero" goal: str = "" - system_msg: str = "" + system_msg: list[str] = None # Use None to conform to the default value at llm.aask cmd_prompt: str = CMD_PROMPT + instruction: str = ROLE_INSTRUCTION react_mode: Literal["react"] = "react" max_react_loop: int = 20 # used for react mode @@ -37,6 +43,9 @@ class RoleZero(Role): tools: list[str] = [] # Use special symbol [""] to indicate use of all registered tools tool_recommender: ToolRecommender = None tool_execution_map: dict[str, callable] = {} + special_tool_commands: list[str] = ["Plan.finish_current_task", "end"] + + experience_retriever: ExpRetriever = DummyExpRetriever() @model_validator(mode="after") def set_plan_and_tool(self) -> "RoleZero": @@ -56,30 +65,23 @@ class RoleZero(Role): return self @model_validator(mode="after") - def set_tool_execution_map(self) -> "RoleZero": + def set_tool_execution(self) -> "RoleZero": raise NotImplementedError async def _think(self) -> bool: """Useful in 'react' mode. Use LLM to decide whether and what to do next.""" + ### 0. Preparation ### if not self.rc.todo and not self.rc.news: return False - self._set_state(0) - example = "" if not self.planner.plan.goal: self.user_requirement = self.get_memories()[-1].content self.planner.plan.goal = self.user_requirement - example = KeywordExpRetriever().retrieve(self.user_requirement) - else: - self.rc.memory.add_batch(self.rc.news) - # TODO: implement experience retrieval in multi-round setting - # if self.planner.plan.current_task: - # 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.memory.get()]: - # exp_msg = Message(content=experience, role="assistant") - # self.rc.memory.add(exp_msg) - # example = KeywordExpRetriever().retrieve(self.planner.plan.current_task.instruction, exp_type="task") + ### 1. Experience ### + example = self._retrieve_experience() + + ### 2. Plan Status ### plan_status = self.planner.plan.model_dump(include=["goal", "tasks"]) for task in plan_status["tasks"]: task.pop("code") @@ -92,20 +94,21 @@ class RoleZero(Role): else "" ) + ### 3. Tool/Command Info ### tools = await self.tool_recommender.recommend_tools() tool_info = json.dumps({tool.name: tool.schemas for tool in tools}) + + ### Make Decision ### prompt = self.cmd_prompt.format( plan_status=plan_status, current_task=current_task, example=example, available_commands=tool_info, + instruction=self.instruction.strip(), ) context = self.llm.format_msg(self.rc.memory.get(self.memory_k) + [Message(content=prompt, role="user")]) - print(*context, sep="\n" + "*" * 5 + "\n") - - self.command_rsp = await self.llm.aask(context) - + self.command_rsp = await self.llm.aask(context, system_msgs=self.system_msg) self.rc.memory.add(Message(content=self.command_rsp, role="assistant")) return True @@ -121,7 +124,12 @@ class RoleZero(Role): return error_msg outputs = await self._run_commands(commands) self.rc.memory.add(Message(content=outputs, role="user")) - return Message(content="Task completed", role="assistant", sent_from=self._setting, cause_by=RunCommand) + return Message( + content=f"Complete run with outputs: {outputs}", + role="assistant", + sent_from=self._setting, + cause_by=RunCommand, + ) async def _react(self) -> Message: actions_taken = 0 @@ -146,8 +154,8 @@ class RoleZero(Role): if await self._run_special_command(cmd): continue # run command as specified by tool_execute_map - if cmd["command_name"] in self.tool_execute_map: - tool_obj = self.tool_execute_map[cmd["command_name"]] + if cmd["command_name"] in self.tool_execution_map: + tool_obj = self.tool_execution_map[cmd["command_name"]] output = f"Command {cmd['command_name']} executed" try: if inspect.iscoroutinefunction(tool_obj): @@ -171,7 +179,7 @@ class RoleZero(Role): async def _run_special_command(self, cmd) -> bool: """command requiring special check or parsing""" - is_special_cmd = cmd["command_name"] in ["Plan.finish_current_task", "Common.end"] + is_special_cmd = cmd["command_name"] in self.special_tool_commands if cmd["command_name"] == "Plan.finish_current_task" and not self.planner.plan.is_plan_finished(): # task_result = TaskResult(code=str(commands), result=outputs, is_success=is_success) @@ -182,3 +190,24 @@ class RoleZero(Role): self._set_state(-1) return is_special_cmd + + def _retrieve_experience(self) -> str: + """Default implementation of experience retrieval. Can be overwritten in subclasses.""" + context = [str(msg) for msg in self.rc.memory.get(self.memory_k)] + context = "\n\n".join(context) + example = self.experience_retriever.retrieve(context=context) + return example + + async def ask_human(self, question: str) -> str: + """Use this when you fail the current task or if you are unsure of the situation encountered. Your response should contain a brief summary of your situation, ended with a clear and concise question.""" + # NOTE: Can be overwritten in remote setting + if not isinstance(self.rc.env, MGXEnv): + return "Not in MGXEnv, command will not be executed." + return await self.rc.env.get_human_input(question, sent_from=self) + + async def reply_to_human(self, content: str) -> str: + """Reply to human user with the content provided. Use this when you have a clear answer or solution to the user's question.""" + # NOTE: Can be overwritten in remote setting + 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) diff --git a/metagpt/roles/di/team_leader.py b/metagpt/roles/di/team_leader.py index a1ef11fa6..3a4c71254 100644 --- a/metagpt/roles/di/team_leader.py +++ b/metagpt/roles/di/team_leader.py @@ -1,99 +1,70 @@ from __future__ import annotations -import json - from pydantic import model_validator -from metagpt.actions.di.run_command import RunCommand from metagpt.prompts.di.team_leader import ( - CMD_PROMPT, FINISH_CURRENT_TASK_CMD, SYSTEM_PROMPT, + TL_INSTRUCTION, ) -from metagpt.roles import Role -from metagpt.schema import Message, TaskResult -from metagpt.strategy.experience_retriever import SimpleExpRetriever -from metagpt.strategy.planner import Planner -from metagpt.strategy.thinking_command import ( - Command, - prepare_command_prompt, - run_commands, -) -from metagpt.utils.common import CodeParser +from metagpt.roles.di.role_zero import RoleZero +from metagpt.schema import Message +from metagpt.strategy.experience_retriever import ExpRetriever, SimpleExpRetriever +from metagpt.tools.tool_registry import register_tool -class TeamLeader(Role): +@register_tool(include_functions=["publish_team_message"]) +class TeamLeader(RoleZero): name: str = "Tim" profile: str = "Team Leader" - task_result: TaskResult = None - available_commands: list[Command] = [ - Command.APPEND_TASK, - Command.RESET_TASK, - Command.REPLACE_TASK, - Command.FINISH_CURRENT_TASK, - Command.PUBLISH_MESSAGE, - Command.ASK_HUMAN, - Command.REPLY_TO_HUMAN, - Command.PASS, - ] - commands: list[dict] = [] # issued commands to be executed + system_msg: list[str] = [SYSTEM_PROMPT] + + max_react_loop: int = 1 # TeamLeader only reacts once each time + + tools: list[str] = ["Plan", "RoleZero", "TeamLeader"] + experience_retriever: ExpRetriever = SimpleExpRetriever() @model_validator(mode="after") - def set_plan(self) -> "TeamLeader": - self.planner = Planner(goal=self.goal, working_memory=self.rc.working_memory, auto_run=True) + def set_tool_execution(self) -> "RoleZero": + self.tool_execution_map = { + "Plan.append_task": self.planner.plan.append_task, + "Plan.reset_task": self.planner.plan.reset_task, + "Plan.replace_task": self.planner.plan.replace_task, + "RoleZero.ask_human": self.ask_human, + "RoleZero.reply_to_human": self.reply_to_human, + "TeamLeader.publish_team_message": self.publish_team_message, + } return self - async def _think(self) -> bool: - """Useful in 'react' mode. Use LLM to decide whether and what to do next.""" - - if not self.planner.plan.goal: - user_requirement = self.get_memories()[-1].content - self.planner.plan.goal = user_requirement - - plan_status = self.planner.plan.model_dump(include=["goal", "tasks"]) - for task in plan_status["tasks"]: - task.pop("code") - task.pop("result") + def set_instruction(self): team_info = "" for role in self.rc.env.roles.values(): - if role.profile == "TeamLeader": - continue + # if role.profile == "Team Leader": + # continue team_info += f"{role.name}: {role.profile}, {role.goal}\n" - example = SimpleExpRetriever().retrieve() + self.instruction = TL_INSTRUCTION.format(team_info=team_info) - prompt = CMD_PROMPT.format( - plan_status=plan_status, - team_info=team_info, - example=example, - available_commands=prepare_command_prompt(self.available_commands), - ) - context = self.llm.format_msg(self.get_memories(k=10) + [Message(content=prompt, role="user")]) + async def _think(self) -> bool: + self.set_instruction() + return await super()._think() - rsp = await self.llm.aask(context, system_msgs=[SYSTEM_PROMPT]) - self.commands = json.loads(CodeParser.parse_code(text=rsp)) - self.rc.memory.add(Message(content=rsp, role="assistant")) - - return True - - async def _act(self) -> Message: - """Useful in 'react' mode. Return a Message conforming to Role._act interface.""" - await run_commands(self, self.commands, self.rc.memory) - self.task_result = TaskResult(result="Success", is_success=True) - msg = Message(content="Commands executed", send_to="no one") # a dummy message to conform to the interface - self.rc.memory.add(msg) - return msg - - def publish_message(self, msg): - """If the role belongs to env, then the role's messages will be broadcast to env""" + def publish_message(self, msg, send_to="no one"): + """Overwrite Role.publish_message, send to no one if called within Role.run, send to the specified role if called dynamically.""" if not msg: return if not self.rc.env: # If env does not exist, do not publish the message return - msg.sent_from = self.profile - msg.cause_by = RunCommand + msg.send_to = send_to self.rc.env.publish_message(msg, publicer=self.profile) + def publish_team_message(self, content: str, send_to: str): + """ + Publish a message to a team member, use member name to fill send_to args. You may copy the full original content or add additional information from upstream. This will make team members start their work. + DONT omit any necessary info such as path, link, environment, programming language, framework, requirement, constraint from original content to team members because you are their sole info source. + """ + self.publish_message(Message(content=content), send_to=send_to) + def finish_current_task(self): self.planner.plan.finish_current_task() self.rc.memory.add(Message(content=FINISH_CURRENT_TASK_CMD, role="assistant")) diff --git a/metagpt/strategy/experience_retriever.py b/metagpt/strategy/experience_retriever.py index 4b209717c..c122affb5 100644 --- a/metagpt/strategy/experience_retriever.py +++ b/metagpt/strategy/experience_retriever.py @@ -10,6 +10,13 @@ class ExpRetriever(BaseModel): raise NotImplementedError +class DummyExpRetriever(ExpRetriever): + """A dummy experience retriever that returns empty string.""" + + def retrieve(self, context: str = "") -> str: + return "" + + class SimpleExpRetriever(ExpRetriever): """A simple experience retriever that returns manually crafted examples.""" @@ -20,7 +27,7 @@ class SimpleExpRetriever(ExpRetriever): ```json [ { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "1", "dependent_task_ids": [], @@ -29,7 +36,7 @@ class SimpleExpRetriever(ExpRetriever): } }, { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "2", "dependent_task_ids": ["1"], @@ -38,7 +45,7 @@ class SimpleExpRetriever(ExpRetriever): } }, { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "3", "dependent_task_ids": ["2"], @@ -47,7 +54,7 @@ class SimpleExpRetriever(ExpRetriever): } }, { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "4", "dependent_task_ids": ["3"], @@ -56,7 +63,7 @@ class SimpleExpRetriever(ExpRetriever): } }, { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "5", "dependent_task_ids": ["4"], @@ -65,14 +72,14 @@ class SimpleExpRetriever(ExpRetriever): } }, { - "command_name": "publish_message", + "command_name": "TeamLeader.publish_message", "args": { "content": "Create a cli snake game using Python", "send_to": "Alice" } }, { - "command_name": "reply_to_human", + "command_name": "RoleZero.reply_to_human", "args": { "content": "I have assigned the tasks to the team members. Alice will create the PRD, Bob will design the software architecture, Eve will break down the architecture into tasks, Alex will implement the core game logic, and Edward will write comprehensive tests. The team will work on the project accordingly", } @@ -86,7 +93,7 @@ class SimpleExpRetriever(ExpRetriever): ```json [ { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "1", "dependent_task_ids": [], @@ -95,14 +102,14 @@ class SimpleExpRetriever(ExpRetriever): } }, { - "command_name": "publish_message", + "command_name": "TeamLeader.publish_message", "args": { "content": "Run data analysis on sklearn Wine recognition dataset, include a plot, and train a model to predict wine class (20% as validation), and show validation accuracy.", "send_to": "David" } }, { - "command_name": "reply_to_human", + "command_name": "RoleZero.reply_to_human", "args": { "content": "I have assigned the task to David. He will break down the task further by himself and starts solving it.", } @@ -116,22 +123,22 @@ class SimpleExpRetriever(ExpRetriever): ..., {'role': 'assistant', 'content': 'from Alice(Product Manager) to {'Bob'}: {'docs': {'20240424153821.json': {'root_path': 'docs/prd', 'filename': '20240424153821.json', 'content': '{"Language":"en_us","Programming Language":"Python","Original Requirements":"create a cli snake game","Project Name":"snake_game","Product Goals":["Develop an intuitive and addictive snake game",...], ...}}}}}, ] - Explanation: You received a message from Alice, the Product Manager, that she has completed the PRD, use finish_current_task to mark her task as finished and moves the plan to the next task. Based on plan status, next task is for Bob (Architect), publish a message asking him to start. The message content should contain important path info. + Explanation: You received a message from Alice, the Product Manager, that she has completed the PRD, use Plan.finish_current_task to mark her task as finished and moves the plan to the next task. Based on plan status, next task is for Bob (Architect), publish a message asking him to start. The message content should contain important path info. ```json [ { - "command_name": "finish_current_task", + "command_name": "Plan.finish_current_task", "args": {} }, { - "command_name": "publish_message", + "command_name": "TeamLeader.publish_message", "args": { "content": "Please design the software architecture for the snake game based on the PRD created by Alice. The PRD is at 'docs/prd/20240424153821.json'. Include the choice of programming language, libraries, and data flow, etc.", "send_to": "Bob" } }, { - "command_name": "reply_to_human", + "command_name": "RoleZero.reply_to_human", "args": { "content": "Alice has completed the PRD. I have marked her task as finished and sent the PRD to Bob. Bob will work on the software architecture.", } @@ -145,7 +152,7 @@ class SimpleExpRetriever(ExpRetriever): ```json [ { - "command_name": "reply_to_human", + "command_name": "RoleZero.reply_to_human", "args": { "content": "The team is currently working on ... We have completed ...", } @@ -180,7 +187,7 @@ Explanation: Launching a service requires Terminal tool with daemon mode, write ```json [ { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "1", "dependent_task_ids": [], @@ -189,7 +196,7 @@ Explanation: Launching a service requires Terminal tool with daemon mode, write } }, { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "2", "dependent_task_ids": ["1"], @@ -198,7 +205,7 @@ Explanation: Launching a service requires Terminal tool with daemon mode, write } }, { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "3", "dependent_task_ids": ["2"], @@ -216,7 +223,7 @@ Explanation: The requirement is to fix an issue in an existing repository. The p ```json [ { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "1", "dependent_task_ids": [], @@ -225,7 +232,7 @@ Explanation: The requirement is to fix an issue in an existing repository. The p } }, { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "2", "dependent_task_ids": ["1"], @@ -234,7 +241,7 @@ Explanation: The requirement is to fix an issue in an existing repository. The p } }, { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "3", "dependent_task_ids": ["2"], @@ -243,7 +250,7 @@ Explanation: The requirement is to fix an issue in an existing repository. The p } }, { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "4", "dependent_task_ids": ["3"], @@ -252,7 +259,7 @@ Explanation: The requirement is to fix an issue in an existing repository. The p } }, { - "command_name": "append_task", + "command_name": "Plan.append_task", "args": { "task_id": "5", "dependent_task_ids": ["4"], From 89ad59876bb05ae412f8cf180487abda9ec54aa1 Mon Sep 17 00:00:00 2001 From: garylin2099 Date: Wed, 5 Jun 2024 14:39:41 +0800 Subject: [PATCH 05/10] format and add fixed sop compatibility --- metagpt/roles/di/engineer2.py | 46 +--------- metagpt/roles/di/role_zero.py | 27 ++++-- metagpt/roles/di/team_leader.py | 1 + tests/metagpt/roles/di/run_engineer2.py | 106 ++++++++++++++++++++++++ 4 files changed, 128 insertions(+), 52 deletions(-) create mode 100644 tests/metagpt/roles/di/run_engineer2.py diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 673f3ff64..538976826 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -1,22 +1,19 @@ from __future__ import annotations -import asyncio - from pydantic import model_validator from metagpt.prompts.di.engineer2 import ENGINEER2_INSTRUCTION from metagpt.roles.di.role_zero import RoleZero from metagpt.tools.libs.editor import Editor -from test3 import design_doc_2048, design_doc_snake, task_doc_2048, task_doc_snake class Engineer2(RoleZero): name: str = "Alex" profile: str = "Engineer" goal: str = "Take on game, app, and web development" - tools: str = ["Plan", "Editor:write,read,write_content", "RoleZero"] instruction: str = ENGINEER2_INSTRUCTION + tools: str = ["Plan", "Editor:write,read,write_content", "RoleZero"] editor: Editor = Editor() @model_validator(mode="after") @@ -32,44 +29,3 @@ class Engineer2(RoleZero): "RoleZero.reply_to_human": self.reply_to_human, } return self - - -GAME_REQ_2048 = f""" -Create a 2048 game, follow the design doc and task doc. Write your code under /Users/gary/Files/temp/workspace/2048_game/src. -After writing all codes, write a code review for the codes, make improvement or adjustment based on the review. -Notice: You MUST implement the full code, don't leave comment without implementation! -Design doc: -{task_doc_2048} -Task doc: -{design_doc_2048} -""" -GAME_REQ_SNAKE = f""" -Create a snake game, follow the design doc and task doc. Write your code under /Users/gary/Files/temp/workspace/snake_game/src. -After writing all codes, write a code review for the codes, make improvement or adjustment based on the review. -Notice: You MUST implement the full code, don't leave comment without implementation! -Design doc: -{task_doc_snake} -Task doc: -{design_doc_snake} -""" -GAME_REQ_2048_NO_DOC = """ -Create a 2048 game with pygame. Write your code under /Users/gary/Files/temp/workspace/2048_game/src. -Consider what files you will write, break down the requests to multiple tasks and write one file in each task. -After writing all codes, write a code review for the codes, make improvement or adjustment based on the review. -Notice: You MUST implement the full code, don't leave comment without implementation! -""" -GAME_INC_REQ_2048 = """ -I found an issue with the 2048 code: when tiles are merged, no new tiles pop up. -Write code review for the codes (game.py, main.py, ui.py) under under /Users/gary/Files/temp/workspace/2048_game_bugs/src. -Then correct any issues you find. You can review all code in one time, and solve issues in one time. -""" -GAME_INC_REQ_SNAKE = """ -Found this issue, TypeError: generate_new_position() missing 1 required positional argument: 'snake_body' -Write code review for the codes (food.py, game.py, main.py, snake.py, ui.py) under under /Users/gary/Files/temp/workspace/snake_game_bugs/src. -Then correct any issues you find. You can review all code in one time, and solve issues in one time. -""" -CASUAL_CHAT = """what's your name?""" - -if __name__ == "__main__": - engineer2 = Engineer2() - asyncio.run(engineer2.run(GAME_REQ_2048_NO_DOC)) diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index f1518f8be..afd4a68c4 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -23,8 +23,9 @@ from metagpt.utils.common import CodeParser @register_tool(include_functions=["ask_human", "reply_to_human"]) class RoleZero(Role): - """A role serving as the basis for other MGX roles.""" + """A role who can think and act dynamically""" + # Basic Info name: str = "Zero" profile: str = "RoleZero" goal: str = "" @@ -32,21 +33,26 @@ class RoleZero(Role): cmd_prompt: str = CMD_PROMPT instruction: str = ROLE_INSTRUCTION + # React Mode react_mode: Literal["react"] = "react" max_react_loop: int = 20 # used for react mode - user_requirement: str = "" - command_rsp: str = "" # the raw string containing the commands - commands: list[dict] = [] # commands to be executed - memory_k: int = 20 # number of memories (messages) to use as historical context - + # Tools tools: list[str] = [] # Use special symbol [""] to indicate use of all registered tools tool_recommender: ToolRecommender = None tool_execution_map: dict[str, callable] = {} special_tool_commands: list[str] = ["Plan.finish_current_task", "end"] + # Experience experience_retriever: ExpRetriever = DummyExpRetriever() + # Others + user_requirement: str = "" + command_rsp: str = "" # the raw string containing the commands + commands: list[dict] = [] # commands to be executed + memory_k: int = 20 # number of memories (messages) to use as historical context + use_fixed_sop: bool = False + @model_validator(mode="after") def set_plan_and_tool(self) -> "RoleZero": # We force using this parameter for DataAnalyst @@ -70,6 +76,10 @@ class RoleZero(Role): async def _think(self) -> bool: """Useful in 'react' mode. Use LLM to decide whether and what to do next.""" + # Compatibility + if self.use_fixed_sop: + return await super()._think() + ### 0. Preparation ### if not self.rc.todo and not self.rc.news: return False @@ -98,7 +108,7 @@ class RoleZero(Role): tools = await self.tool_recommender.recommend_tools() tool_info = json.dumps({tool.name: tool.schemas for tool in tools}) - ### Make Decision ### + ### Make Decision Dynamically ### prompt = self.cmd_prompt.format( plan_status=plan_status, current_task=current_task, @@ -114,6 +124,9 @@ class RoleZero(Role): return True async def _act(self) -> Message: + if self.use_fixed_sop: + return await super()._act() + try: commands = json.loads(CodeParser.parse_code(block=None, lang="json", text=self.command_rsp)) except Exception as e: diff --git a/metagpt/roles/di/team_leader.py b/metagpt/roles/di/team_leader.py index 3a4c71254..da9e2b3ed 100644 --- a/metagpt/roles/di/team_leader.py +++ b/metagpt/roles/di/team_leader.py @@ -22,6 +22,7 @@ class TeamLeader(RoleZero): max_react_loop: int = 1 # TeamLeader only reacts once each time tools: list[str] = ["Plan", "RoleZero", "TeamLeader"] + experience_retriever: ExpRetriever = SimpleExpRetriever() @model_validator(mode="after") diff --git a/tests/metagpt/roles/di/run_engineer2.py b/tests/metagpt/roles/di/run_engineer2.py new file mode 100644 index 000000000..3ce0457e5 --- /dev/null +++ b/tests/metagpt/roles/di/run_engineer2.py @@ -0,0 +1,106 @@ +import asyncio + +from metagpt.roles.di.engineer2 import Engineer2 + +DESIGN_DOC_2048 = '{"Implementation approach":"We will use the Pygame library to implement the 2048 game logic and user interface. Pygame is a set of Python modules designed for writing video games, which will help us create a responsive and visually appealing UI. For the mobile responsiveness, we will ensure that the game scales appropriately on different screen sizes. We will also use the Pygame GUI library to create buttons for restarting the game and choosing difficulty levels.","File list":["main.py","game.py","ui.py"],"Data structures and interfaces":"\\nclassDiagram\\n class Game {\\n -grid: list[list[int]]\\n -score: int\\n +__init__()\\n +move(direction: str) bool\\n +merge() bool\\n +spawn_tile() None\\n +is_game_over() bool\\n +reset() None\\n }\\n class UI {\\n -game: Game\\n +__init__(game: Game)\\n +draw_grid() None\\n +draw_score() None\\n +draw_buttons() None\\n +handle_input() None\\n }\\n class Main {\\n -ui: UI\\n +main() None\\n }\\n Main --> UI\\n UI --> Game\\n","Program call flow":"\\nsequenceDiagram\\n participant M as Main\\n participant U as UI\\n participant G as Game\\n M->>U: __init__(game)\\n U->>G: __init__()\\n M->>U: draw_grid()\\n U->>G: move(direction)\\n G-->>U: return bool\\n U->>G: merge()\\n G-->>U: return bool\\n U->>G: spawn_tile()\\n G-->>U: return None\\n U->>G: is_game_over()\\n G-->>U: return bool\\n U->>G: reset()\\n G-->>U: return None\\n M->>U: draw_score()\\n M->>U: draw_buttons()\\n M->>U: handle_input()\\n","Anything UNCLEAR":"Clarification needed on the specific design elements for the UI to ensure it meets the \'beautiful\' requirement. Additionally, we need to confirm the exact difficulty levels and how they should affect the game mechanics."}' + +TASK_DOC_2048 = '{"Required Python packages":["pygame==2.0.1","pygame_gui==0.5.7"],"Required Other language third-party packages":["No third-party dependencies required"],"Logic Analysis":[["game.py","Contains Game class with methods: __init__, move, merge, spawn_tile, is_game_over, reset"],["ui.py","Contains UI class with methods: __init__, draw_grid, draw_score, draw_buttons, handle_input"],["main.py","Contains Main class with method: main, initializes UI and Game"]],"Task list":["game.py","ui.py","main.py"],"Full API spec":"","Shared Knowledge":"`game.py` contains core game logic and state management. `ui.py` handles all user interface elements and interactions. `main.py` serves as the entry point to initialize and run the game.","Anything UNCLEAR":"Clarification needed on the specific design elements for the UI to ensure it meets the \'beautiful\' requirement. Additionally, we need to confirm the exact difficulty levels and how they should affect the game mechanics."}' + +DESIGN_DOC_SNAKE = """ +{ + "Implementation approach": "We will use the Pygame library to create the CLI-based snake game. Pygame is a set of Python modules designed for writing video games, which will help us handle graphics, sound, and input. The game will be structured into different modules to handle the main game loop, snake movement, food generation, collision detection, and user interface. We will ensure the game is engaging and responsive by optimizing the game loop and input handling. The score display and different speed levels will be implemented to enhance the user experience.", + "File list": [ + "main.py", + "game.py", + "snake.py", + "food.py", + "ui.py" + ], + "Data structures and interfaces": "\nclassDiagram\n class Main {\n +main() void\n }\n class Game {\n -Snake snake\n -Food food\n -int score\n -int speed\n +__init__(speed: int)\n +run() void\n +restart() void\n +update_score() void\n }\n class Snake {\n -list body\n -str direction\n +__init__()\n +move() void\n +change_direction(new_direction: str) void\n +check_collision() bool\n +grow() void\n }\n class Food {\n -tuple position\n +__init__()\n +generate_new_position() void\n }\n class UI {\n +display_score(score: int) void\n +display_game_over() void\n +display_game(snake: Snake, food: Food) void\n }\n Main --> Game\n Game --> Snake\n Game --> Food\n Game --> UI\n", + "Program call flow": "\nsequenceDiagram\n participant M as Main\n participant G as Game\n participant S as Snake\n participant F as Food\n participant U as UI\n M->>G: __init__(speed)\n M->>G: run()\n G->>S: __init__()\n G->>F: __init__()\n loop Game Loop\n G->>S: move()\n G->>S: check_collision()\n alt Collision Detected\n G->>G: restart()\n G->>U: display_game_over()\n else No Collision\n G->>F: generate_new_position()\n G->>S: grow()\n G->>G: update_score()\n G->>U: display_score(score)\n end\n G->>U: display_game(snake, food)\n end\n", + "Anything UNCLEAR": "Currently, all aspects of the project are clear." +} +""" +TASK_DOC_SNAKE = """ +{ + "Required Python packages": [ + "pygame==2.0.1" + ], + "Required Other language third-party packages": [ + "No third-party dependencies required" + ], + "Logic Analysis": [ + [ + "main.py", + "Contains the main function to initialize and start the game. Imports Game from game.py." + ], + [ + "game.py", + "Contains the Game class which manages the game loop, score, and speed. Imports Snake from snake.py, Food from food.py, and UI from ui.py." + ], + [ + "snake.py", + "Contains the Snake class which handles snake movement, direction changes, collision detection, and growth." + ], + [ + "food.py", + "Contains the Food class which handles food position generation." + ], + [ + "ui.py", + "Contains the UI class which handles displaying the score, game over screen, and the game state." + ] + ], + "Task list": [ + "snake.py", + "food.py", + "ui.py", + "game.py", + "main.py" + ], + "Full API spec": "", + "Shared Knowledge": "`game.py` contains the main game loop and integrates all other modules (snake, food, UI).", + "Anything UNCLEAR": "Currently, all aspects of the project are clear." +} +""" + +GAME_REQ_2048 = f""" +Create a 2048 game, follow the design doc and task doc. Write your code under /Users/gary/Files/temp/workspace/2048_game/src. +After writing all codes, write a code review for the codes, make improvement or adjustment based on the review. +Notice: You MUST implement the full code, don't leave comment without implementation! +Design doc: +{TASK_DOC_2048} +Task doc: +{DESIGN_DOC_2048} +""" +GAME_REQ_SNAKE = f""" +Create a snake game, follow the design doc and task doc. Write your code under /Users/gary/Files/temp/workspace/snake_game/src. +After writing all codes, write a code review for the codes, make improvement or adjustment based on the review. +Notice: You MUST implement the full code, don't leave comment without implementation! +Design doc: +{TASK_DOC_SNAKE} +Task doc: +{DESIGN_DOC_SNAKE} +""" +GAME_REQ_2048_NO_DOC = """ +Create a 2048 game with pygame. Write your code under /Users/gary/Files/temp/workspace/2048_game/src. +Consider what files you will write, break down the requests to multiple tasks and write one file in each task. +After writing all codes, write a code review for the codes, make improvement or adjustment based on the review. +Notice: You MUST implement the full code, don't leave comment without implementation! +""" +GAME_INC_REQ_2048 = """ +I found an issue with the 2048 code: when tiles are merged, no new tiles pop up. +Write code review for the codes (game.py, main.py, ui.py) under under /Users/gary/Files/temp/workspace/2048_game_bugs/src. +Then correct any issues you find. You can review all code in one time, and solve issues in one time. +""" +GAME_INC_REQ_SNAKE = """ +Found this issue, TypeError: generate_new_position() missing 1 required positional argument: 'snake_body' +Write code review for the codes (food.py, game.py, main.py, snake.py, ui.py) under under /Users/gary/Files/temp/workspace/snake_game_bugs/src. +Then correct any issues you find. You can review all code in one time, and solve issues in one time. +""" +CASUAL_CHAT = """what's your name?""" + + +if __name__ == "__main__": + engineer2 = Engineer2() + asyncio.run(engineer2.run(GAME_REQ_2048_NO_DOC)) From 6cf7a8114cc147c3849a87e1eec82b4de65054b1 Mon Sep 17 00:00:00 2001 From: garylin2099 Date: Wed, 5 Jun 2024 16:54:18 +0800 Subject: [PATCH 06/10] fix msg routing bug, use message class --- metagpt/roles/di/role_zero.py | 17 ++++++++--------- metagpt/roles/di/team_leader.py | 12 ++++++++---- tests/metagpt/roles/di/run_engineer2.py | 2 -- 3 files changed, 16 insertions(+), 15 deletions(-) diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index 62df9d580..3015392ba 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -13,7 +13,7 @@ from metagpt.environment.mgx.mgx_env import MGXEnv from metagpt.logs import logger from metagpt.prompts.di.role_zero import CMD_PROMPT, ROLE_INSTRUCTION from metagpt.roles import Role -from metagpt.schema import Message +from metagpt.schema import AIMessage, Message, UserMessage from metagpt.strategy.experience_retriever import DummyExpRetriever, ExpRetriever from metagpt.strategy.planner import Planner from metagpt.tools.tool_recommend import BM25ToolRecommender, ToolRecommender @@ -117,11 +117,11 @@ class RoleZero(Role): available_commands=tool_info, instruction=self.instruction.strip(), ) - context = self.llm.format_msg(self.rc.memory.get(self.memory_k) + [Message(content=prompt, role="user")]) + context = self.llm.format_msg(self.rc.memory.get(self.memory_k) + [UserMessage(content=prompt)]) print(*context, sep="\n" + "*" * 5 + "\n") async with ThoughtReporter(): self.command_rsp = await self.llm.aask(context, system_msgs=self.system_msg) - self.rc.memory.add(Message(content=self.command_rsp, role="assistant")) + self.rc.memory.add(AIMessage(content=self.command_rsp)) return True @@ -134,21 +134,20 @@ class RoleZero(Role): except Exception as e: tb = traceback.format_exc() print(tb) - error_msg = Message(content=str(e), role="user") + error_msg = UserMessage(content=str(e)) self.rc.memory.add(error_msg) return error_msg outputs = await self._run_commands(commands) - self.rc.memory.add(Message(content=outputs, role="user")) - return Message( + self.rc.memory.add(UserMessage(content=outputs)) + return AIMessage( content=f"Complete run with outputs: {outputs}", - role="assistant", - sent_from=self._setting, + sent_from=self.name, cause_by=RunCommand, ) async def _react(self) -> Message: actions_taken = 0 - rsp = Message(content="No actions taken yet", cause_by=Action) # will be overwritten after Role _act + rsp = AIMessage(content="No actions taken yet", cause_by=Action) # will be overwritten after Role _act while actions_taken < self.rc.max_react_loop: # NOTE: difference here, keep observing within react await self._observe() diff --git a/metagpt/roles/di/team_leader.py b/metagpt/roles/di/team_leader.py index da9e2b3ed..421bd3c26 100644 --- a/metagpt/roles/di/team_leader.py +++ b/metagpt/roles/di/team_leader.py @@ -2,13 +2,14 @@ from __future__ import annotations from pydantic import model_validator +from metagpt.actions.di.run_command import RunCommand from metagpt.prompts.di.team_leader import ( FINISH_CURRENT_TASK_CMD, SYSTEM_PROMPT, TL_INSTRUCTION, ) from metagpt.roles.di.role_zero import RoleZero -from metagpt.schema import Message +from metagpt.schema import AIMessage, Message, UserMessage from metagpt.strategy.experience_retriever import ExpRetriever, SimpleExpRetriever from metagpt.tools.tool_registry import register_tool @@ -49,7 +50,7 @@ class TeamLeader(RoleZero): self.set_instruction() return await super()._think() - def publish_message(self, msg, send_to="no one"): + def publish_message(self, msg: Message, send_to="no one"): """Overwrite Role.publish_message, send to no one if called within Role.run, send to the specified role if called dynamically.""" if not msg: return @@ -64,8 +65,11 @@ class TeamLeader(RoleZero): Publish a message to a team member, use member name to fill send_to args. You may copy the full original content or add additional information from upstream. This will make team members start their work. DONT omit any necessary info such as path, link, environment, programming language, framework, requirement, constraint from original content to team members because you are their sole info source. """ - self.publish_message(Message(content=content), send_to=send_to) + # Specify the outer send_to to overwrite the default "no one" value. Use UserMessage because message from self is like a user request for others. + self.publish_message( + UserMessage(content=content, sent_from=self.name, send_to=send_to, cause_by=RunCommand), send_to=send_to + ) def finish_current_task(self): self.planner.plan.finish_current_task() - self.rc.memory.add(Message(content=FINISH_CURRENT_TASK_CMD, role="assistant")) + self.rc.memory.add(AIMessage(content=FINISH_CURRENT_TASK_CMD)) diff --git a/tests/metagpt/roles/di/run_engineer2.py b/tests/metagpt/roles/di/run_engineer2.py index 3ce0457e5..4e948bad7 100644 --- a/tests/metagpt/roles/di/run_engineer2.py +++ b/tests/metagpt/roles/di/run_engineer2.py @@ -3,9 +3,7 @@ import asyncio from metagpt.roles.di.engineer2 import Engineer2 DESIGN_DOC_2048 = '{"Implementation approach":"We will use the Pygame library to implement the 2048 game logic and user interface. Pygame is a set of Python modules designed for writing video games, which will help us create a responsive and visually appealing UI. For the mobile responsiveness, we will ensure that the game scales appropriately on different screen sizes. We will also use the Pygame GUI library to create buttons for restarting the game and choosing difficulty levels.","File list":["main.py","game.py","ui.py"],"Data structures and interfaces":"\\nclassDiagram\\n class Game {\\n -grid: list[list[int]]\\n -score: int\\n +__init__()\\n +move(direction: str) bool\\n +merge() bool\\n +spawn_tile() None\\n +is_game_over() bool\\n +reset() None\\n }\\n class UI {\\n -game: Game\\n +__init__(game: Game)\\n +draw_grid() None\\n +draw_score() None\\n +draw_buttons() None\\n +handle_input() None\\n }\\n class Main {\\n -ui: UI\\n +main() None\\n }\\n Main --> UI\\n UI --> Game\\n","Program call flow":"\\nsequenceDiagram\\n participant M as Main\\n participant U as UI\\n participant G as Game\\n M->>U: __init__(game)\\n U->>G: __init__()\\n M->>U: draw_grid()\\n U->>G: move(direction)\\n G-->>U: return bool\\n U->>G: merge()\\n G-->>U: return bool\\n U->>G: spawn_tile()\\n G-->>U: return None\\n U->>G: is_game_over()\\n G-->>U: return bool\\n U->>G: reset()\\n G-->>U: return None\\n M->>U: draw_score()\\n M->>U: draw_buttons()\\n M->>U: handle_input()\\n","Anything UNCLEAR":"Clarification needed on the specific design elements for the UI to ensure it meets the \'beautiful\' requirement. Additionally, we need to confirm the exact difficulty levels and how they should affect the game mechanics."}' - TASK_DOC_2048 = '{"Required Python packages":["pygame==2.0.1","pygame_gui==0.5.7"],"Required Other language third-party packages":["No third-party dependencies required"],"Logic Analysis":[["game.py","Contains Game class with methods: __init__, move, merge, spawn_tile, is_game_over, reset"],["ui.py","Contains UI class with methods: __init__, draw_grid, draw_score, draw_buttons, handle_input"],["main.py","Contains Main class with method: main, initializes UI and Game"]],"Task list":["game.py","ui.py","main.py"],"Full API spec":"","Shared Knowledge":"`game.py` contains core game logic and state management. `ui.py` handles all user interface elements and interactions. `main.py` serves as the entry point to initialize and run the game.","Anything UNCLEAR":"Clarification needed on the specific design elements for the UI to ensure it meets the \'beautiful\' requirement. Additionally, we need to confirm the exact difficulty levels and how they should affect the game mechanics."}' - DESIGN_DOC_SNAKE = """ { "Implementation approach": "We will use the Pygame library to create the CLI-based snake game. Pygame is a set of Python modules designed for writing video games, which will help us handle graphics, sound, and input. The game will be structured into different modules to handle the main game loop, snake movement, food generation, collision detection, and user interface. We will ensure the game is engaging and responsive by optimizing the game loop and input handling. The score display and different speed levels will be implemented to enhance the user experience.", From a59b9e228665825d727b54793d78ae52ee2b2112 Mon Sep 17 00:00:00 2001 From: garylin2099 Date: Wed, 5 Jun 2024 23:37:15 +0800 Subject: [PATCH 07/10] base software company roles on RoleZero --- metagpt/roles/architect.py | 20 ++++++++++++--- metagpt/roles/di/engineer2.py | 18 -------------- metagpt/roles/di/role_zero.py | 42 ++++++++++++++++++++++++++------ metagpt/roles/di/team_leader.py | 20 ++++++--------- metagpt/roles/product_manager.py | 29 +++++++++++++++++----- metagpt/roles/project_manager.py | 19 ++++++++++++--- 6 files changed, 98 insertions(+), 50 deletions(-) diff --git a/metagpt/roles/architect.py b/metagpt/roles/architect.py index 9e1761c85..afa234a3c 100644 --- a/metagpt/roles/architect.py +++ b/metagpt/roles/architect.py @@ -5,13 +5,12 @@ @Author : alexanderwu @File : architect.py """ - from metagpt.actions import WritePRD from metagpt.actions.design_api import WriteDesign -from metagpt.roles.role import Role +from metagpt.roles.di.role_zero import RoleZero -class Architect(Role): +class Architect(RoleZero): """ Represents an Architect role in a software development process. @@ -30,11 +29,26 @@ class Architect(Role): "libraries. Use same language as user requirement" ) + instruction: str = """Use WriteDesign tool to write a system design document""" + max_react_loop: int = 1 # FIXME: Read and edit files requires more steps, consider later + tools: list[str] = ["Editor:write,read,write_content", "RoleZero", "WriteDesign"] + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) + + # NOTE: The following init setting will only be effective when self.use_fixed_sop is changed to True self.enable_memory = False # Initialize actions specific to the Architect role self.set_actions([WriteDesign]) # Set events or actions the Architect should watch or be aware of self._watch({WritePRD}) + + def _update_tool_execution(self): + wd = WriteDesign() + self.tool_execution_map.update( + { + "WriteDesign.run": wd.run, + "WriteDesign": wd.run, # alias + } + ) diff --git a/metagpt/roles/di/engineer2.py b/metagpt/roles/di/engineer2.py index 538976826..e013ef09e 100644 --- a/metagpt/roles/di/engineer2.py +++ b/metagpt/roles/di/engineer2.py @@ -1,10 +1,7 @@ from __future__ import annotations -from pydantic import model_validator - from metagpt.prompts.di.engineer2 import ENGINEER2_INSTRUCTION from metagpt.roles.di.role_zero import RoleZero -from metagpt.tools.libs.editor import Editor class Engineer2(RoleZero): @@ -14,18 +11,3 @@ class Engineer2(RoleZero): instruction: str = ENGINEER2_INSTRUCTION tools: str = ["Plan", "Editor:write,read,write_content", "RoleZero"] - editor: Editor = Editor() - - @model_validator(mode="after") - def set_tool_execution(self) -> "RoleZero": - self.tool_execution_map = { - "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, - } - return self diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index 3015392ba..36ce98032 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -9,13 +9,14 @@ from pydantic import model_validator from metagpt.actions import Action from metagpt.actions.di.run_command import RunCommand -from metagpt.environment.mgx.mgx_env import MGXEnv from metagpt.logs import logger from metagpt.prompts.di.role_zero import CMD_PROMPT, ROLE_INSTRUCTION from metagpt.roles import Role from metagpt.schema import AIMessage, Message, UserMessage from metagpt.strategy.experience_retriever import DummyExpRetriever, ExpRetriever from metagpt.strategy.planner import Planner +from metagpt.tools.libs.browser import Browser +from metagpt.tools.libs.editor import Editor from metagpt.tools.tool_recommend import BM25ToolRecommender, ToolRecommender from metagpt.tools.tool_registry import register_tool from metagpt.utils.common import CodeParser @@ -43,6 +44,10 @@ class RoleZero(Role): tool_recommender: ToolRecommender = None tool_execution_map: dict[str, callable] = {} 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() + # terminal: Terminal = Terminal() # FIXME: TypeError: cannot pickle '_thread.lock' object # Experience experience_retriever: ExpRetriever = DummyExpRetriever() @@ -64,7 +69,6 @@ class RoleZero(Role): if self.tools and not self.tool_recommender: self.tool_recommender = BM25ToolRecommender(tools=self.tools, force=True) self.set_actions([RunCommand]) - self._set_state(0) # HACK: Init Planner, control it through dynamic thinking; Consider formalizing as a react mode self.planner = Planner(goal="", working_memory=self.rc.working_memory, auto_run=True) @@ -73,7 +77,23 @@ class RoleZero(Role): @model_validator(mode="after") def set_tool_execution(self) -> "RoleZero": - raise NotImplementedError + # default map + self.tool_execution_map = { + "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, + } + # can be updated by subclass + self._update_tool_execution() + return self + + def _update_tool_execution(self): + pass async def _think(self) -> bool: """Useful in 'react' mode. Use LLM to decide whether and what to do next.""" @@ -82,9 +102,9 @@ class RoleZero(Role): return await super()._think() ### 0. Preparation ### - if not self.rc.todo and not self.rc.news: + if not self.rc.todo: return False - self._set_state(0) + if not self.planner.plan.goal: self.user_requirement = self.get_memories()[-1].content self.planner.plan.goal = self.user_requirement @@ -118,7 +138,7 @@ class RoleZero(Role): instruction=self.instruction.strip(), ) context = self.llm.format_msg(self.rc.memory.get(self.memory_k) + [UserMessage(content=prompt)]) - print(*context, sep="\n" + "*" * 5 + "\n") + # print(*context, sep="\n" + "*" * 5 + "\n") async with ThoughtReporter(): self.command_rsp = await self.llm.aask(context, system_msgs=self.system_msg) self.rc.memory.add(AIMessage(content=self.command_rsp)) @@ -146,11 +166,15 @@ class RoleZero(Role): ) async def _react(self) -> Message: + # NOTE: Diff 1: Each time landing here means observing news, set todo to allow news processing in _think + self._set_state(0) + actions_taken = 0 rsp = AIMessage(content="No actions taken yet", cause_by=Action) # will be overwritten after Role _act while actions_taken < self.rc.max_react_loop: - # NOTE: difference here, keep observing within react + # NOTE: Diff 2: Keep observing within _react, news will go into memory, allowing adapting to new info await self._observe() + # think has_todo = await self._think() if not has_todo: @@ -215,6 +239,8 @@ class RoleZero(Role): async def ask_human(self, question: str) -> str: """Use this when you fail the current task or if you are unsure of the situation encountered. Your response should contain a brief summary of your situation, ended with a clear and concise question.""" # NOTE: Can be overwritten in remote setting + 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." return await self.rc.env.get_human_input(question, sent_from=self) @@ -222,6 +248,8 @@ class RoleZero(Role): async def reply_to_human(self, content: str) -> str: """Reply to human user with the content provided. Use this when you have a clear answer or solution to the user's question.""" # NOTE: Can be overwritten in remote setting + 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." return await self.rc.env.reply_to_human(content, sent_from=self) diff --git a/metagpt/roles/di/team_leader.py b/metagpt/roles/di/team_leader.py index 421bd3c26..92ddcab04 100644 --- a/metagpt/roles/di/team_leader.py +++ b/metagpt/roles/di/team_leader.py @@ -1,7 +1,5 @@ from __future__ import annotations -from pydantic import model_validator - from metagpt.actions.di.run_command import RunCommand from metagpt.prompts.di.team_leader import ( FINISH_CURRENT_TASK_CMD, @@ -26,17 +24,13 @@ class TeamLeader(RoleZero): experience_retriever: ExpRetriever = SimpleExpRetriever() - @model_validator(mode="after") - def set_tool_execution(self) -> "RoleZero": - self.tool_execution_map = { - "Plan.append_task": self.planner.plan.append_task, - "Plan.reset_task": self.planner.plan.reset_task, - "Plan.replace_task": self.planner.plan.replace_task, - "RoleZero.ask_human": self.ask_human, - "RoleZero.reply_to_human": self.reply_to_human, - "TeamLeader.publish_team_message": self.publish_team_message, - } - return self + def _update_tool_execution(self): + self.tool_execution_map.update( + { + "TeamLeader.publish_team_message": self.publish_team_message, + "TeamLeader.publish_message": self.publish_team_message, # alias + } + ) def set_instruction(self): team_info = "" diff --git a/metagpt/roles/product_manager.py b/metagpt/roles/product_manager.py index d08933cb0..cc8c82bf1 100644 --- a/metagpt/roles/product_manager.py +++ b/metagpt/roles/product_manager.py @@ -7,15 +7,15 @@ @Modified By: mashenquan, 2023/11/27. Add `PrepareDocuments` action according to Section 2.2.3.5.1 of RFC 135. """ - from metagpt.actions import UserRequirement, WritePRD from metagpt.actions.prepare_documents import PrepareDocuments -from metagpt.roles.role import Role, RoleReactMode +from metagpt.roles.di.role_zero import RoleZero +from metagpt.roles.role import RoleReactMode from metagpt.utils.common import any_to_name, any_to_str from metagpt.utils.git_repository import GitRepository -class ProductManager(Role): +class ProductManager(RoleZero): """ Represents a Product Manager role responsible for product development and management. @@ -30,18 +30,35 @@ class ProductManager(Role): profile: str = "Product Manager" goal: str = "efficiently create a successful product that meets market demands and user expectations" constraints: str = "utilize the same language as the user requirements for seamless communication" - todo_action: str = "" + todo_action: str = any_to_name(WritePRD) + + instruction: str = """Use WritePRD tool to write PRD""" + max_react_loop: int = 1 # FIXME: Read and edit files requires more steps, consider later + tools: list[str] = ["Editor:write,read,write_content", "RoleZero", "WritePRD"] def __init__(self, **kwargs) -> None: super().__init__(**kwargs) + # NOTE: The following init setting will only be effective when self.use_fixed_sop is changed to True self.enable_memory = False self.set_actions([PrepareDocuments(send_to=any_to_str(self)), WritePRD]) self._watch([UserRequirement, PrepareDocuments]) - self.rc.react_mode = RoleReactMode.BY_ORDER - self.todo_action = any_to_name(WritePRD) + if self.use_fixed_sop: + self.rc.react_mode = RoleReactMode.BY_ORDER + + def _update_tool_execution(self): + wp = WritePRD() + self.tool_execution_map.update( + { + "WritePRD.run": wp.run, + "WritePRD": wp.run, # alias + } + ) async def _think(self) -> bool: """Decide what to do""" + if not self.use_fixed_sop: + return await super()._think() + if GitRepository.is_git_dir(self.config.project_path) and not self.config.git_reinit: self._set_state(1) else: diff --git a/metagpt/roles/project_manager.py b/metagpt/roles/project_manager.py index d6374e673..228b38660 100644 --- a/metagpt/roles/project_manager.py +++ b/metagpt/roles/project_manager.py @@ -5,13 +5,12 @@ @Author : alexanderwu @File : project_manager.py """ - from metagpt.actions import WriteTasks from metagpt.actions.design_api import WriteDesign -from metagpt.roles.role import Role +from metagpt.roles.di.role_zero import RoleZero -class ProjectManager(Role): +class ProjectManager(RoleZero): """ Represents a Project Manager role responsible for overseeing project execution and team efficiency. @@ -30,8 +29,22 @@ class ProjectManager(Role): ) constraints: str = "use same language as user requirement" + instruction: str = """Use WriteTasks tool to write a project task list""" + max_react_loop: int = 1 # FIXME: Read and edit files requires more steps, consider later + tools: list[str] = ["Editor:write,read,write_content", "RoleZero", "WriteTasks"] + def __init__(self, **kwargs) -> None: super().__init__(**kwargs) + # NOTE: The following init setting will only be effective when self.use_fixed_sop is changed to True self.enable_memory = False self.set_actions([WriteTasks]) self._watch([WriteDesign]) + + def _update_tool_execution(self): + wt = WriteTasks() + self.tool_execution_map.update( + { + "WriteTasks.run": wt.run, + "WriteTasks": wt.run, # alias + } + ) From 8e2c7c964f82ccb59eab5f0611070917c3a5e34e Mon Sep 17 00:00:00 2001 From: garylin2099 Date: Thu, 6 Jun 2024 10:41:49 +0800 Subject: [PATCH 08/10] provide example for running the whole software company; fix editor bug --- metagpt/tools/libs/editor.py | 2 +- .../environment/mgx_env/run_mgx_env.py | 26 ++++++++----- tests/metagpt/roles/di/run_architect.py | 38 +++++++++++++++++++ 3 files changed, 56 insertions(+), 10 deletions(-) create mode 100644 tests/metagpt/roles/di/run_architect.py diff --git a/metagpt/tools/libs/editor.py b/metagpt/tools/libs/editor.py index 9b36d5eea..23df02edd 100644 --- a/metagpt/tools/libs/editor.py +++ b/metagpt/tools/libs/editor.py @@ -27,7 +27,7 @@ class Editor: 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.""" directory = os.path.dirname(path) - if not os.path.exists(directory): + if directory and not os.path.exists(directory): os.makedirs(directory) with open(path, "w", encoding="utf-8") as f: f.write(content) diff --git a/tests/metagpt/environment/mgx_env/run_mgx_env.py b/tests/metagpt/environment/mgx_env/run_mgx_env.py index 42f550ab6..0d5287412 100644 --- a/tests/metagpt/environment/mgx_env/run_mgx_env.py +++ b/tests/metagpt/environment/mgx_env/run_mgx_env.py @@ -5,19 +5,25 @@ import threading 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.team_leader import TeamLeader from metagpt.schema import Message -async def main(requirement="", enable_human_input=False): - env = MGXEnv() +async def main(requirement="", enable_human_input=False, use_fixed_sop=False): + if use_fixed_sop: + engineer = Engineer(n_borg=5, use_code_review=False) + else: + engineer = Engineer2() + + env = MGXEnv(allow_bypass_team_leader=use_fixed_sop) env.add_roles( [ TeamLeader(), - ProductManager(), - Architect(), - ProjectManager(), - Engineer(n_borg=5, use_code_review=False), + ProductManager(use_fixed_sop=use_fixed_sop), + Architect(use_fixed_sop=use_fixed_sop), + ProjectManager(use_fixed_sop=use_fixed_sop), + engineer, # QaEngineer(), DataAnalyst(tools=[""]), ] @@ -52,8 +58,10 @@ def send_human_input(env): GAME_REQ = "create a 2048 game" +WEB_GAME_REQ = "Write a 2048 game using JavaScript without using any frameworks, user can play with keyboard." +WEB_GAME_REQ_DEPLOY = "Write a 2048 game using JavaScript without using any frameworks, user can play with keyboard. When finished, deploy the game to public at port 8090." SIMPLE_REQ = "print statistic summary of sklearn iris dataset" -WINE_REQ = "Run data analysis on sklearn Wine recognition dataset, include a plot, and train a model to predict wine class (20% as validation), and show validation accuracy." +WINE_REQ = "Run data analysis on sklearn Wine recognition dataset, and train a model to predict wine class (20% as validation), and show validation accuracy." PAPER_LIST_REQ = """ Get data from `paperlist` table in https://papercopilot.com/statistics/iclr-statistics/iclr-2024-statistics/, and save it to a csv file. paper title must include `multiagent` or `large language model`. *notice: print key variables* @@ -67,7 +75,7 @@ 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 = """ +FIX_ISSUE1 = """ 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 @@ -98,4 +106,4 @@ if __name__ == "__main__": 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=FIX_ISSUE, enable_human_input=False)) + asyncio.run(main(requirement=GAME_REQ, enable_human_input=False, use_fixed_sop=False)) diff --git a/tests/metagpt/roles/di/run_architect.py b/tests/metagpt/roles/di/run_architect.py new file mode 100644 index 000000000..e615af4eb --- /dev/null +++ b/tests/metagpt/roles/di/run_architect.py @@ -0,0 +1,38 @@ +import asyncio +import os + +from metagpt.roles.architect import Architect + +DESIGN_DOC_SNAKE = """ +{ + "Implementation approach": "We will use the Pygame library to create the CLI-based snake game. Pygame is a set of Python modules designed for writing video games, which will help us handle graphics, sound, and input. The game will be structured into different modules to handle the main game loop, snake movement, food generation, collision detection, and user interface. We will ensure the game is engaging and responsive by optimizing the game loop and input handling. The score display and different speed levels will be implemented to enhance the user experience.", + "File list": [ + "main.py", + "game.py", + "snake.py", + "food.py", + "ui.py" + ], + "Data structures and interfaces": "\nclassDiagram\n class Main {\n +main() void\n }\n class Game {\n -Snake snake\n -Food food\n -int score\n -int speed\n +__init__(speed: int)\n +run() void\n +restart() void\n +update_score() void\n }\n class Snake {\n -list body\n -str direction\n +__init__()\n +move() void\n +change_direction(new_direction: str) void\n +check_collision() bool\n +grow() void\n }\n class Food {\n -tuple position\n +__init__()\n +generate_new_position() void\n }\n class UI {\n +display_score(score: int) void\n +display_game_over() void\n +display_game(snake: Snake, food: Food) void\n }\n Main --> Game\n Game --> Snake\n Game --> Food\n Game --> UI\n", + "Program call flow": "\nsequenceDiagram\n participant M as Main\n participant G as Game\n participant S as Snake\n participant F as Food\n participant U as UI\n M->>G: __init__(speed)\n M->>G: run()\n G->>S: __init__()\n G->>F: __init__()\n loop Game Loop\n G->>S: move()\n G->>S: check_collision()\n alt Collision Detected\n G->>G: restart()\n G->>U: display_game_over()\n else No Collision\n G->>F: generate_new_position()\n G->>S: grow()\n G->>G: update_score()\n G->>U: display_score(score)\n end\n G->>U: display_game(snake, food)\n end\n", + "Anything UNCLEAR": "Currently, all aspects of the project are clear." +} +""" + +WRITE_SNAKE = """Write a system design for a cli snake game with pygame""" + +REWRITE_SNAKE = """Rewrite the system design at temp_design.json, add a web UI""" + +CASUAL_CHAT = """What's your name?""" + + +async def main(requirement): + with open("temp_design.json", "w") as f: + f.write(DESIGN_DOC_SNAKE) + architect = Architect() + await architect.run(requirement) + os.remove("temp_design.json") + + +if __name__ == "__main__": + asyncio.run(main(WRITE_SNAKE)) From 285ca321a0a37b69782d095fadba28dc82934dcd Mon Sep 17 00:00:00 2001 From: garylin2099 Date: Thu, 6 Jun 2024 12:04:41 +0800 Subject: [PATCH 09/10] only include run function for Action class --- metagpt/actions/design_api.py | 2 +- metagpt/actions/project_management.py | 2 +- metagpt/actions/write_prd.py | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/metagpt/actions/design_api.py b/metagpt/actions/design_api.py index 981dde53f..eb505fc36 100644 --- a/metagpt/actions/design_api.py +++ b/metagpt/actions/design_api.py @@ -48,7 +48,7 @@ NEW_REQ_TEMPLATE = """ """ -@register_tool(tags=["software development", "write system design"]) +@register_tool(include_functions=["run"]) class WriteDesign(Action): name: str = "" i_context: Optional[str] = None diff --git a/metagpt/actions/project_management.py b/metagpt/actions/project_management.py index b44bfb9f3..33bb97987 100644 --- a/metagpt/actions/project_management.py +++ b/metagpt/actions/project_management.py @@ -36,7 +36,7 @@ NEW_REQ_TEMPLATE = """ """ -@register_tool(tags=["software development", "write a project schedule given a project system design file"]) +@register_tool(include_functions=["run"]) class WriteTasks(Action): name: str = "CreateTasks" i_context: Optional[str] = None diff --git a/metagpt/actions/write_prd.py b/metagpt/actions/write_prd.py index 5ed45cab4..839b37151 100644 --- a/metagpt/actions/write_prd.py +++ b/metagpt/actions/write_prd.py @@ -67,7 +67,7 @@ NEW_REQ_TEMPLATE = """ """ -@register_tool(tags=["software development", "write product requirement documents"]) +@register_tool(include_functions=["run"]) class WritePRD(Action): """WritePRD deal with the following situations: 1. Bugfix: If the requirement is a bugfix, the bugfix document will be generated. From 95e7b0bf0071e3856e435e6a5e69b65fba340103 Mon Sep 17 00:00:00 2001 From: garylin2099 Date: Thu, 6 Jun 2024 17:27:26 +0800 Subject: [PATCH 10/10] log & small bug fixed --- metagpt/roles/di/role_zero.py | 30 ++++++++++++++++------------ metagpt/strategy/thinking_command.py | 12 +++-------- 2 files changed, 20 insertions(+), 22 deletions(-) diff --git a/metagpt/roles/di/role_zero.py b/metagpt/roles/di/role_zero.py index 36ce98032..9c678c600 100644 --- a/metagpt/roles/di/role_zero.py +++ b/metagpt/roles/di/role_zero.py @@ -3,7 +3,7 @@ from __future__ import annotations import inspect import json import traceback -from typing import Literal +from typing import Literal, Tuple from pydantic import model_validator @@ -113,17 +113,7 @@ class RoleZero(Role): example = self._retrieve_experience() ### 2. Plan Status ### - plan_status = self.planner.plan.model_dump(include=["goal", "tasks"]) - for task in plan_status["tasks"]: - task.pop("code") - task.pop("result") - task.pop("is_success") - # print(plan_status) - current_task = ( - self.planner.plan.current_task.model_dump(exclude=["code", "result", "is_success"]) - if self.planner.plan.current_task - else "" - ) + plan_status, current_task = self._get_plan_status() ### 3. Tool/Command Info ### tools = await self.tool_recommender.recommend_tools() @@ -205,7 +195,7 @@ class RoleZero(Role): outputs.append(output) except Exception as e: tb = traceback.format_exc() - print(e, tb) + logger.exception(e + tb) outputs.append(output + f": {tb}") break # Stop executing if any command fails else: @@ -229,6 +219,20 @@ class RoleZero(Role): return is_special_cmd + def _get_plan_status(self) -> Tuple[str, str]: + plan_status = self.planner.plan.model_dump(include=["goal", "tasks"]) + for task in plan_status["tasks"]: + task.pop("code") + task.pop("result") + task.pop("is_success") + # print(plan_status) + current_task = ( + self.planner.plan.current_task.model_dump(exclude=["code", "result", "is_success"]) + if self.planner.plan.current_task + else "" + ) + return plan_status, current_task + def _retrieve_experience(self) -> str: """Default implementation of experience retrieval. Can be overwritten in subclasses.""" context = [str(msg) for msg in self.rc.memory.get(self.memory_k)] diff --git a/metagpt/strategy/thinking_command.py b/metagpt/strategy/thinking_command.py index 2910af5cb..14fdf5950 100644 --- a/metagpt/strategy/thinking_command.py +++ b/metagpt/strategy/thinking_command.py @@ -7,7 +7,7 @@ from pydantic import BaseModel from metagpt.environment.mgx.mgx_env import MGXEnv from metagpt.memory import Memory from metagpt.roles import Role -from metagpt.schema import Message, Task +from metagpt.schema import Message class CommandDef(BaseModel): @@ -92,17 +92,11 @@ async def run_env_command(role: Role, cmd: list[dict], role_memory: Memory = Non def run_plan_command(role: Role, cmd: list[dict]): if cmd["command_name"] == Command.APPEND_TASK.cmd_name: - role.planner.plan.append_task(Task(**cmd["args"])) + role.planner.plan.append_task(**cmd["args"]) 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: - 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) + role.planner.plan.replace_task(**cmd["args"]) elif cmd["command_name"] == Command.FINISH_CURRENT_TASK.cmd_name: if role.planner.plan.is_plan_finished(): return