diff --git a/metagpt/actions/design_api.py b/metagpt/actions/design_api.py index 83139035a..613c4a47b 100644 --- a/metagpt/actions/design_api.py +++ b/metagpt/actions/design_api.py @@ -13,7 +13,7 @@ import json from pathlib import Path from typing import Optional -from metagpt.actions import Action, ActionOutput +from metagpt.actions import Action from metagpt.actions.design_api_an import ( DATA_STRUCTURES_AND_INTERFACES, DESIGN_API_NODE, @@ -24,7 +24,7 @@ from metagpt.actions.design_api_an import ( ) from metagpt.const import DATA_API_DESIGN_FILE_REPO, SEQ_FLOW_FILE_REPO from metagpt.logs import logger -from metagpt.schema import Document, Documents, Message +from metagpt.schema import AIMessage, Document, Documents, Message from metagpt.utils.mermaid import mermaid_to_file from metagpt.utils.report import DocsReporter, GalleryReporter @@ -68,7 +68,15 @@ class WriteDesign(Action): logger.info("Nothing has changed.") # Wait until all files under `docs/system_designs/` are processed before sending the publish message, # leaving room for global optimization in subsequent steps. - return ActionOutput(content=changed_files.model_dump_json(), instruct_content=changed_files) + return AIMessage( + content="Designing is complete. " + + "\n".join( + list(self.repo.docs.system_design.changed_files.keys()) + + list(self.repo.resources.data_api_design.changed_files.keys()) + + list(self.repo.resources.seq_flow.changed_files.keys()) + ), + cause_by=self, + ) async def _new_system_design(self, context): node = await DESIGN_API_NODE.fill(context=context, llm=self.llm, schema=self.prompt_schema) diff --git a/metagpt/actions/prepare_documents.py b/metagpt/actions/prepare_documents.py index bba3deaa3..eb674374c 100644 --- a/metagpt/actions/prepare_documents.py +++ b/metagpt/actions/prepare_documents.py @@ -13,6 +13,7 @@ from typing import Dict, Optional from metagpt.actions import Action, UserRequirement from metagpt.const import REQUIREMENT_FILENAME +from metagpt.logs import logger from metagpt.schema import AIMessage from metagpt.utils.common import any_to_str from metagpt.utils.file_repository import FileRepository @@ -56,6 +57,7 @@ class PrepareDocuments(Action): if not v or k in ["resources", "reason"]: continue self.context.kwargs.set(k, v) + logger.info(f"{k}={v}") if self.context.kwargs.project_path: self.config.update_via_cli( project_path=self.context.kwargs.project_path, diff --git a/metagpt/actions/project_management.py b/metagpt/actions/project_management.py index 5aace9fa9..ef0fe6fc6 100644 --- a/metagpt/actions/project_management.py +++ b/metagpt/actions/project_management.py @@ -14,11 +14,10 @@ import json from typing import Optional from metagpt.actions.action import Action -from metagpt.actions.action_output import ActionOutput from metagpt.actions.project_management_an import PM_NODE, REFINED_PM_NODE from metagpt.const import PACKAGE_REQUIREMENTS_FILENAME from metagpt.logs import logger -from metagpt.schema import Document, Documents +from metagpt.schema import AIMessage, Document, Documents from metagpt.utils.report import DocsReporter NEW_REQ_TEMPLATE = """ @@ -55,7 +54,15 @@ class WriteTasks(Action): logger.info("Nothing has changed.") # Wait until all files under `docs/tasks/` are processed before sending the publish_message, leaving room for # global optimization in subsequent steps. - return ActionOutput(content=change_files.model_dump_json(), instruct_content=change_files) + return AIMessage( + content="WBS is completed. " + + "\n".join( + [PACKAGE_REQUIREMENTS_FILENAME] + + list(self.repo.docs.task.changed_files.keys()) + + list(self.repo.resources.api_spec_and_task.changed_files.keys()) + ), + cause_by=self, + ) async def _update_tasks(self, filename): system_design_doc = await self.repo.docs.system_design.get(filename) diff --git a/metagpt/actions/write_prd.py b/metagpt/actions/write_prd.py index 02bd1d6d5..a4f6e1dd1 100644 --- a/metagpt/actions/write_prd.py +++ b/metagpt/actions/write_prd.py @@ -33,7 +33,7 @@ from metagpt.const import ( REQUIREMENT_FILENAME, ) from metagpt.logs import logger -from metagpt.schema import BugFixContext, Document, Documents, Message +from metagpt.schema import AIMessage, Document, Documents, Message from metagpt.utils.common import CodeParser from metagpt.utils.file_repository import FileRepository from metagpt.utils.mermaid import mermaid_to_file @@ -66,7 +66,7 @@ class WritePRD(Action): 3. Requirement update: If the requirement is an update, the PRD document will be updated. """ - async def run(self, with_messages, *args, **kwargs) -> ActionOutput | Message: + async def run(self, with_messages, *args, **kwargs) -> Message: """Run the action.""" req: Document = await self.repo.requirement docs: list[Document] = await self.repo.docs.prd.get_all() @@ -82,22 +82,27 @@ class WritePRD(Action): # if requirement is related to other documents, update them, otherwise create a new one if related_docs := await self.get_related_docs(req, docs): logger.info(f"Requirement update detected: {req.content}") - return await self._handle_requirement_update(req, related_docs) + await self._handle_requirement_update(req, related_docs) else: logger.info(f"New requirement detected: {req.content}") - return await self._handle_new_requirement(req) + await self._handle_new_requirement(req) + return AIMessage( + content="PRD is completed. " + + "\n".join( + list(self.repo.docs.prd.changed_files.keys()) + + list(self.repo.resources.prd.changed_files.keys()) + + list(self.repo.resources.competitive_analysis.changed_files.keys()) + ), + cause_by=self, + ) async def _handle_bugfix(self, req: Document) -> Message: # ... bugfix logic ... await self.repo.docs.save(filename=BUGFIX_FILENAME, content=req.content) await self.repo.docs.save(filename=REQUIREMENT_FILENAME, content="") - bug_fix = BugFixContext(filename=BUGFIX_FILENAME) - return Message( - content=bug_fix.model_dump_json(), - instruct_content=bug_fix, - role="", + return AIMessage( + content=f"A new issue is received: {BUGFIX_FILENAME}", cause_by=FixBug, - sent_from=self, send_to="Alex", # the name of Engineer ) diff --git a/metagpt/const.py b/metagpt/const.py index eaa22434f..6e823d56c 100644 --- a/metagpt/const.py +++ b/metagpt/const.py @@ -81,6 +81,7 @@ MESSAGE_ROUTE_CAUSE_BY = "cause_by" MESSAGE_META_ROLE = "role" MESSAGE_ROUTE_TO_ALL = "" MESSAGE_ROUTE_TO_NONE = "" +MESSAGE_ROUTE_TO_SELF = "" # Add this tag to replace `ActionOutput` REQUIREMENT_FILENAME = "requirement.txt" BUGFIX_FILENAME = "bugfix.txt" diff --git a/metagpt/environment/base_env.py b/metagpt/environment/base_env.py index f6d2e431d..fe1660fc6 100644 --- a/metagpt/environment/base_env.py +++ b/metagpt/environment/base_env.py @@ -202,10 +202,13 @@ class Environment(ExtEnv): for _ in range(k): futures = [] for role in self.roles.values(): + if role.is_idle: + continue future = role.run() futures.append(future) - await asyncio.gather(*futures) + if futures: + await asyncio.gather(*futures) logger.debug(f"is idle: {self.is_idle}") def get_roles(self) -> dict[str, "Role"]: diff --git a/metagpt/roles/engineer.py b/metagpt/roles/engineer.py index e7b5cf219..a76db09b1 100644 --- a/metagpt/roles/engineer.py +++ b/metagpt/roles/engineer.py @@ -39,6 +39,7 @@ from metagpt.actions.write_code_plan_and_change_an import WriteCodePlanAndChange from metagpt.const import ( BUGFIX_FILENAME, CODE_PLAN_AND_CHANGE_FILE_REPO, + MESSAGE_ROUTE_TO_SELF, REQUIREMENT_FILENAME, SYSTEM_DESIGN_FILE_REPO, TASK_FILE_REPO, @@ -150,12 +151,6 @@ class Engineer(Role): dependencies=list(dependencies), content=coding_context.code_doc.content, ) - AIMessage( - content=coding_context.model_dump_json(), - instruct_content=coding_context, - cause_by=WriteCode, - ) - changed_files.add(coding_context.code_doc.filename) if not changed_files: logger.info("Nothing has changed.") @@ -177,17 +172,16 @@ class Engineer(Role): return await self.rc.todo.run(self.rc.history) async def _act_write_code(self): - changed_files = await self._act_sp_with_cr(review=self.use_code_review) + await self._act_sp_with_cr(review=self.use_code_review) return AIMessage( - content="\n".join(changed_files), - cause_by=WriteCodeReview if self.use_code_review else WriteCode, - send_to=self, - sent_from=self, + content="", cause_by=WriteCodeReview if self.use_code_review else WriteCode, send_to=MESSAGE_ROUTE_TO_SELF ) async def _act_summarize(self): tasks = [] for todo in self.summarize_todos: + if self.n_summarize >= self.config.max_auto_summarize_code: + break summary = await todo.run() summary_filename = Path(todo.i_context.design_filename).with_suffix(".md").name dependencies = {todo.i_context.design_filename, todo.i_context.task_filename} @@ -209,19 +203,23 @@ class Engineer(Role): ) else: await self.project_repo.docs.code_summary.delete(filename=Path(todo.i_context.design_filename).name) - + self.summarize_todos = [] logger.info(f"--max-auto-summarize-code={self.config.max_auto_summarize_code}") if not tasks or self.config.max_auto_summarize_code == 0: + self.n_summarize = 0 return AIMessage( - content="", + content="Coding is complete. " + "\n".join( + list(self.project_repo.resources.code_summary.changed_files.keys()) + + list(self.project_repo.srcs.changed_files.keys()) + ), cause_by=SummarizeCode, - sent_from=self, send_to="Edward", # The name of QaEngineer ) # The maximum number of times the 'SummarizeCode' action is automatically invoked, with -1 indicating unlimited. # This parameter is used for debugging the workflow. self.n_summarize += 1 if self.config.max_auto_summarize_code > self.n_summarize else 0 - return AIMessage(content=json.dumps(tasks), cause_by=SummarizeCode, send_to=self, sent_from=self) + return AIMessage(content="", cause_by=SummarizeCode, send_to=MESSAGE_ROUTE_TO_SELF) async def _act_code_plan_and_change(self): """Write code plan and change that guides subsequent WriteCode and WriteCodeReview""" @@ -243,12 +241,7 @@ class Engineer(Role): dependencies=dependencies, ) - return AIMessage( - content=code_plan_and_change, - cause_by=WriteCodePlanAndChange, - send_to=self, - sent_from=self, - ) + return AIMessage(content="", cause_by=WriteCodePlanAndChange, send_to=MESSAGE_ROUTE_TO_SELF) async def _is_pass(self, summary) -> (str, str): rsp = await self.llm.aask(msg=IS_PASS_PROMPT.format(context=summary), stream=False) @@ -416,7 +409,6 @@ class Engineer(Role): self.summarize_todos.append(new_summarize) if self.summarize_todos: self.set_todo(self.summarize_todos[0]) - self.summarize_todos.pop(0) async def _new_code_plan_and_change_action(self, cause_by: str): """Create a WriteCodePlanAndChange action for subsequent to-do actions.""" diff --git a/metagpt/roles/qa_engineer.py b/metagpt/roles/qa_engineer.py index c5f22174f..f76baff3f 100644 --- a/metagpt/roles/qa_engineer.py +++ b/metagpt/roles/qa_engineer.py @@ -18,10 +18,9 @@ from metagpt.actions import DebugError, RunCode, UserRequirement, WriteTest from metagpt.actions.prepare_documents import PrepareDocuments from metagpt.actions.summarize_code import SummarizeCode -from metagpt.const import MESSAGE_ROUTE_TO_NONE +from metagpt.const import MESSAGE_ROUTE_TO_NONE, MESSAGE_ROUTE_TO_SELF from metagpt.logs import logger from metagpt.roles import Role -from metagpt.utils.report import EditorReporter from metagpt.schema import AIMessage, Document, Message, RunCodeContext, TestingContext from metagpt.utils.common import ( any_to_str, @@ -29,6 +28,7 @@ from metagpt.utils.common import ( init_python_folder, parse_recipient, ) +from metagpt.utils.report import EditorReporter class QaEngineer(Role): @@ -95,12 +95,7 @@ class QaEngineer(Role): additional_python_paths=[str(self.context.src_workspace)], ) self.publish_message( - AIMessage( - content=run_code_context.model_dump_json(), - cause_by=WriteTest, - sent_from=self, - send_to=self, - ) + AIMessage(content=run_code_context.model_dump_json(), cause_by=WriteTest, send_to=MESSAGE_ROUTE_TO_SELF) ) logger.info(f"Done {str(self.project_repo.tests.workdir)} generating.") @@ -133,7 +128,6 @@ class QaEngineer(Role): AIMessage( content=run_code_context.model_dump_json(), cause_by=RunCode, - sent_from=self, send_to=mappings.get(recipient, MESSAGE_ROUTE_TO_NONE), ) ) @@ -144,12 +138,7 @@ class QaEngineer(Role): await self.project_repo.tests.save(filename=run_code_context.test_filename, content=code) run_code_context.output = None self.publish_message( - AIMessage( - content=run_code_context.model_dump_json(), - cause_by=DebugError, - sent_from=self, - send_to=self, - ) + AIMessage(content=run_code_context.model_dump_json(), cause_by=DebugError, send_to=MESSAGE_ROUTE_TO_SELF) ) async def _act(self) -> Message: @@ -157,9 +146,9 @@ class QaEngineer(Role): await init_python_folder(self.project_repo.tests.workdir) if self.test_round > self.test_round_allowed: result_msg = AIMessage( - content=f"Exceeding {self.test_round_allowed} rounds of tests, skip (writing code counts as a round, too)", + content=f"Exceeding {self.test_round_allowed} rounds of tests, stop. " + + "\n".join(list(self.project_repo.tests.changed_files.keys())), cause_by=WriteTest, - sent_from=self.profile, send_to=MESSAGE_ROUTE_TO_NONE, ) return result_msg @@ -185,7 +174,6 @@ class QaEngineer(Role): return AIMessage( content=f"Round {self.test_round} of tests done", cause_by=WriteTest, - sent_from=self.profile, send_to=MESSAGE_ROUTE_TO_NONE, ) diff --git a/metagpt/roles/role.py b/metagpt/roles/role.py index b6f6b7d20..1eaa77fa3 100644 --- a/metagpt/roles/role.py +++ b/metagpt/roles/role.py @@ -30,6 +30,7 @@ from pydantic import BaseModel, ConfigDict, Field, SerializeAsAny, model_validat from metagpt.actions import Action, ActionOutput from metagpt.actions.action_node import ActionNode from metagpt.actions.add_requirement import UserRequirement +from metagpt.const import MESSAGE_ROUTE_TO_SELF from metagpt.context_mixin import ContextMixin from metagpt.logs import logger from metagpt.memory import Memory @@ -408,7 +409,7 @@ class Role(SerializationMixin, ContextMixin, BaseModel): elif isinstance(response, Message): msg = response else: - msg = AIMessage(content=response, cause_by=self.rc.todo, sent_from=self) + msg = AIMessage(content=response or "", cause_by=self.rc.todo, sent_from=self) if self.enable_memory: self.rc.memory.add(msg) @@ -443,12 +444,19 @@ class Role(SerializationMixin, ContextMixin, BaseModel): """If the role belongs to env, then the role's messages will be broadcast to env""" if not msg: return + if MESSAGE_ROUTE_TO_SELF in msg.send_to: + msg.send_to.add(any_to_str(self)) + msg.send_to.remove(MESSAGE_ROUTE_TO_SELF) + if not msg.sent_from or msg.sent_from == MESSAGE_ROUTE_TO_SELF: + msg.sent_from = any_to_str(self) if all(to in {any_to_str(self), self.name} for to in msg.send_to): # Message to myself self.put_message(msg) return if not self.rc.env: # If env does not exist, do not publish the message return + if isinstance(msg, AIMessage) and not msg.agent: + msg.with_agent(self._setting) self.rc.env.publish_message(msg) def put_message(self, message): diff --git a/metagpt/schema.py b/metagpt/schema.py index 5fc1ae242..1b527b594 100644 --- a/metagpt/schema.py +++ b/metagpt/schema.py @@ -200,7 +200,7 @@ class Message(BaseModel): """list[: ]""" id: str = Field(default="", validate_default=True) # According to Section 2.2.3.1.1 of RFC 135 - content: str + content: str # natural language for user or agent instruct_content: Optional[BaseModel] = Field(default=None, validate_default=True) role: str = "user" # system / user / assistant cause_by: str = Field(default="", validate_default=True) @@ -748,10 +748,6 @@ class CodeSummarizeContext(BaseModel): return hash((self.design_filename, self.task_filename)) -class BugFixContext(BaseContext): - filename: str = "" - - class CodePlanAndChangeContext(BaseModel): requirement: str = "" issue: str = "" diff --git a/tests/metagpt/test_environment.py b/tests/metagpt/test_environment.py index 07f5b4305..522013804 100644 --- a/tests/metagpt/test_environment.py +++ b/tests/metagpt/test_environment.py @@ -31,6 +31,7 @@ serdeser_path = Path(__file__).absolute().parent.joinpath("../data/serdeser_stor class MockEnv(Environment): def publish_message(self, message: Message, peekable: bool = True) -> bool: + logger.info(f"{message.metadata}:{message.content}") consumers = [] for role, addrs in self.member_addrs.items(): if is_send_to(message, addrs): @@ -87,6 +88,7 @@ async def test_publish_and_process_message(env: Environment): assert len(env.history.storage) == 0 +@pytest.mark.skip @pytest.mark.asyncio @pytest.mark.parametrize( ("content", "send_to"), @@ -105,7 +107,7 @@ async def test_publish_and_process_message(env: Environment): any_to_str(ProjectManager), ), ( - "Rewrite 'main.py' of the project at '/Users/iorishinier/github/MetaGPT/workspace/snake_game'", + "src filename is 'game.py', Uncaught SyntaxError: Identifier 'Position' has already been declared (at game.js:1:1), the project at '/Users/iorishinier/github/bak/MetaGPT/workspace/snake_game'", any_to_str(Engineer), ), (