feat: +user requirement to Architect, ProjectManager

This commit is contained in:
莘权 马 2024-04-29 15:07:21 +08:00
parent df36fcc929
commit 125d043464
15 changed files with 264 additions and 911 deletions

View file

@ -9,10 +9,12 @@
"""
import shutil
from pathlib import Path
from typing import Optional
from typing import Dict, Optional
from metagpt.actions import Action, ActionOutput
from metagpt.actions import Action, UserRequirement
from metagpt.const import REQUIREMENT_FILENAME
from metagpt.schema import AIMessage
from metagpt.utils.common import any_to_str
from metagpt.utils.file_repository import FileRepository
@ -21,6 +23,13 @@ class PrepareDocuments(Action):
name: str = "PrepareDocuments"
i_context: Optional[str] = None
key_descriptions: Optional[Dict[str, str]] = None
send_to: str
def __init__(self, **kwargs):
super().__init__(**kwargs)
if not self.key_descriptions:
self.key_descriptions = {"project_path": 'the project path if exists in "Original Requirement"'}
@property
def config(self):
@ -40,10 +49,26 @@ class PrepareDocuments(Action):
async def run(self, with_messages, **kwargs):
"""Create and initialize the workspace folder, initialize the Git environment."""
user_requirements = [i for i in with_messages if i.cause_by == any_to_str(UserRequirement)]
if not self.config.project_path and user_requirements and self.key_descriptions:
args = await user_requirements[0].parse_resources(llm=self.llm, key_descriptions=self.key_descriptions)
for k, v in args.items():
if not v or k in ["resources", "reason"]:
continue
self.context.kwargs.set(k, v)
if self.context.kwargs.project_path:
self.config.update_via_cli(
project_path=self.context.kwargs.project_path,
project_name="",
inc=False,
reqa_file=self.context.kwargs.reqa_file or "",
max_auto_summarize_code=0,
)
self._init_repo()
# Write the newly added requirements from the main parameter idea to `docs/requirement.txt`.
doc = await self.repo.docs.save(filename=REQUIREMENT_FILENAME, content=with_messages[0].content)
# Send a Message notification to the WritePRD action, instructing it to process requirements using
# `docs/requirement.txt` and `docs/prd/`.
return ActionOutput(content=doc.content, instruct_content=doc)
return AIMessage(content="", instruct_content=doc, cause_by=self, send_to=self.send_to)

View file

@ -6,9 +6,11 @@
@File : architect.py
"""
from metagpt.actions import WritePRD
from metagpt.actions import UserRequirement, WritePRD
from metagpt.actions.design_api import WriteDesign
from metagpt.actions.prepare_documents import PrepareDocuments
from metagpt.roles.role import Role
from metagpt.utils.common import any_to_str
class Architect(Role):
@ -33,7 +35,25 @@ class Architect(Role):
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
# Initialize actions specific to the Architect role
self.set_actions([WriteDesign])
self.set_actions([PrepareDocuments(send_to=any_to_str(self), context=self.context), WriteDesign])
# Set events or actions the Architect should watch or be aware of
self._watch({WritePRD})
self._watch({UserRequirement, PrepareDocuments, WritePRD})
async def _think(self) -> bool:
"""Decide what to do"""
mappings = {
any_to_str(UserRequirement): 0,
any_to_str(PrepareDocuments): 1,
any_to_str(WritePRD): 1,
}
for i in self.rc.news:
idx = mappings.get(i.cause_by, -1)
if idx < 0:
continue
self.rc.todo = self.actions[idx]
return bool(self.rc.todo)
return False
async def _observe(self, ignore_memory=False) -> int:
return await super()._observe(ignore_memory=True)

View file

@ -24,8 +24,15 @@ from collections import defaultdict
from pathlib import Path
from typing import List, Optional, Set
from metagpt.actions import Action, WriteCode, WriteCodeReview, WriteTasks
from metagpt.actions import (
Action,
UserRequirement,
WriteCode,
WriteCodeReview,
WriteTasks,
)
from metagpt.actions.fix_bug import FixBug
from metagpt.actions.prepare_documents import PrepareDocuments
from metagpt.actions.project_management_an import REFINED_TASK_LIST, TASK_LIST
from metagpt.actions.summarize_code import SummarizeCode
from metagpt.actions.write_code_plan_and_change_an import WriteCodePlanAndChange
@ -95,7 +102,18 @@ class Engineer(Role):
super().__init__(**kwargs)
self.set_actions([WriteCode])
self._watch([WriteTasks, SummarizeCode, WriteCode, WriteCodeReview, FixBug, WriteCodePlanAndChange])
self._watch(
[
UserRequirement,
PrepareDocuments,
WriteTasks,
SummarizeCode,
WriteCode,
WriteCodeReview,
FixBug,
WriteCodePlanAndChange,
]
)
self.code_todos = []
self.summarize_todos = []
self.next_todo_action = any_to_name(WriteCode)
@ -245,14 +263,25 @@ class Engineer(Role):
return False, rsp
async def _think(self) -> Action | None:
if not self.src_workspace:
self.src_workspace = get_project_srcs_path(self.project_repo.workdir)
write_plan_and_change_filters = any_to_str_set([WriteTasks, FixBug])
write_code_filters = any_to_str_set([WriteTasks, WriteCodePlanAndChange, SummarizeCode])
summarize_code_filters = any_to_str_set([WriteCode, WriteCodeReview])
if not self.rc.news:
return None
msg = self.rc.news[0]
if msg.cause_by == any_to_str(UserRequirement):
self.rc.todo = PrepareDocuments(
key_descriptions={
"project_path": 'the project path if exists in "Original Requirement"',
"src_file_path": 'the path of the source code file explicitly requested for modification if exists in "Original Requirement"',
},
context=self.context,
send_to=any_to_str(self),
)
return self.rc.todo
if not self.src_workspace:
self.src_workspace = get_project_srcs_path(self.project_repo.workdir)
write_plan_and_change_filters = any_to_str_set([PrepareDocuments, WriteTasks, FixBug])
write_code_filters = any_to_str_set([WriteTasks, WriteCodePlanAndChange, SummarizeCode])
summarize_code_filters = any_to_str_set([WriteCode, WriteCodeReview])
if self.config.inc and msg.cause_by in write_plan_and_change_filters:
logger.debug(f"TODO WriteCodePlanAndChange:{msg.model_dump_json()}")
await self._new_code_plan_and_change_action(cause_by=msg.cause_by)

View file

@ -10,7 +10,7 @@
from metagpt.actions import UserRequirement, WritePRD
from metagpt.actions.prepare_documents import PrepareDocuments
from metagpt.roles.role import Role, RoleReactMode
from metagpt.utils.common import any_to_name
from metagpt.utils.common import any_to_name, any_to_str
class ProductManager(Role):
@ -33,7 +33,7 @@ class ProductManager(Role):
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.set_actions([PrepareDocuments, WritePRD])
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)

View file

@ -6,9 +6,11 @@
@File : project_manager.py
"""
from metagpt.actions import WriteTasks
from metagpt.actions import UserRequirement, WriteTasks
from metagpt.actions.design_api import WriteDesign
from metagpt.actions.prepare_documents import PrepareDocuments
from metagpt.roles.role import Role
from metagpt.utils.common import any_to_str
class ProjectManager(Role):
@ -33,5 +35,23 @@ class ProjectManager(Role):
def __init__(self, **kwargs) -> None:
super().__init__(**kwargs)
self.set_actions([WriteTasks])
self._watch([WriteDesign])
self.set_actions([PrepareDocuments(send_to=any_to_str(self), context=self.context), WriteTasks])
self._watch([UserRequirement, PrepareDocuments, WriteDesign])
async def _think(self) -> bool:
"""Decide what to do"""
mappings = {
any_to_str(UserRequirement): 0,
any_to_str(PrepareDocuments): 1,
any_to_str(WriteDesign): 1,
}
for i in self.rc.news:
idx = mappings.get(i.cause_by, -1)
if idx < 0:
continue
self.rc.todo = self.actions[idx]
return bool(self.rc.todo)
return False
async def _observe(self, ignore_memory=False) -> int:
return await super()._observe(ignore_memory=True)

View file

@ -16,12 +16,18 @@
"""
from metagpt.actions import DebugError, RunCode, 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.logs import logger
from metagpt.roles import Role
from metagpt.schema import Document, Message, RunCodeContext, TestingContext
from metagpt.utils.common import any_to_str_set, init_python_folder, parse_recipient
from metagpt.schema import AIMessage, Document, Message, RunCodeContext, TestingContext
from metagpt.utils.common import (
any_to_str,
any_to_str_set,
init_python_folder,
parse_recipient,
)
class QaEngineer(Role):
@ -40,8 +46,20 @@ class QaEngineer(Role):
# FIXME: a bit hack here, only init one action to circumvent _think() logic,
# will overwrite _think() in future updates
self.set_actions([WriteTest])
self._watch([SummarizeCode, WriteTest, RunCode, DebugError])
self.set_actions(
[
PrepareDocuments(
send_to=any_to_str(self),
key_descriptions={
"project_path": 'the project path if exists in "Original Requirement"',
"reqa_file": 'the path of the source code file explicitly requested for unit test if exists in "Original Requirement"',
},
context=self.context,
),
WriteTest,
]
)
self._watch([PrepareDocuments, SummarizeCode, WriteTest, RunCode, DebugError])
self.test_round = 0
async def _write_test(self, message: Message) -> None:
@ -80,9 +98,8 @@ class QaEngineer(Role):
additional_python_paths=[str(self.context.src_workspace)],
)
self.publish_message(
Message(
AIMessage(
content=run_code_context.model_dump_json(),
role=self.profile,
cause_by=WriteTest,
sent_from=self,
send_to=self,
@ -116,9 +133,8 @@ class QaEngineer(Role):
recipient = parse_recipient(result.summary)
mappings = {"Engineer": "Alex", "QaEngineer": "Edward"}
self.publish_message(
Message(
AIMessage(
content=run_code_context.model_dump_json(),
role=self.profile,
cause_by=RunCode,
sent_from=self,
send_to=mappings.get(recipient, MESSAGE_ROUTE_TO_NONE),
@ -131,9 +147,8 @@ 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(
Message(
AIMessage(
content=run_code_context.model_dump_json(),
role=self.profile,
cause_by=DebugError,
sent_from=self,
send_to=self,
@ -143,16 +158,15 @@ class QaEngineer(Role):
async def _act(self) -> Message:
await init_python_folder(self.project_repo.tests.workdir)
if self.test_round > self.test_round_allowed:
result_msg = Message(
result_msg = AIMessage(
content=f"Exceeding {self.test_round_allowed} rounds of tests, skip (writing code counts as a round, too)",
role=self.profile,
cause_by=WriteTest,
sent_from=self.profile,
send_to=MESSAGE_ROUTE_TO_NONE,
)
return result_msg
code_filters = any_to_str_set({SummarizeCode})
code_filters = any_to_str_set({PrepareDocuments, SummarizeCode})
test_filters = any_to_str_set({WriteTest, DebugError})
run_filters = any_to_str_set({RunCode})
for msg in self.rc.news:
@ -168,9 +182,8 @@ class QaEngineer(Role):
# I ran my test code, time to fix bugs, if any
await self._debug_error(msg)
self.test_round += 1
return Message(
return AIMessage(
content=f"Round {self.test_round} of tests done",
role=self.profile,
cause_by=WriteTest,
sent_from=self.profile,
send_to=MESSAGE_ROUTE_TO_NONE,

View file

@ -34,7 +34,7 @@ from metagpt.context_mixin import ContextMixin
from metagpt.logs import logger
from metagpt.memory import Memory
from metagpt.provider import HumanProvider
from metagpt.schema import Message, MessageQueue, SerializationMixin
from metagpt.schema import AIMessage, Message, MessageQueue, SerializationMixin
from metagpt.strategy.planner import Planner
from metagpt.utils.common import any_to_name, any_to_str, role_raise_decorator
from metagpt.utils.project_repo import ProjectRepo
@ -390,17 +390,16 @@ class Role(SerializationMixin, ContextMixin, BaseModel):
logger.info(f"{self._setting}: to do {self.rc.todo}({self.rc.todo.name})")
response = await self.rc.todo.run(self.rc.history)
if isinstance(response, (ActionOutput, ActionNode)):
msg = Message(
msg = AIMessage(
content=response.content,
instruct_content=response.instruct_content,
role=self._setting,
cause_by=self.rc.todo,
sent_from=self,
)
elif isinstance(response, Message):
msg = response
else:
msg = Message(content=response, role=self.profile, cause_by=self.rc.todo, sent_from=self)
msg = AIMessage(content=response, cause_by=self.rc.todo, sent_from=self)
self.rc.memory.add(msg)
return msg
@ -451,7 +450,7 @@ class Role(SerializationMixin, ContextMixin, BaseModel):
Use llm to select actions in _think dynamically
"""
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:
# think
has_todo = await self._think()
@ -466,7 +465,7 @@ class Role(SerializationMixin, ContextMixin, BaseModel):
async def _act_by_order(self) -> Message:
"""switch action each time by order defined in _init_actions, i.e. _act (Action1) -> _act (Action2) -> ..."""
start_idx = self.rc.state if self.rc.state >= 0 else 0 # action to run from recovered state
rsp = Message(content="No actions taken yet") # return default message if actions=[]
rsp = AIMessage(content="No actions taken yet") # return default message if actions=[]
for i in range(start_idx, len(self.states)):
self._set_state(i)
rsp = await self._act()

View file

@ -351,8 +351,9 @@ class UserMessage(Message):
Facilitate support for OpenAI messages
"""
def __init__(self, content: str):
super().__init__(content=content, role="user")
def __init__(self, content: str, **kwargs):
kwargs.pop("role", None)
super().__init__(content=content, role="user", **kwargs)
class SystemMessage(Message):
@ -360,8 +361,9 @@ class SystemMessage(Message):
Facilitate support for OpenAI messages
"""
def __init__(self, content: str):
super().__init__(content=content, role="system")
def __init__(self, content: str, **kwargs):
kwargs.pop("role", None)
super().__init__(content=content, role="system", **kwargs)
class AIMessage(Message):
@ -369,8 +371,9 @@ class AIMessage(Message):
Facilitate support for OpenAI messages
"""
def __init__(self, content: str):
super().__init__(content=content, role="assistant")
def __init__(self, content: str, **kwargs):
kwargs.pop("role", None)
super().__init__(content=content, role="assistant", **kwargs)
class Task(BaseModel):

View file

@ -7,6 +7,7 @@ from pathlib import Path
import typer
from metagpt.const import CONFIG_ROOT
from metagpt.utils.common import any_to_str
from metagpt.utils.project_repo import ProjectRepo
app = typer.Typer(add_completion=False, pretty_exceptions_show_locals=False)
@ -65,7 +66,7 @@ def generate_repo(
idea = company.idea
company.invest(investment)
company.run_project(idea)
company.run_project(idea, send_to=any_to_str(ProductManager))
asyncio.run(company.run(n_round=n_round))
return ctx.repo

View file

@ -20,7 +20,7 @@ from metagpt.context import Context
from metagpt.environment import Environment
from metagpt.logs import logger
from metagpt.roles import Role
from metagpt.schema import Message
from metagpt.schema import UserMessage
from metagpt.utils.common import (
NoMoneyException,
read_json_file,
@ -102,7 +102,7 @@ class Team(BaseModel):
# Human requirement.
self.env.publish_message(
Message(role="Human", content=idea, cause_by=UserRequirement, send_to=send_to or MESSAGE_ROUTE_TO_ALL),
UserMessage(content=idea, cause_by=UserRequirement, send_to=send_to or MESSAGE_ROUTE_TO_ALL),
peekable=False,
)

View file

@ -17,9 +17,6 @@ from metagpt.tools.libs import (
deployer,
)
from metagpt.tools.libs.env import get_env, set_get_env_entry, default_get_env, get_env_description
from metagpt.tools.libs.software_development import (
git_archive,
)
_ = (
data_preprocess,
@ -28,7 +25,6 @@ _ = (
gpt_v_generator,
web_scraping,
email_login,
git_archive,
terminal,
file_manager,
browser,

View file

@ -3,384 +3,10 @@
from __future__ import annotations
from pathlib import Path
from typing import Optional
from metagpt.const import ASSISTANT_ALIAS, BUGFIX_FILENAME, REQUIREMENT_FILENAME
from metagpt.const import ASSISTANT_ALIAS
from metagpt.logs import ToolLogItem, log_tool_output
from metagpt.schema import BugFixContext, Message
from metagpt.tools.tool_registry import register_tool
from metagpt.utils.common import any_to_str
from metagpt.utils.project_repo import ProjectRepo
@register_tool(tags=["software development", "ProductManager"])
async def write_prd(idea: str, project_path: Optional[str | Path] = None) -> Path:
"""Writes a PRD based on user requirements.
Args:
idea (str): The idea or concept for the PRD.
project_path (Optional[str|Path], optional): The path to an existing project directory.
If it's None, a new project path will be created. Defaults to None.
Returns:
Path: The path to the PRD files under the project directory
Example:
>>> # Create a new project:
>>> from metagpt.tools.libs.software_development import write_prd
>>> prd_path = await write_prd("Create a new feature for the application")
>>> print(prd_path)
'/path/to/project_path/docs/prd/'
>>> # Add user requirements to the exists project:
>>> from metagpt.tools.libs.software_development import write_prd
>>> project_path = '/path/to/exists_project_path'
>>> prd_path = await write_prd("Create a new feature for the application", project_path=project_path)
>>> print(prd_path = )
'/path/to/project_path/docs/prd/'
"""
from metagpt.actions import UserRequirement
from metagpt.context import Context
from metagpt.roles import ProductManager
log_tool_output(output=[ToolLogItem(name=ASSISTANT_ALIAS, value=write_prd.__name__)], tool_name=write_prd.__name__)
ctx = Context()
if project_path and Path(project_path).exists():
ctx.config.project_path = Path(project_path)
ctx.config.inc = True
role = ProductManager(context=ctx)
msg = await role.run(with_message=Message(content=idea, cause_by=UserRequirement))
await role.run(with_message=msg)
outputs = [
ToolLogItem(name="Intermedia PRD File", value=str(ctx.repo.docs.prd.workdir / i))
for i in ctx.repo.docs.prd.changed_files.keys()
]
outputs.extend(
[
ToolLogItem(name="PRD File", value=str(ctx.repo.resources.prd.workdir / i))
for i in ctx.repo.resources.prd.changed_files.keys()
]
)
outputs.extend(
[
ToolLogItem(name="Competitive Analysis", value=str(ctx.repo.resources.competitive_analysis.workdir / i))
for i in ctx.repo.resources.competitive_analysis.changed_files.keys()
]
)
log_tool_output(output=outputs, tool_name=write_prd.__name__)
return ctx.repo.docs.prd.workdir
@register_tool(tags=["Design", "software development", "Architect"])
async def write_design(prd_path: str | Path) -> Path:
"""Writes a system design to the project repository, based on the PRD of the project.
Args:
prd_path (str|Path): The path to the PRD files under the project directory.
Returns:
Path: The path to the system design files under the project directory.
Example:
>>> from metagpt.tools.libs.software_development import write_design
>>> prd_path = '/path/to/project_path/docs/prd' # Returned by `write_prd`
>>> system_design_path = await write_desgin(prd_path)
>>> print(system_design_path)
'/path/to/project_path/docs/system_design/'
"""
from metagpt.actions import WritePRD
from metagpt.context import Context
from metagpt.roles import Architect
log_tool_output(
output=[ToolLogItem(name=ASSISTANT_ALIAS, value=write_design.__name__)], tool_name=write_design.__name__
)
ctx = Context()
prd_path = Path(prd_path)
project_path = (Path(prd_path) if not prd_path.is_file() else prd_path.parent) / "../.."
ctx.set_repo_dir(project_path)
role = Architect(context=ctx)
await role.run(with_message=Message(content="", cause_by=WritePRD))
outputs = [
ToolLogItem(name="Intermedia Design File", value=str(ctx.repo.docs.system_design.workdir / i))
for i in ctx.repo.docs.system_design.changed_files.keys()
]
for i in ctx.repo.resources.system_design.changed_files.keys():
outputs.append(ToolLogItem(name="Design File", value=str(ctx.repo.resources.system_design.workdir / i)))
for i in ctx.repo.resources.data_api_design.changed_files.keys():
outputs.append(
ToolLogItem(name="Class Diagram File", value=str(ctx.repo.resources.data_api_design.workdir / i))
)
for i in ctx.repo.resources.seq_flow.changed_files.keys():
outputs.append(ToolLogItem(name="Sequence Diagram File", value=str(ctx.repo.resources.seq_flow.workdir / i)))
log_tool_output(output=outputs, tool_name=write_design.__name__)
return ctx.repo.docs.system_design.workdir
@register_tool(tags=["software development", "Architect"])
async def write_project_plan(system_design_path: str | Path) -> Path:
"""Writes a project plan to the project repository, based on the design of the project.
Args:
system_design_path (str|Path): The path to the system design files under the project directory.
Returns:
Path: The path to task files under the project directory.
Example:
>>> from metagpt.tools.libs.software_development import write_project_plan
>>> system_design_path = '/path/to/project_path/docs/system_design/' # Returned by `write_design`
>>> task_path = await write_project_plan(system_design_path)
>>> print(task_path)
'/path/to/project_path/docs/task'
"""
from metagpt.actions import WriteDesign
from metagpt.context import Context
from metagpt.roles import ProjectManager
log_tool_output(
output=[ToolLogItem(name=ASSISTANT_ALIAS, value=write_project_plan.__name__)],
tool_name=write_project_plan.__name__,
)
ctx = Context()
system_design_path = Path(system_design_path)
project_path = (system_design_path if not system_design_path.is_file() else system_design_path.parent) / "../.."
ctx.set_repo_dir(project_path)
role = ProjectManager(context=ctx)
await role.run(with_message=Message(content="", cause_by=WriteDesign))
outputs = [
ToolLogItem(name="Intermedia Project Plan", value=str(ctx.repo.docs.task.workdir / i))
for i in ctx.repo.docs.task.changed_files.keys()
]
outputs.extend(
[
ToolLogItem(name="Project Plan", value=str(ctx.repo.resources.api_spec_and_task.workdir / i))
for i in ctx.repo.resources.api_spec_and_task.changed_files.keys()
]
)
log_tool_output(output=outputs, tool_name=write_project_plan.__name__)
return ctx.repo.docs.task.workdir
@register_tool(tags=["software development", "Engineer"])
async def write_codes(task_path: str | Path, inc: bool = False) -> Path:
"""Writes code to implement designed features according to the project plan and adds them to the project repository.
In code writing tasks, prioritize calling this tool against writing code from scratch directly.
Args:
task_path (str|Path): The path to task files under the project directory.
inc (bool, optional): Whether to write incremental codes. Defaults to False.
Returns:
Path: The path to the source code files under the project directory.
Example:
# Write codes to a new project
>>> from metagpt.tools.libs.software_development import write_codes
>>> task_path = '/path/to/project_path/docs/task' # Returned by `write_project_plan`
>>> src_path = await write_codes(task_path)
>>> print(src_path)
'/path/to/project_path/src/'
# Write increment codes to the exists project
>>> from metagpt.tools.libs.software_development import write_codes
>>> task_path = '/path/to/project_path/docs/task' # Returned by `write_prd`
>>> src_path = await write_codes(task_path, inc=True)
>>> print(src_path)
'/path/to/project_path/src/'
"""
from metagpt.actions import WriteTasks
from metagpt.context import Context
from metagpt.roles import Engineer
log_tool_output(
output=[ToolLogItem(name=ASSISTANT_ALIAS, value=write_codes.__name__)], tool_name=write_codes.__name__
)
ctx = Context()
ctx.config.inc = inc
task_path = Path(task_path)
project_path = (task_path if not task_path.is_file() else task_path.parent) / "../.."
ctx.set_repo_dir(project_path)
role = Engineer(context=ctx)
msg = Message(content="", cause_by=WriteTasks, send_to=role)
me = {any_to_str(role), role.name}
while me.intersection(msg.send_to):
msg = await role.run(with_message=msg)
outputs = [
ToolLogItem(name="Source File", value=str(ctx.repo.srcs.workdir / i))
for i in ctx.repo.srcs.changed_files.keys()
]
log_tool_output(output=outputs, tool_name=write_codes.__name__)
return ctx.repo.srcs.workdir
@register_tool(tags=["software development", "QaEngineer"])
async def run_qa_test(src_path: str | Path) -> Path:
"""Run QA test on the project repository.
Args:
src_path (str|Path): The path to the source code files under the project directory.
Returns:
Path: The path to the unit tests under the project directory
Example:
>>> from metagpt.tools.libs.software_development import run_qa_test
>>> src_path = '/path/to/project_path/src/' # Returned by `write_codes`
>>> test_path = await run_qa_test(src_path)
>>> print(test_path)
'/path/to/project_path/tests'
"""
from metagpt.actions.summarize_code import SummarizeCode
from metagpt.context import Context
from metagpt.environment import Environment
from metagpt.roles import QaEngineer
log_tool_output(
output=[ToolLogItem(name=ASSISTANT_ALIAS, value=run_qa_test.__name__)], tool_name=run_qa_test.__name__
)
ctx = Context()
src_path = Path(src_path)
project_path = (src_path if not src_path.is_file() else src_path.parent) / ".."
ctx.set_repo_dir(project_path)
ctx.src_workspace = ctx.git_repo.workdir / ctx.git_repo.workdir.name
env = Environment(context=ctx)
role = QaEngineer(context=ctx)
env.add_role(role)
msg = Message(content="", cause_by=SummarizeCode, send_to=role)
env.publish_message(msg)
while not env.is_idle:
await env.run()
outputs = [
ToolLogItem(name="Unit Test File", value=str(ctx.repo.tests.workdir / i))
for i in ctx.repo.tests.changed_files.keys()
]
log_tool_output(output=outputs, tool_name=run_qa_test.__name__)
return ctx.repo.tests.workdir
@register_tool(tags=["software development", "Engineer"])
async def fix_bug(project_path: str | Path, issue: str) -> Path:
"""Fix bugs in the project repository.
Args:
project_path (str|Path): The path to the project repository.
issue (str): Description of the bug or issue.
Returns:
Path: The path to the project directory
Example:
>>> from metagpt.tools.libs.software_development import fix_bug
>>> project_path = '/path/to/project_path' # Returned by `write_codes`
>>> issue = 'Exception: exception about ...; Bug: bug about ...; Issue: issue about ...'
>>> project_path = await fix_bug(project_path=project_path, issue=issue)
>>> print(project_path)
'/path/to/project_path'
"""
from metagpt.actions.fix_bug import FixBug
from metagpt.context import Context
from metagpt.roles import Engineer
log_tool_output(output=[ToolLogItem(name=ASSISTANT_ALIAS, value=fix_bug.__name__)], tool_name=fix_bug.__name__)
ctx = Context()
ctx.set_repo_dir(project_path)
ctx.src_workspace = ctx.git_repo.workdir / ctx.git_repo.workdir.name
await ctx.repo.docs.save(filename=BUGFIX_FILENAME, content=issue)
await ctx.repo.docs.save(filename=REQUIREMENT_FILENAME, content="")
role = Engineer(context=ctx)
bug_fix = BugFixContext(filename=BUGFIX_FILENAME)
msg = Message(
content=bug_fix.model_dump_json(),
instruct_content=bug_fix,
role="",
cause_by=FixBug,
sent_from=role,
send_to=role,
)
me = {any_to_str(role), role.name}
while me.intersection(msg.send_to):
msg = await role.run(with_message=msg)
outputs = [
ToolLogItem(name="Changed File", value=str(ctx.repo.srcs.workdir / i))
for i in ctx.repo.srcs.changed_files.keys()
]
log_tool_output(output=outputs, tool_name=fix_bug.__name__)
return project_path
@register_tool(tags=["software development", "git"])
async def git_archive(project_path: str | Path) -> str:
"""Stage and commit changes for the project repository using Git.
Args:
project_path (str|Path): The path to the project repository.
Returns:
git log
Example:
>>> from metagpt.tools.libs.software_development import git_archive
>>> project_path = '/path/to/project_path' # Returned by `write_prd`
>>> git_log = await git_archive(project_path=project_path)
>>> print(git_log)
commit a221d1c418c07f2b4fc07001e486285ead1a520a (HEAD -> feature/toollib/software_company, geekan/main)
Merge: e01afd09 4a72f398
Author: Sirui Hong <x@xx.github.com>
Date: Tue Mar 19 15:16:03 2024 +0800
Merge pull request #1037 from iorisa/fixbug/issues/1018
fixbug: #1018
"""
from metagpt.context import Context
log_tool_output(
output=[ToolLogItem(name=ASSISTANT_ALIAS, value=git_archive.__name__)], tool_name=git_archive.__name__
)
ctx = Context()
project_dir = ProjectRepo.search_project_path(project_path)
if not project_dir:
ValueError(f"{project_path} is not a valid git repository.")
ctx.set_repo_dir(project_dir)
files = " ".join(ctx.git_repo.changed_files.keys())
outputs = [ToolLogItem(name="cmd", value=f"git add {files}")]
log_tool_output(output=outputs, tool_name=git_archive.__name__)
ctx.git_repo.archive()
outputs = [ToolLogItem(name="cmd", value="git commit -m 'Archive'")]
log_tool_output(output=outputs, tool_name=git_archive.__name__)
return ctx.git_repo.log()
@register_tool(tags=["software development", "import git repo"])

File diff suppressed because one or more lines are too long

View file

@ -11,53 +11,123 @@ from pathlib import Path
import pytest
from metagpt.actions import UserRequirement
from metagpt.actions.prepare_documents import PrepareDocuments
from metagpt.context import Context
from metagpt.environment import Environment
from metagpt.logs import logger
from metagpt.roles import Architect, ProductManager, Role
from metagpt.schema import Message
from metagpt.roles import (
Architect,
Engineer,
ProductManager,
ProjectManager,
QaEngineer,
)
from metagpt.schema import Message, UserMessage
from metagpt.utils.common import any_to_str, is_send_to
serdeser_path = Path(__file__).absolute().parent.joinpath("../data/serdeser_storage")
@pytest.fixture
def env():
return Environment()
# @pytest.fixture
# def env():
# return Environment()
#
#
# def test_add_role(env: Environment):
# role = ProductManager(
# name="Alice", profile="product manager", goal="create a new product", constraints="limited resources"
# )
# env.add_role(role)
# assert env.get_role(role.profile) == role
#
#
# def test_get_roles(env: Environment):
# role1 = Role(name="Alice", profile="product manager", goal="create a new product", constraints="limited resources")
# role2 = Role(name="Bob", profile="engineer", goal="develop the new product", constraints="short deadline")
# env.add_role(role1)
# env.add_role(role2)
# roles = env.get_roles()
# assert roles == {role1.profile: role1, role2.profile: role2}
#
#
# @pytest.mark.asyncio
# async def test_publish_and_process_message(env: Environment):
# if env.context.git_repo:
# env.context.git_repo.delete_repository()
# env.context.git_repo = None
#
# product_manager = ProductManager(name="Alice", profile="Product Manager", goal="做AI Native产品", constraints="资源有限")
# architect = Architect(
# name="Bob", profile="Architect", goal="设计一个可用、高效、较低成本的系统,包括数据结构与接口", constraints="资源有限,需要节省成本"
# )
#
# env.add_roles([product_manager, architect])
#
# env.publish_message(Message(role="User", content="需要一个基于LLM做总结的搜索引擎", cause_by=UserRequirement))
# await env.run(k=2)
# logger.info(f"{env.history}")
# assert len(env.history.storage) > 10
def test_add_role(env: Environment):
role = ProductManager(
name="Alice", profile="product manager", goal="create a new product", constraints="limited resources"
)
env.add_role(role)
assert env.get_role(role.profile) == role
class MockEnv(Environment):
def publish_message(self, message: Message, peekable: bool = True) -> bool:
consumers = []
for role, addrs in self.member_addrs.items():
if is_send_to(message, addrs):
role.put_message(message)
consumers.append(role)
if not consumers:
logger.warning(f"Message no recipients: {message.dump()}")
if message.cause_by in [any_to_str(UserRequirement), any_to_str(PrepareDocuments)]:
assert len(consumers) == 1
def test_get_roles(env: Environment):
role1 = Role(name="Alice", profile="product manager", goal="create a new product", constraints="limited resources")
role2 = Role(name="Bob", profile="engineer", goal="develop the new product", constraints="short deadline")
env.add_role(role1)
env.add_role(role2)
roles = env.get_roles()
assert roles == {role1.profile: role1, role2.profile: role2}
return True
@pytest.mark.asyncio
async def test_publish_and_process_message(env: Environment):
if env.context.git_repo:
env.context.git_repo.delete_repository()
env.context.git_repo = None
product_manager = ProductManager(name="Alice", profile="Product Manager", goal="做AI Native产品", constraints="资源有限")
architect = Architect(
name="Bob", profile="Architect", goal="设计一个可用、高效、较低成本的系统,包括数据结构与接口", constraints="资源有限,需要节省成本"
@pytest.mark.parametrize(
("content", "send_to"),
[
# ("snake game", any_to_str(ProductManager)),
# (
# "Rewrite the PRD file of the project at '/Users/iorishinier/github/MetaGPT/workspace/snake_game', add 'moving enemy' to the original requirement",
# any_to_str(ProductManager),
# ),
# (
# "Add 'random moving enemy, and dispears after 10 seconds' design to the project at '/Users/iorishinier/github/MetaGPT/workspace/snake_game'",
# any_to_str(Architect),
# ),
(
'Rewrite the tasks file of the project at "/Users/iorishinier/github/MetaGPT/workspace/snake_game"',
any_to_str(ProjectManager),
),
# (
# "Rewrite 'main.py' of the project at '/Users/iorishinier/github/MetaGPT/workspace/snake_game'",
# any_to_str(Engineer),
# ),
# (
# "Rewrite the unit test of 'test_main.py' at '/Users/iorishinier/github/MetaGPT/workspace/snake_game'",
# any_to_str(QaEngineer),
# ),
],
)
async def test_env(content, send_to):
context = Context()
env = MockEnv(context=context)
env.add_roles(
[
ProductManager(context=context),
Architect(context=context),
ProjectManager(context=context),
Engineer(n_borg=5, use_code_review=True, context=context),
QaEngineer(context=context),
]
)
env.add_roles([product_manager, architect])
env.publish_message(Message(role="User", content="需要一个基于LLM做总结的搜索引擎", cause_by=UserRequirement))
await env.run(k=2)
logger.info(f"{env.history=}")
assert len(env.history) > 10
msg = UserMessage(content=content, send_to=send_to)
env.publish_message(msg)
while not env.is_idle:
await env.run()
pass
if __name__ == "__main__":

View file

@ -85,7 +85,7 @@ class MockLLM(OriginalLLM):
format_msgs: Optional[list[dict[str, str]]] = None,
images: Optional[Union[str, list[str]]] = None,
timeout=LLM_API_TIMEOUT,
stream=True,
stream=False,
) -> str:
# used to identify it a message has been called before
if isinstance(msg, list):