diff --git a/.gitignore b/.gitignore index 1613a638d..2c59f3b59 100644 --- a/.gitignore +++ b/.gitignore @@ -171,3 +171,4 @@ tests/metagpt/utils/file_repo_git *.png htmlcov htmlcov.* +*.pkl diff --git a/docs/scripts/coverage.sh b/docs/scripts/coverage.sh index 648d9b412..a56571399 100755 --- a/docs/scripts/coverage.sh +++ b/docs/scripts/coverage.sh @@ -1 +1 @@ -coverage run --source ./metagpt -m pytest --durations=0 && coverage report -m && coverage html && open htmlcov/index.html +coverage run --source ./metagpt -m pytest --durations=0 --timeout=100 && coverage report -m && coverage html && open htmlcov/index.html diff --git a/examples/debate_simple.py b/examples/debate_simple.py index 1a80bf8f4..aa95c5b85 100644 --- a/examples/debate_simple.py +++ b/examples/debate_simple.py @@ -12,11 +12,11 @@ from metagpt.environment import Environment from metagpt.roles import Role from metagpt.team import Team -action1 = Action(name="BidenSay", instruction="Express opinions and argue vigorously, and strive to gain votes") -action2 = Action(name="TrumpSay", instruction="Express opinions and argue vigorously, and strive to gain votes") -biden = Role(name="Biden", profile="Democratic candidate", goal="Win the election", actions=[action1], watch=[action2]) -trump = Role(name="Trump", profile="Republican candidate", goal="Win the election", actions=[action2], watch=[action1]) +action1 = Action(name="AlexSay", instruction="Express your opinion with emotion and don't repeat it") +action2 = Action(name="BobSay", instruction="Express your opinion with emotion and don't repeat it") +alex = Role(name="Alex", profile="Democratic candidate", goal="Win the election", actions=[action1], watch=[action2]) +bob = Role(name="Bob", profile="Republican candidate", goal="Win the election", actions=[action2], watch=[action1]) env = Environment(desc="US election live broadcast") -team = Team(investment=10.0, env=env, roles=[biden, trump]) +team = Team(investment=10.0, env=env, roles=[alex, bob]) -asyncio.run(team.run(idea="Topic: climate change. Under 80 words per message.", send_to="Biden", n_round=5)) +asyncio.run(team.run(idea="Topic: climate change. Under 80 words per message.", send_to="Alex", n_round=5)) diff --git a/examples/example.faiss b/examples/example.faiss index a5a539dc4..580946190 100644 Binary files a/examples/example.faiss and b/examples/example.faiss differ diff --git a/examples/example.pkl b/examples/example.pkl index a0e839763..f706fd803 100644 Binary files a/examples/example.pkl and b/examples/example.pkl differ diff --git a/examples/faq.xlsx b/examples/example.xlsx similarity index 100% rename from examples/faq.xlsx rename to examples/example.xlsx diff --git a/metagpt/actions/action.py b/metagpt/actions/action.py index 9b94ce461..b586bcc22 100644 --- a/metagpt/actions/action.py +++ b/metagpt/actions/action.py @@ -8,9 +8,9 @@ from __future__ import annotations -from typing import Any, Optional, Union +from typing import Optional, Union -from pydantic import ConfigDict, Field +from pydantic import ConfigDict, Field, model_validator from metagpt.actions.action_node import ActionNode from metagpt.llm import LLM @@ -34,16 +34,19 @@ class Action(SerializationMixin, is_polymorphic_base=True): desc: str = "" # for skill manager node: ActionNode = Field(default=None, exclude=True) - def __init_with_instruction(self, instruction: str): - """Initialize action with instruction""" - self.node = ActionNode(key=self.name, expected_type=str, instruction=instruction, example="", schema="raw") - return self + @model_validator(mode="before") + def set_name_if_empty(cls, values): + if "name" not in values or not values["name"]: + values["name"] = cls.__name__ + return values - def __init__(self, **data: Any): - super().__init__(**data) - - if "instruction" in data: - self.__init_with_instruction(data["instruction"]) + @model_validator(mode="before") + def _init_with_instruction(cls, values): + if "instruction" in values: + name = values["name"] + i = values["instruction"] + values["node"] = ActionNode(key=name, expected_type=str, instruction=i, example="", schema="raw") + return values def set_prefix(self, prefix): """Set prefix for later usage""" diff --git a/metagpt/actions/action_node.py b/metagpt/actions/action_node.py index 4c06d0d1d..6c65b33ef 100644 --- a/metagpt/actions/action_node.py +++ b/metagpt/actions/action_node.py @@ -11,7 +11,7 @@ NOTE: You should use typing.List instead of list to do type annotation. Because import json from typing import Any, Dict, List, Optional, Tuple, Type -from pydantic import BaseModel, create_model, field_validator, model_validator +from pydantic import BaseModel, create_model, model_validator from tenacity import retry, stop_after_attempt, wait_random_exponential from metagpt.config import CONFIG @@ -135,26 +135,21 @@ class ActionNode: @classmethod def create_model_class(cls, class_name: str, mapping: Dict[str, Tuple[Type, Any]]): """基于pydantic v1的模型动态生成,用来检验结果类型正确性""" - new_class = create_model(class_name, **mapping) - @field_validator("*", mode="before") - @classmethod - def check_name(v, field): - if field.name not in mapping.keys(): - raise ValueError(f"Unrecognized block: {field.name}") - return v - - @model_validator(mode="before") - @classmethod - def check_missing_fields(values): + def check_fields(cls, values): required_fields = set(mapping.keys()) missing_fields = required_fields - set(values.keys()) if missing_fields: raise ValueError(f"Missing fields: {missing_fields}") + + unrecognized_fields = set(values.keys()) - required_fields + if unrecognized_fields: + logger.warning(f"Unrecognized fields: {unrecognized_fields}") return values - new_class.__validator_check_name = classmethod(check_name) - new_class.__root_validator_check_missing_fields = classmethod(check_missing_fields) + validators = {"check_missing_fields_validator": model_validator(mode="before")(check_fields)} + + new_class = create_model(class_name, __validators__=validators, **mapping) return new_class def create_children_class(self, exclude=None): diff --git a/metagpt/document.py b/metagpt/document.py index 022e5d6f1..f4fa0a489 100644 --- a/metagpt/document.py +++ b/metagpt/document.py @@ -20,8 +20,6 @@ from langchain.text_splitter import CharacterTextSplitter from pydantic import BaseModel, ConfigDict, Field from tqdm import tqdm -from metagpt.config import CONFIG -from metagpt.logs import logger from metagpt.repo_parser import RepoParser @@ -103,6 +101,7 @@ class Document(BaseModel): raise ValueError("File path is not set.") self.path.parent.mkdir(parents=True, exist_ok=True) + # TODO: excel, csv, json, etc. self.path.write_text(self.content, encoding="utf-8") def persist(self): @@ -128,10 +127,12 @@ class IndexableDocument(Document): if not data_path.exists(): raise FileNotFoundError(f"File {data_path} not found.") data = read_data(data_path) - content = data_path.read_text() if isinstance(data, pd.DataFrame): validate_cols(content_col, data) - return cls(data=data, content=content, content_col=content_col, meta_col=meta_col) + return cls(data=data, content=str(data), content_col=content_col, meta_col=meta_col) + else: + content = data_path.read_text() + return cls(data=data, content=content, content_col=content_col, meta_col=meta_col) def _get_docs_and_metadatas_by_df(self) -> (list, list): df = self.data @@ -213,7 +214,7 @@ class Repo(BaseModel): self.assets[path] = doc return doc - def set(self, content: str, filename: str): + def set(self, filename: str, content: str): """Set a document and persist it to disk.""" path = self._path(filename) doc = self._set(content, path) @@ -232,24 +233,3 @@ class Repo(BaseModel): n_chars = sum(sum(len(j.content) for j in i.values()) for i in [self.docs, self.codes, self.assets]) symbols = RepoParser(base_directory=self.path).generate_symbols() return RepoMetadata(name=self.name, n_docs=n_docs, n_chars=n_chars, symbols=symbols) - - -def set_existing_repo(path=CONFIG.workspace_path / "t1"): - repo1 = Repo.from_path(path) - repo1.set("wtf content", "doc/wtf_file.md") - repo1.set("wtf code", "code/wtf_file.py") - logger.info(repo1) # check doc - - -def load_existing_repo(path=CONFIG.workspace_path / "web_tetris"): - repo = Repo.from_path(path) - logger.info(repo) - logger.info(repo.eda()) - - -def main(): - load_existing_repo() - - -if __name__ == "__main__": - main() diff --git a/metagpt/document_store/document.py b/metagpt/document_store/document.py deleted file mode 100644 index 90abc54de..000000000 --- a/metagpt/document_store/document.py +++ /dev/null @@ -1,81 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/6/8 14:03 -@Author : alexanderwu -@File : document.py -@Desc : Classes and Operations Related to Vector Files in the Vector Database. Still under design. -""" -from pathlib import Path - -import pandas as pd -from langchain.document_loaders import ( - TextLoader, - UnstructuredPDFLoader, - UnstructuredWordDocumentLoader, -) -from langchain.text_splitter import CharacterTextSplitter -from tqdm import tqdm - - -def validate_cols(content_col: str, df: pd.DataFrame): - if content_col not in df.columns: - raise ValueError - - -def read_data(data_path: Path): - suffix = data_path.suffix - if ".xlsx" == suffix: - data = pd.read_excel(data_path) - elif ".csv" == suffix: - data = pd.read_csv(data_path) - elif ".json" == suffix: - data = pd.read_json(data_path) - elif suffix in (".docx", ".doc"): - data = UnstructuredWordDocumentLoader(str(data_path), mode="elements").load() - elif ".txt" == suffix: - data = TextLoader(str(data_path)).load() - text_splitter = CharacterTextSplitter(separator="\n", chunk_size=256, chunk_overlap=0) - texts = text_splitter.split_documents(data) - data = texts - elif ".pdf" == suffix: - data = UnstructuredPDFLoader(str(data_path), mode="elements").load() - else: - raise NotImplementedError - return data - - -class Document: - def __init__(self, data_path, content_col="content", meta_col="metadata"): - self.data = read_data(data_path) - if isinstance(self.data, pd.DataFrame): - validate_cols(content_col, self.data) - self.content_col = content_col - self.meta_col = meta_col - - def _get_docs_and_metadatas_by_df(self) -> (list, list): - df = self.data - docs = [] - metadatas = [] - for i in tqdm(range(len(df))): - docs.append(df[self.content_col].iloc[i]) - if self.meta_col: - metadatas.append({self.meta_col: df[self.meta_col].iloc[i]}) - else: - metadatas.append({}) - - return docs, metadatas - - def _get_docs_and_metadatas_by_langchain(self) -> (list, list): - data = self.data - docs = [i.page_content for i in data] - metadatas = [i.metadata for i in data] - return docs, metadatas - - def get_docs_and_metadatas(self) -> (list, list): - if isinstance(self.data, pd.DataFrame): - return self._get_docs_and_metadatas_by_df() - elif isinstance(self.data, list): - return self._get_docs_and_metadatas_by_langchain() - else: - raise NotImplementedError diff --git a/metagpt/document_store/faiss_store.py b/metagpt/document_store/faiss_store.py index bfba1d386..1271f1c23 100644 --- a/metagpt/document_store/faiss_store.py +++ b/metagpt/document_store/faiss_store.py @@ -14,7 +14,6 @@ from langchain.vectorstores import FAISS from langchain_core.embeddings import Embeddings from metagpt.config import CONFIG -from metagpt.const import DATA_PATH from metagpt.document import IndexableDocument from metagpt.document_store.base_store import LocalStore from metagpt.logs import logger @@ -76,10 +75,3 @@ class FaissStore(LocalStore): def delete(self, *args, **kwargs): """Currently, langchain does not provide a delete interface.""" raise NotImplementedError - - -if __name__ == "__main__": - faiss_store = FaissStore(DATA_PATH / "qcs/qcs_4w.json") - logger.info(faiss_store.search("Oily Skin Facial Cleanser")) - faiss_store.add([f"Oily Skin Facial Cleanser-{i}" for i in range(3)]) - logger.info(faiss_store.search("Oily Skin Facial Cleanser")) diff --git a/metagpt/prompts/decompose.py b/metagpt/prompts/decompose.py deleted file mode 100644 index ab0c360d3..000000000 --- a/metagpt/prompts/decompose.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/30 10:09 -@Author : alexanderwu -@File : decompose.py -""" - -DECOMPOSE_SYSTEM = """SYSTEM: -You serve as an assistant that helps me play Minecraft. -I will give you my goal in the game, please break it down as a tree-structure plan to achieve this goal. -The requirements of the tree-structure plan are: -1. The plan tree should be exactly of depth 2. -2. Describe each step in one line. -3. You should index the two levels like ’1.’, ’1.1.’, ’1.2.’, ’2.’, ’2.1.’, etc. -4. The sub-goals at the bottom level should be basic actions so that I can easily execute them in the game. -""" - - -DECOMPOSE_USER = """USER: -The goal is to {goal description}. Generate the plan according to the requirements. -""" diff --git a/metagpt/prompts/structure_action.py b/metagpt/prompts/structure_action.py deleted file mode 100644 index 97c57cf24..000000000 --- a/metagpt/prompts/structure_action.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/30 10:12 -@Author : alexanderwu -@File : structure_action.py -""" - -ACTION_SYSTEM = """SYSTEM: -You serve as an assistant that helps me play Minecraft. -I will give you a sentence. Please convert this sentence into one or several actions according to the following instructions. -Each action should be a tuple of four items, written in the form (’verb’, ’object’, ’tools’, ’materials’) -’verb’ is the verb of this action. -’object’ refers to the target object of the action. -’tools’ specifies the tools required for the action. -’material’ specifies the materials required for the action. -If some of the items are not required, set them to be ’None’. -""" - -ACTION_USER = """USER: -The sentence is {sentence}. Generate the action tuple according to the requirements. -""" diff --git a/metagpt/prompts/structure_goal.py b/metagpt/prompts/structure_goal.py deleted file mode 100644 index e4b1a3bee..000000000 --- a/metagpt/prompts/structure_goal.py +++ /dev/null @@ -1,46 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/30 09:51 -@Author : alexanderwu -@File : structure_goal.py -""" - -GOAL_SYSTEM = """SYSTEM: -You are an assistant for the game Minecraft. -I will give you some target object and some knowledge related to the object. Please write the obtaining of the object as a goal in the standard form. -The standard form of the goal is as follows: -{ -"object": "the name of the target object", -"count": "the target quantity", -"material": "the materials required for this goal, a dictionary in the form {material_name: material_quantity}. If no material is required, set it to None", -"tool": "the tool used for this goal. If multiple tools can be used for this goal, only write the most basic one. If no tool is required, set it to None", -"info": "the knowledge related to this goal" -} -The information I will give you: -Target object: the name and the quantity of the target object -Knowledge: some knowledge related to the object. -Requirements: -1. You must generate the goal based on the provided knowledge instead of purely depending on your own knowledge. -2. The "info" should be as compact as possible, at most 3 sentences. The knowledge I give you may be raw texts from Wiki documents. Please extract and summarize important information instead of directly copying all the texts. -Goal Example: -{ -"object": "iron_ore", -"count": 1, -"material": None, -"tool": "stone_pickaxe", -"info": "iron ore is obtained by mining iron ore. iron ore is most found in level 53. iron ore can only be mined with a stone pickaxe or better; using a wooden or gold pickaxe will yield nothing." -} -{ -"object": "wooden_pickaxe", -"count": 1, -"material": {"planks": 3, "stick": 2}, -"tool": "crafting_table", -"info": "wooden pickaxe can be crafted with 3 planks and 2 stick as the material and crafting table as the tool." -} -""" - -GOAL_USER = """USER: -Target object: {object quantity} {object name} -Knowledge: {related knowledge} -""" diff --git a/metagpt/prompts/use_lib_sop.py b/metagpt/prompts/use_lib_sop.py deleted file mode 100644 index b43ed5125..000000000 --- a/metagpt/prompts/use_lib_sop.py +++ /dev/null @@ -1,88 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/30 10:45 -@Author : alexanderwu -@File : use_lib_sop.py -""" - -SOP_SYSTEM = """SYSTEM: -You serve as an assistant that helps me play the game Minecraft. -I will give you a goal in the game. Please think of a plan to achieve the goal, and then write a sequence of actions to realize the plan. The requirements and instructions are as follows: -1. You can only use the following functions. Don’t make plans purely based on your experience, think about how to use these functions. -explore(object, strategy) -Move around to find the object with the strategy: used to find objects including block items and entities. This action is finished once the object is visible (maybe at the distance). -Augments: -- object: a string, the object to explore. -- strategy: a string, the strategy for exploration. -approach(object) -Move close to a visible object: used to approach the object you want to attack or mine. It may fail if the target object is not accessible. -Augments: -- object: a string, the object to approach. -craft(object, materials, tool) -Craft the object with the materials and tool: used for crafting new object that is not in the inventory or is not enough. The required materials must be in the inventory and will be consumed, and the newly crafted objects will be added to the inventory. The tools like the crafting table and furnace should be in the inventory and this action will directly use them. Don’t try to place or approach the crafting table or furnace, you will get failed since this action does not support using tools placed on the ground. You don’t need to collect the items after crafting. If the quantity you require is more than a unit, this action will craft the objects one unit by one unit. If the materials run out halfway through, this action will stop, and you will only get part of the objects you want that have been crafted. -Augments: -- object: a dict, whose key is the name of the object and value is the object quantity. -- materials: a dict, whose keys are the names of the materials and values are the quantities. -- tool: a string, the tool used for crafting. Set to null if no tool is required. -mine(object, tool) -Mine the object with the tool: can only mine the object within reach, cannot mine object from a distance. If there are enough objects within reach, this action will mine as many as you specify. The obtained objects will be added to the inventory. -Augments: -- object: a string, the object to mine. -- tool: a string, the tool used for mining. Set to null if no tool is required. -attack(object, tool) -Attack the object with the tool: used to attack the object within reach. This action will keep track of and attack the object until it is killed. -Augments: -- object: a string, the object to attack. -- tool: a string, the tool used for mining. Set to null if no tool is required. -equip(object) -Equip the object from the inventory: used to equip equipment, including tools, weapons, and armor. The object must be in the inventory and belong to the items for equipping. -Augments: -- object: a string, the object to equip. -digdown(object, tool) -Dig down to the y-level with the tool: the only action you can take if you want to go underground for mining some ore. -Augments: -- object: an int, the y-level (absolute y coordinate) to dig to. -- tool: a string, the tool used for digging. Set to null if no tool is required. -go_back_to_ground(tool) -Go back to the ground from underground: the only action you can take for going back to the ground if you are underground. -Augments: -- tool: a string, the tool used for digging. Set to null if no tool is required. -apply(object, tool) -Apply the tool on the object: used for fetching water, milk, lava with the tool bucket, pooling water or lava to the object with the tool water bucket or lava bucket, shearing sheep with the tool shears, blocking attacks with the tool shield. -Augments: -- object: a string, the object to apply to. -- tool: a string, the tool used to apply. -2. You cannot define any new function. Note that the "Generated structures" world creation option is turned off. -3. There is an inventory that stores all the objects I have. It is not an entity, but objects can be added to it or retrieved from it anytime at anywhere without specific actions. The mined or crafted objects will be added to this inventory, and the materials and tools to use are also from this inventory. Objects in the inventory can be directly used. Don’t write the code to obtain them. If you plan to use some object not in the inventory, you should first plan to obtain it. You can view the inventory as one of my states, and it is written in form of a dictionary whose keys are the name of the objects I have and the values are their quantities. -4. You will get the following information about my current state: -- inventory: a dict representing the inventory mentioned above, whose keys are the name of the objects and the values are their quantities -- environment: a string including my surrounding biome, the y-level of my current location, and whether I am on the ground or underground -Pay attention to this information. Choose the easiest way to achieve the goal conditioned on my current state. Do not provide options, always make the final decision. -5. You must describe your thoughts on the plan in natural language at the beginning. After that, you should write all the actions together. The response should follow the format: -{ -"explanation": "explain why the last action failed, set to null for the first planning", -"thoughts": "Your thoughts on the plan in natural languag", -"action_list": [ -{"name": "action name", "args": {"arg name": value}, "expectation": "describe the expected results of this action"}, -{"name": "action name", "args": {"arg name": value}, "expectation": "describe the expected results of this action"}, -{"name": "action name", "args": {"arg name": value}, "expectation": "describe the expected results of this action"} -] -} -The action_list can contain arbitrary number of actions. The args of each action should correspond to the type mentioned in the Arguments part. Remember to add “‘dict“‘ at the beginning and the end of the dict. Ensure that you response can be parsed by Python json.loads -6. I will execute your code step by step and give you feedback. If some action fails, I will stop at that action and will not execute its following actions. The feedback will include error messages about the failed action. At that time, you should replan and write the new code just starting from that failed action. -""" - - -SOP_USER = """USER: -My current state: -- inventory: {inventory} -- environment: {environment} -The goal is to {goal}. -Here is one plan to achieve similar goal for reference: {reference plan}. -Begin your plan. Remember to follow the response format. -or Action {successful action} succeeded, and {feedback message}. Continue your -plan. Do not repeat successful action. Remember to follow the response format. -or Action {failed action} failed, because {feedback message}. Revise your plan from -the failed action. Remember to follow the response format. -""" diff --git a/metagpt/provider/azure_openai_api.py b/metagpt/provider/azure_openai_api.py index b59326c7f..d15d1c82e 100644 --- a/metagpt/provider/azure_openai_api.py +++ b/metagpt/provider/azure_openai_api.py @@ -27,7 +27,7 @@ class AzureOpenAILLM(OpenAILLM): def _init_client(self): kwargs = self._make_client_kwargs() # https://learn.microsoft.com/zh-cn/azure/ai-services/openai/how-to/migration?tabs=python-new%2Cdalle-fix - self.async_client = AsyncAzureOpenAI(**kwargs) + self.aclient = AsyncAzureOpenAI(**kwargs) self.model = self.config.DEPLOYMENT_NAME # Used in _calc_usage & _cons_kwargs def _make_client_kwargs(self) -> dict: diff --git a/metagpt/provider/general_api_base.py b/metagpt/provider/general_api_base.py index bbe03774c..1b9149396 100644 --- a/metagpt/provider/general_api_base.py +++ b/metagpt/provider/general_api_base.py @@ -15,7 +15,6 @@ from enum import Enum from typing import ( AsyncGenerator, AsyncIterator, - Callable, Dict, Iterator, Optional, @@ -240,54 +239,6 @@ class APIRequestor: self.api_version = api_version or openai.api_version self.organization = organization or openai.organization - def _check_polling_response(self, response: OpenAIResponse, predicate: Callable[[OpenAIResponse], bool]): - if not predicate(response): - return - error_data = response.data["error"] - message = error_data.get("message", "Operation failed") - code = error_data.get("code") - raise openai.APIError(message=message, body=dict(code=code)) - - def _poll( - self, method, url, until, failed, params=None, headers=None, interval=None, delay=None - ) -> Tuple[Iterator[OpenAIResponse], bool, str]: - if delay: - time.sleep(delay) - - response, b, api_key = self.request(method, url, params, headers) - self._check_polling_response(response, failed) - start_time = time.time() - while not until(response): - if time.time() - start_time > TIMEOUT_SECS: - raise openai.APITimeoutError("Operation polling timed out.") - - time.sleep(interval or response.retry_after or 10) - response, b, api_key = self.request(method, url, params, headers) - self._check_polling_response(response, failed) - - response.data = response.data["result"] - return response, b, api_key - - async def _apoll( - self, method, url, until, failed, params=None, headers=None, interval=None, delay=None - ) -> Tuple[Iterator[OpenAIResponse], bool, str]: - if delay: - await asyncio.sleep(delay) - - response, b, api_key = await self.arequest(method, url, params, headers) - self._check_polling_response(response, failed) - start_time = time.time() - while not until(response): - if time.time() - start_time > TIMEOUT_SECS: - raise openai.APITimeoutError("Operation polling timed out.") - - await asyncio.sleep(interval or response.retry_after or 10) - response, b, api_key = await self.arequest(method, url, params, headers) - self._check_polling_response(response, failed) - - response.data = response.data["result"] - return response, b, api_key - @overload def request( self, @@ -469,55 +420,6 @@ class APIRequestor: await ctx.__aexit__(None, None, None) return resp, got_stream, self.api_key - def handle_error_response(self, rbody, rcode, resp, rheaders, stream_error=False): - try: - error_data = resp["error"] - except (KeyError, TypeError): - raise openai.APIError( - "Invalid response object from API: %r (HTTP response code " "was %d)" % (rbody, rcode) - ) - - if "internal_message" in error_data: - error_data["message"] += "\n\n" + error_data["internal_message"] - - log_info( - "LLM API error received", - error_code=error_data.get("code"), - error_type=error_data.get("type"), - error_message=error_data.get("message"), - error_param=error_data.get("param"), - stream_error=stream_error, - ) - - # Rate limits were previously coded as 400's with code 'rate_limit' - if rcode == 429: - return openai.RateLimitError(f"{error_data.get('message')} {rbody} {rcode} {resp} {rheaders}", body=rbody) - elif rcode in [400, 404, 415]: - return openai.BadRequestError( - message=f'{error_data.get("message")}, {error_data.get("param")}, {error_data.get("code")} {rbody} {rcode} {resp} {rheaders}', - body=rbody, - ) - elif rcode == 401: - return openai.AuthenticationError( - f"{error_data.get('message')} {rbody} {rcode} {resp} {rheaders}", body=rbody - ) - elif rcode == 403: - return openai.PermissionDeniedError( - f"{error_data.get('message')} {rbody} {rcode} {resp} {rheaders}", body=rbody - ) - elif rcode == 409: - return openai.ConflictError(f"{error_data.get('message')} {rbody} {rcode} {resp} {rheaders}", body=rbody) - elif stream_error: - # TODO: we will soon attach status codes to stream errors - parts = [error_data.get("message"), "(Error occurred while streaming.)"] - message = " ".join([p for p in parts if p is not None]) - return openai.APIError(f"{message} {rbody} {rcode} {resp} {rheaders}", body=rbody) - else: - return openai.APIError( - f"{error_data.get('message')} {rbody} {rcode} {resp} {rheaders}", - body=rbody, - ) - def request_headers(self, method: str, extra, request_id: Optional[str]) -> Dict[str, str]: user_agent = "LLM/v1 PythonBindings/%s" % (version.VERSION,) diff --git a/metagpt/roles/role.py b/metagpt/roles/role.py index 81815e91b..356b9e33f 100644 --- a/metagpt/roles/role.py +++ b/metagpt/roles/role.py @@ -152,7 +152,7 @@ class Role(SerializationMixin, is_polymorphic_base=True): __hash__ = object.__hash__ # support Role as hashable type in `Environment.members` @model_validator(mode="after") - def check_subscription(self) -> set: + def check_subscription(self): if not self.subscription: self.subscription = {any_to_str(self), self.name} if self.name else {any_to_str(self)} return self @@ -372,16 +372,6 @@ class Role(SerializationMixin, is_polymorphic_base=True): return msg - def _find_news(self, observed: list[Message], existed: list[Message]) -> list[Message]: - news = [] - # Warning, remove `id` here to make it work for recover - observed_pure = [msg.dict(exclude={"id": True}) for msg in observed] - existed_pure = [msg.dict(exclude={"id": True}) for msg in existed] - for idx, new in enumerate(observed_pure): - if (new["cause_by"] in self.rc.watch or self.name in new["send_to"]) and new not in existed_pure: - news.append(observed[idx]) - return news - async def _observe(self, ignore_memory=False) -> int: """Prepare new messages for processing from the message buffer and other sources.""" # Read unprocessed messages from the msg buffer. @@ -407,29 +397,6 @@ class Role(SerializationMixin, is_polymorphic_base=True): logger.debug(f"{self._setting} observed: {news_text}") return len(self.rc.news) - # async def _observe(self, ignore_memory=False) -> int: - # """Prepare new messages for processing from the message buffer and other sources.""" - # # Read unprocessed messages from the msg buffer. - # news = self.rc.msg_buffer.pop_all() - # if self.recovered: - # news = [self.latest_observed_msg] if self.latest_observed_msg else [] - # else: - # self.latest_observed_msg = news[-1] if len(news) > 0 else None # record the latest observed msg - # - # # Store the read messages in your own memory to prevent duplicate processing. - # old_messages = [] if ignore_memory else self.rc.memory.get() - # self.rc.memory.add_batch(news) - # # Filter out messages of interest. - # self.rc.news = self._find_news(news, old_messages) - # - # # Design Rules: - # # If you need to further categorize Message objects, you can do so using the Message.set_meta function. - # # msg_buffer is a receiving buffer, avoid adding message data and operations to msg_buffer. - # news_text = [f"{i.role}: {i.content[:20]}..." for i in self.rc.news] - # if news_text: - # logger.debug(f"{self._setting} observed: {news_text}") - # return len(self.rc.news) - def publish_message(self, msg): """If the role belongs to env, then the role's messages will be broadcast to env""" if not msg: diff --git a/metagpt/schema.py b/metagpt/schema.py index 91158ffeb..e36bef395 100644 --- a/metagpt/schema.py +++ b/metagpt/schema.py @@ -55,7 +55,16 @@ from metagpt.utils.serialize import ( class SerializationMixin(BaseModel): - """SereDeserMixin for subclass' ser&deser""" + """ + PolyMorphic subclasses Serialization / Deserialization Mixin + - First of all, we need to know that pydantic is not designed for polymorphism. + - If Engineer is subclass of Role, it would be serialized as Role. If we want to serialize it as Engineer, we need + to add `class name` to Engineer. So we need Engineer inherit SerializationMixin. + + More details: + - https://docs.pydantic.dev/latest/concepts/serialization/ + - https://github.com/pydantic/pydantic/discussions/7008 discuss about avoid `__get_pydantic_core_schema__` + """ __is_polymorphic_base = False __subclasses_map__ = {} diff --git a/metagpt/tools/hello.py b/metagpt/tools/openapi_v3_hello.py similarity index 96% rename from metagpt/tools/hello.py rename to metagpt/tools/openapi_v3_hello.py index ec7fc9231..c8f5de42d 100644 --- a/metagpt/tools/hello.py +++ b/metagpt/tools/openapi_v3_hello.py @@ -3,7 +3,7 @@ """ @Time : 2023/5/2 16:03 @Author : mashenquan -@File : hello.py +@File : openapi_v3_hello.py @Desc : Implement the OpenAPI Specification 3.0 demo and use the following command to test the HTTP service: curl -X 'POST' \ diff --git a/metagpt/utils/common.py b/metagpt/utils/common.py index 5999b2e11..c7751c2af 100644 --- a/metagpt/utils/common.py +++ b/metagpt/utils/common.py @@ -23,7 +23,7 @@ import sys import traceback import typing from pathlib import Path -from typing import Any, Callable, List, Tuple, Union, get_args, get_origin +from typing import Any, List, Tuple, Union import aiofiles import loguru @@ -147,19 +147,7 @@ class OutputParser: if extracted_content: return extracted_content.group(1).strip() else: - return "No content found between [CONTENT] and [/CONTENT] tags." - - @staticmethod - def is_supported_list_type(i): - origin = get_origin(i) - if origin is not List: - return False - - args = get_args(i) - if args == (str,) or args == (Tuple[str, str],) or args == (List[str],): - return True - - return False + raise ValueError(f"Could not find content between [{tag}] and [/{tag}]") @classmethod def parse_data_with_mapping(cls, data, mapping): @@ -365,14 +353,14 @@ def get_class_name(cls) -> str: return f"{cls.__module__}.{cls.__name__}" -def any_to_str(val: str | Callable) -> str: +def any_to_str(val: Any) -> str: """Return the class name or the class name of the object, or 'val' if it's a string type.""" if isinstance(val, str): return val - if not callable(val): + elif not callable(val): return get_class_name(type(val)) - - return get_class_name(val) + else: + return get_class_name(val) def any_to_str_set(val) -> set: diff --git a/metagpt/utils/redis.py b/metagpt/utils/redis.py index 1ad39be59..10f33285c 100644 --- a/metagpt/utils/redis.py +++ b/metagpt/utils/redis.py @@ -5,6 +5,7 @@ @Author : mashenquan @File : redis.py """ +from __future__ import annotations import traceback from datetime import timedelta @@ -22,7 +23,7 @@ class Redis: async def _connect(self, force=False): if self._client and not force: return True - if not CONFIG.REDIS_HOST or not CONFIG.REDIS_PORT or CONFIG.REDIS_DB is None or CONFIG.REDIS_PASSWORD is None: + if not self.is_configured: return False try: @@ -37,7 +38,7 @@ class Redis: logger.warning(f"Redis initialization has failed:{e}") return False - async def get(self, key: str) -> bytes: + async def get(self, key: str) -> bytes | None: if not await self._connect() or not key: return None try: @@ -65,3 +66,14 @@ class Redis: @property def is_valid(self) -> bool: return self._client is not None + + @property + def is_configured(self) -> bool: + return bool( + CONFIG.REDIS_HOST + and CONFIG.REDIS_HOST != "YOUR_REDIS_HOST" + and CONFIG.REDIS_PORT + and CONFIG.REDIS_PORT != "YOUR_REDIS_PORT" + and CONFIG.REDIS_DB is not None + and CONFIG.REDIS_PASSWORD is not None + ) diff --git a/metagpt/utils/s3.py b/metagpt/utils/s3.py index 6a38a80a4..2a2c1a31c 100644 --- a/metagpt/utils/s3.py +++ b/metagpt/utils/s3.py @@ -154,16 +154,17 @@ class S3: @property def is_valid(self): - is_invalid = ( - not CONFIG.S3_ACCESS_KEY - or CONFIG.S3_ACCESS_KEY == "YOUR_S3_ACCESS_KEY" - or not CONFIG.S3_SECRET_KEY - or CONFIG.S3_SECRET_KEY == "YOUR_S3_SECRET_KEY" - or not CONFIG.S3_ENDPOINT_URL - or CONFIG.S3_ENDPOINT_URL == "YOUR_S3_ENDPOINT_URL" - or not CONFIG.S3_BUCKET - or CONFIG.S3_BUCKET == "YOUR_S3_BUCKET" + return self.is_configured + + @property + def is_configured(self) -> bool: + return bool( + CONFIG.S3_ACCESS_KEY + and CONFIG.S3_ACCESS_KEY != "YOUR_S3_ACCESS_KEY" + and CONFIG.S3_SECRET_KEY + and CONFIG.S3_SECRET_KEY != "YOUR_S3_SECRET_KEY" + and CONFIG.S3_ENDPOINT_URL + and CONFIG.S3_ENDPOINT_URL != "YOUR_S3_ENDPOINT_URL" + and CONFIG.S3_BUCKET + and CONFIG.S3_BUCKET != "YOUR_S3_BUCKET" ) - if is_invalid: - logger.info("S3 is invalid") - return not is_invalid diff --git a/requirements.txt b/requirements.txt index 9caea13f3..9c90034cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -53,9 +53,9 @@ gitpython==3.1.40 zhipuai==1.0.7 socksio~=1.0.0 gitignore-parser==0.1.9 -# connexion[uvicorn]~=3.0.5 # Used by metagpt/tools/hello.py +# connexion[uvicorn]~=3.0.5 # Used by metagpt/tools/openapi_v3_hello.py websockets~=12.0 networkx~=3.2.1 -google-generativeai==0.3.1 +google-generativeai==0.3.2 playwright==1.40.0 anytree diff --git a/setup.py b/setup.py index 29c44d3c1..a81be6115 100644 --- a/setup.py +++ b/setup.py @@ -39,6 +39,7 @@ extras_require["test"] = [ "pytest-mock", "pytest-html", "pytest-xdist", + "pytest-timeout", "connexion[uvicorn]~=3.0.5", "azure-cognitiveservices-speech~=1.31.0", "aioboto3~=11.3.0", diff --git a/tests/conftest.py b/tests/conftest.py index 755496dc5..63fc69272 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -94,6 +94,10 @@ class Context: @property def llm_api(self): + # 1. 初始化llm,带有缓存结果 + # 2. 如果缓存query,那么直接返回缓存结果 + # 3. 如果没有缓存query,那么调用llm_api,返回结果 + # 4. 如果有缓存query,那么更新缓存结果 return self._llm_api diff --git a/tests/data/demo_project/game.py b/tests/data/demo_project/game.py new file mode 100644 index 000000000..22e77b260 --- /dev/null +++ b/tests/data/demo_project/game.py @@ -0,0 +1,92 @@ +## game.py + +import random +from typing import List, Tuple + + +class Game: + def __init__(self): + self.grid: List[List[int]] = [[0 for _ in range(4)] for _ in range(4)] + self.score: int = 0 + self.game_over: bool = False + + def reset_game(self): + self.grid = [[0 for _ in range(4)] for _ in range(4)] + self.score = 0 + self.game_over = False + self.add_new_tile() + self.add_new_tile() + + def move(self, direction: str): + if direction == "up": + self._move_up() + elif direction == "down": + self._move_down() + elif direction == "left": + self._move_left() + elif direction == "right": + self._move_right() + + def is_game_over(self) -> bool: + for i in range(4): + for j in range(4): + if self.grid[i][j] == 0: + return False + if j < 3 and self.grid[i][j] == self.grid[i][j + 1]: + return False + if i < 3 and self.grid[i][j] == self.grid[i + 1][j]: + return False + return True + + def get_empty_cells(self) -> List[Tuple[int, int]]: + empty_cells = [] + for i in range(4): + for j in range(4): + if self.grid[i][j] == 0: + empty_cells.append((i, j)) + return empty_cells + + def add_new_tile(self): + empty_cells = self.get_empty_cells() + if empty_cells: + x, y = random.choice(empty_cells) + self.grid[x][y] = 2 if random.random() < 0.9 else 4 + + def get_score(self) -> int: + return self.score + + def _move_up(self): + for j in range(4): + for i in range(1, 4): + if self.grid[i][j] != 0: + for k in range(i, 0, -1): + if self.grid[k - 1][j] == 0: + self.grid[k - 1][j] = self.grid[k][j] + self.grid[k][j] = 0 + + def _move_down(self): + for j in range(4): + for i in range(2, -1, -1): + if self.grid[i][j] != 0: + for k in range(i, 3): + if self.grid[k + 1][j] == 0: + self.grid[k + 1][j] = self.grid[k][j] + self.grid[k][j] = 0 + + def _move_left(self): + for i in range(4): + for j in range(1, 4): + if self.grid[i][j] != 0: + for k in range(j, 0, -1): + if self.grid[i][k - 1] == 0: + self.grid[i][k - 1] = self.grid[i][k] + self.grid[i][k] = 0 + + def _move_right(self): + for i in range(4): + for j in range(2, -1, -1): + if self.grid[i][j] != 0: + for k in range(j, 3): + if self.grid[i][k + 1] == 0: + self.grid[i][k + 1] = self.grid[i][k] + self.grid[i][k] = 0 diff --git a/tests/metagpt/actions/test_action.py b/tests/metagpt/actions/test_action.py index f750b5e6f..97818ca22 100644 --- a/tests/metagpt/actions/test_action.py +++ b/tests/metagpt/actions/test_action.py @@ -5,6 +5,8 @@ @Author : alexanderwu @File : test_action.py """ +import pytest + from metagpt.actions import Action, ActionType, WritePRD, WriteTest @@ -18,3 +20,22 @@ def test_action_type(): assert ActionType.WRITE_TEST.value == WriteTest assert ActionType.WRITE_PRD.name == "WRITE_PRD" assert ActionType.WRITE_TEST.name == "WRITE_TEST" + + +def test_simple_action(): + action = Action(name="AlexSay", instruction="Express your opinion with emotion and don't repeat it") + assert action.name == "AlexSay" + assert action.node.instruction == "Express your opinion with emotion and don't repeat it" + + +def test_empty_action(): + action = Action() + assert action.name == "Action" + assert not action.node + + +@pytest.mark.asyncio +async def test_empty_action_exception(): + action = Action() + with pytest.raises(NotImplementedError): + await action.run() diff --git a/tests/metagpt/actions/test_action_node.py b/tests/metagpt/actions/test_action_node.py index 74b4df27f..384c4507b 100644 --- a/tests/metagpt/actions/test_action_node.py +++ b/tests/metagpt/actions/test_action_node.py @@ -8,6 +8,7 @@ from typing import List, Tuple import pytest +from pydantic import ValidationError from metagpt.actions import Action from metagpt.actions.action_node import ActionNode @@ -20,35 +21,35 @@ from metagpt.team import Team @pytest.mark.asyncio async def test_debate_two_roles(): - action1 = Action(name="BidenSay", instruction="Express opinions and argue vigorously, and strive to gain votes") - action2 = Action(name="TrumpSay", instruction="Express opinions and argue vigorously, and strive to gain votes") + action1 = Action(name="AlexSay", instruction="Express your opinion with emotion and don't repeat it") + action2 = Action(name="BobSay", instruction="Express your opinion with emotion and don't repeat it") biden = Role( - name="Biden", profile="Democratic candidate", goal="Win the election", actions=[action1], watch=[action2] + name="Alex", profile="Democratic candidate", goal="Win the election", actions=[action1], watch=[action2] ) trump = Role( - name="Trump", profile="Republican candidate", goal="Win the election", actions=[action2], watch=[action1] + name="Bob", profile="Republican candidate", goal="Win the election", actions=[action2], watch=[action1] ) env = Environment(desc="US election live broadcast") team = Team(investment=10.0, env=env, roles=[biden, trump]) - history = await team.run(idea="Topic: climate change. Under 80 words per message.", send_to="Biden", n_round=3) - assert "Biden" in history + history = await team.run(idea="Topic: climate change. Under 80 words per message.", send_to="Alex", n_round=3) + assert "Alex" in history @pytest.mark.asyncio async def test_debate_one_role_in_env(): - action = Action(name="Debate", instruction="Express opinions and argue vigorously, and strive to gain votes") - biden = Role(name="Biden", profile="Democratic candidate", goal="Win the election", actions=[action]) + action = Action(name="Debate", instruction="Express your opinion with emotion and don't repeat it") + biden = Role(name="Alex", profile="Democratic candidate", goal="Win the election", actions=[action]) env = Environment(desc="US election live broadcast") team = Team(investment=10.0, env=env, roles=[biden]) - history = await team.run(idea="Topic: climate change. Under 80 words per message.", send_to="Biden", n_round=3) - assert "Biden" in history + history = await team.run(idea="Topic: climate change. Under 80 words per message.", send_to="Alex", n_round=3) + assert "Alex" in history @pytest.mark.asyncio async def test_debate_one_role(): - action = Action(name="Debate", instruction="Express opinions and argue vigorously, and strive to gain votes") - biden = Role(name="Biden", profile="Democratic candidate", goal="Win the election", actions=[action]) + action = Action(name="Debate", instruction="Express your opinion with emotion and don't repeat it") + biden = Role(name="Alex", profile="Democratic candidate", goal="Win the election", actions=[action]) msg: Message = await biden.run("Topic: climate change. Under 80 words per message.") assert len(msg.content) > 10 @@ -113,6 +114,10 @@ t_dict = { "Anything UNCLEAR": "We need clarification on how the high score should be stored. Should it persist across sessions (stored in a database or a file) or should it reset every time the game is restarted? Also, should the game speed increase as the snake grows, or should it remain constant throughout the game?", } +t_dict_min = { + "Required Python third-party packages": '"""\nflask==1.1.2\npygame==2.0.1\n"""\n', +} + WRITE_TASKS_OUTPUT_MAPPING = { "Required Python third-party packages": (str, ...), "Required Other language third-party packages": (str, ...), @@ -139,11 +144,19 @@ def test_create_model_class(): assert output.schema()["properties"]["Full API spec"] -def test_create_model_class_missing(): +def test_create_model_class_with_fields_unrecognized(): test_class = ActionNode.create_model_class("test_class", WRITE_TASKS_OUTPUT_MAPPING_MISSING) assert test_class.__name__ == "test_class" - _ = test_class(**t_dict) # 这里应该要挂掉 + _ = test_class(**t_dict) # just warning + + +def test_create_model_class_with_fields_missing(): + test_class = ActionNode.create_model_class("test_class", WRITE_TASKS_OUTPUT_MAPPING) + assert test_class.__name__ == "test_class" + + with pytest.raises(ValidationError): + _ = test_class(**t_dict_min) def test_create_model_class_with_mapping(): diff --git a/tests/metagpt/document_store/test_faiss_store.py b/tests/metagpt/document_store/test_faiss_store.py index 75bb5427f..7e2979bd4 100644 --- a/tests/metagpt/document_store/test_faiss_store.py +++ b/tests/metagpt/document_store/test_faiss_store.py @@ -30,3 +30,11 @@ async def test_search_xlsx(): query = "Which facial cleanser is good for oily skin?" result = await role.run(query) logger.info(result) + + +@pytest.mark.asyncio +async def test_write(): + store = FaissStore(EXAMPLE_PATH / "example.xlsx", meta_col="Answer", content_col="Question") + _faiss_store = store.write() + assert _faiss_store.docstore + assert _faiss_store.index diff --git a/tests/metagpt/learn/test_text_to_image.py b/tests/metagpt/learn/test_text_to_image.py index 0afe8534d..760b9d09c 100644 --- a/tests/metagpt/learn/test_text_to_image.py +++ b/tests/metagpt/learn/test_text_to_image.py @@ -15,20 +15,24 @@ from metagpt.learn.text_to_image import text_to_image @pytest.mark.asyncio -async def test(): +async def test_metagpt_llm(): # Prerequisites assert CONFIG.METAGPT_TEXT_TO_IMAGE_MODEL_URL assert CONFIG.OPENAI_API_KEY data = await text_to_image("Panda emoji", size_type="512x512") assert "base64" in data or "http" in data - key = CONFIG.METAGPT_TEXT_TO_IMAGE_MODEL_URL - CONFIG.METAGPT_TEXT_TO_IMAGE_MODEL_URL = None + + # Mock session env + old_options = CONFIG.options.copy() + new_options = old_options.copy() + new_options["METAGPT_TEXT_TO_IMAGE_MODEL_URL"] = None + CONFIG.set_context(new_options) try: data = await text_to_image("Panda emoji", size_type="512x512") assert "base64" in data or "http" in data finally: - CONFIG.METAGPT_TEXT_TO_IMAGE_MODEL_URL = key + CONFIG.set_context(old_options) if __name__ == "__main__": diff --git a/tests/metagpt/learn/test_text_to_speech.py b/tests/metagpt/learn/test_text_to_speech.py index 02faecdde..aca08b9a2 100644 --- a/tests/metagpt/learn/test_text_to_speech.py +++ b/tests/metagpt/learn/test_text_to_speech.py @@ -27,13 +27,16 @@ async def test_text_to_speech(): assert "base64" in data or "http" in data # test iflytek - key = CONFIG.AZURE_TTS_SUBSCRIPTION_KEY - CONFIG.AZURE_TTS_SUBSCRIPTION_KEY = "" + ## Mock session env + old_options = CONFIG.options.copy() + new_options = old_options.copy() + new_options["AZURE_TTS_SUBSCRIPTION_KEY"] = "" + CONFIG.set_context(new_options) try: data = await text_to_speech("panda emoji") assert "base64" in data or "http" in data finally: - CONFIG.AZURE_TTS_SUBSCRIPTION_KEY = key + CONFIG.set_context(old_options) if __name__ == "__main__": diff --git a/tests/metagpt/provider/test_general_api_base.py b/tests/metagpt/provider/test_general_api_base.py index ae768ce95..b8ab619f7 100644 --- a/tests/metagpt/provider/test_general_api_base.py +++ b/tests/metagpt/provider/test_general_api_base.py @@ -14,11 +14,14 @@ from metagpt.provider.general_api_base import ( APIRequestor, ApiType, OpenAIResponse, + _aiohttp_proxies_arg, + _build_api_url, _make_session, _requests_proxies_arg, log_debug, log_info, log_warn, + logfmt, parse_stream, parse_stream_helper, ) @@ -36,6 +39,10 @@ def test_basic(): log_warn("warn") log_info("info") + logfmt({"k1": b"v1", "k2": 1, "k3": "a b"}) + + _build_api_url(url="http://www.baidu.com/s?wd=", query="baidu") + def test_openai_response(): resp = OpenAIResponse(data=[], headers={"retry-after": 3}) @@ -53,11 +60,18 @@ def test_proxy(): assert _requests_proxies_arg(proxy=proxy) == {"http": proxy, "https": proxy} proxy_dict = {"http": proxy} assert _requests_proxies_arg(proxy=proxy_dict) == proxy_dict + assert _aiohttp_proxies_arg(proxy_dict) == proxy proxy_dict = {"https": proxy} assert _requests_proxies_arg(proxy=proxy_dict) == proxy_dict + assert _aiohttp_proxies_arg(proxy_dict) == proxy assert _make_session() is not None + assert _aiohttp_proxies_arg(None) is None + assert _aiohttp_proxies_arg("test") == "test" + with pytest.raises(ValueError): + _aiohttp_proxies_arg(-1) + def test_parse_stream(): assert parse_stream_helper(None) is None @@ -83,6 +97,29 @@ async def mock_interpret_async_response( return b"baidu", True +def test_requestor_headers(): + # validate_headers + headers = api_requestor._validate_headers(None) + assert not headers + with pytest.raises(Exception): + api_requestor._validate_headers(-1) + with pytest.raises(Exception): + api_requestor._validate_headers({1: 2}) + with pytest.raises(Exception): + api_requestor._validate_headers({"test": 1}) + supplied_headers = {"test": "test"} + assert api_requestor._validate_headers(supplied_headers) == supplied_headers + + api_requestor.organization = "test" + api_requestor.api_version = "test123" + api_requestor.api_type = ApiType.OPEN_AI + request_id = "test123" + headers = api_requestor.request_headers(method="post", extra={}, request_id=request_id) + assert headers["LLM-Organization"] == api_requestor.organization + assert headers["LLM-Version"] == api_requestor.api_version + assert headers["X-Request-Id"] == request_id + + def test_api_requestor(mocker): mocker.patch("metagpt.provider.general_api_base.APIRequestor._interpret_response", mock_interpret_response) resp, _, _ = api_requestor.request(method="get", url="/s?wd=baidu") diff --git a/tests/metagpt/provider/test_human_provider.py b/tests/metagpt/provider/test_human_provider.py index 8ba532781..3f63410c0 100644 --- a/tests/metagpt/provider/test_human_provider.py +++ b/tests/metagpt/provider/test_human_provider.py @@ -7,23 +7,25 @@ import pytest from metagpt.provider.human_provider import HumanProvider resp_content = "test" - - -def mock_llm_ask(msg: str, timeout: int = 3) -> str: - return resp_content - - -async def mock_llm_aask(msg: str, timeout: int = 3) -> str: - return mock_llm_ask(msg) +resp_exit = "exit" @pytest.mark.asyncio async def test_async_human_provider(mocker): - mocker.patch("metagpt.provider.human_provider.HumanProvider.aask", mock_llm_aask) + mocker.patch("builtins.input", lambda _: resp_content) human_provider = HumanProvider() + resp = human_provider.ask(resp_content) + assert resp == resp_content resp = await human_provider.aask(None) assert resp_content == resp + mocker.patch("builtins.input", lambda _: resp_exit) + with pytest.raises(SystemExit): + human_provider.ask(resp_exit) + resp = await human_provider.acompletion([]) assert not resp + + resp = await human_provider.acompletion_text([]) + assert resp == "" diff --git a/tests/metagpt/provider/test_spark_api.py b/tests/metagpt/provider/test_spark_api.py index 6d5a0e1f6..ee2d02c97 100644 --- a/tests/metagpt/provider/test_spark_api.py +++ b/tests/metagpt/provider/test_spark_api.py @@ -17,10 +17,23 @@ prompt_msg = "who are you" resp_content = "I'm Spark" -def test_get_msg_from_web(): +class MockWebSocketApp(object): + def __init__(self, ws_url, on_message=None, on_error=None, on_close=None, on_open=None): + pass + + def run_forever(self, sslopt=None): + pass + + +def test_get_msg_from_web(mocker): + mocker.patch("websocket.WebSocketApp", MockWebSocketApp) + get_msg_from_web = GetMessageFromWeb(text=prompt_msg) assert get_msg_from_web.gen_params()["parameter"]["chat"]["domain"] == "xxxxxx" + ret = get_msg_from_web.run() + assert ret == "" + def mock_spark_get_msg_from_web_run(self) -> str: return resp_content @@ -29,6 +42,7 @@ def mock_spark_get_msg_from_web_run(self) -> str: @pytest.mark.asyncio async def test_spark_acompletion(mocker): mocker.patch("metagpt.provider.spark_api.GetMessageFromWeb.run", mock_spark_get_msg_from_web_run) + spark_gpt = SparkLLM() resp = await spark_gpt.acompletion([]) diff --git a/tests/metagpt/provider/zhipuai/test_async_sse_client.py b/tests/metagpt/provider/zhipuai/test_async_sse_client.py index 9e5bd5f2e..2649f595b 100644 --- a/tests/metagpt/provider/zhipuai/test_async_sse_client.py +++ b/tests/metagpt/provider/zhipuai/test_async_sse_client.py @@ -16,3 +16,11 @@ async def test_async_sse_client(): async_sse_client = AsyncSSEClient(event_source=Iterator()) async for event in async_sse_client.async_events(): assert event.data, "test_value" + + class InvalidIterator(object): + async def __aiter__(self): + yield b"invalid: test_value" + + async_sse_client = AsyncSSEClient(event_source=InvalidIterator()) + async for event in async_sse_client.async_events(): + assert not event diff --git a/tests/metagpt/provider/zhipuai/test_zhipu_model_api.py b/tests/metagpt/provider/zhipuai/test_zhipu_model_api.py index 83ae2de60..1f0a42fa6 100644 --- a/tests/metagpt/provider/zhipuai/test_zhipu_model_api.py +++ b/tests/metagpt/provider/zhipuai/test_zhipu_model_api.py @@ -14,7 +14,7 @@ from metagpt.provider.zhipuai.zhipu_model_api import ZhiPuModelAPI api_key = "xxx.xxx" zhipuai.api_key = api_key -default_resp = {"result": "test response"} +default_resp = b'{"result": "test response"}' async def mock_requestor_arequest(self, **kwargs) -> Tuple[Any, Any, str]: @@ -39,3 +39,6 @@ async def test_zhipu_model_api(mocker): InvokeType.SYNC, stream=False, method="get", headers={}, kwargs={"model": "chatglm_turbo"} ) assert result == default_resp + + result = await ZhiPuModelAPI.ainvoke() + assert result["result"] == "test response" diff --git a/tests/metagpt/roles/test_architect.py b/tests/metagpt/roles/test_architect.py index 669a38556..2f45fef84 100644 --- a/tests/metagpt/roles/test_architect.py +++ b/tests/metagpt/roles/test_architect.py @@ -7,19 +7,39 @@ @Modified By: mashenquan, 2023-11-1. In accordance with Chapter 2.2.1 and 2.2.2 of RFC 116, utilize the new message distribution feature for message handling. """ +import uuid + import pytest +from metagpt.actions import WriteDesign, WritePRD +from metagpt.config import CONFIG +from metagpt.const import PRDS_FILE_REPO from metagpt.logs import logger from metagpt.roles import Architect +from metagpt.schema import Message +from metagpt.utils.common import any_to_str, awrite from tests.metagpt.roles.mock import MockMessages @pytest.mark.asyncio @pytest.mark.usefixtures("llm_mock") async def test_architect(): - # FIXME: make git as env? Or should we support + # Prerequisites + filename = uuid.uuid4().hex + ".json" + await awrite(CONFIG.git_repo.workdir / PRDS_FILE_REPO / filename, data=MockMessages.prd.content) + role = Architect() - role.put_message(MockMessages.req) - rsp = await role.run(MockMessages.prd) + rsp = await role.run(with_message=Message(content="", cause_by=WritePRD)) logger.info(rsp) assert len(rsp.content) > 0 + assert rsp.cause_by == any_to_str(WriteDesign) + + # test update + rsp = await role.run(with_message=Message(content="", cause_by=WritePRD)) + assert rsp + assert rsp.cause_by == any_to_str(WriteDesign) + assert len(rsp.content) > 0 + + +if __name__ == "__main__": + pytest.main([__file__, "-s"]) diff --git a/tests/metagpt/roles/test_qa_engineer.py b/tests/metagpt/roles/test_qa_engineer.py index 8fd7c0373..784c26a06 100644 --- a/tests/metagpt/roles/test_qa_engineer.py +++ b/tests/metagpt/roles/test_qa_engineer.py @@ -5,3 +5,59 @@ @Author : alexanderwu @File : test_qa_engineer.py """ +from pathlib import Path +from typing import List + +import pytest +from pydantic import Field + +from metagpt.actions import DebugError, RunCode, WriteTest +from metagpt.actions.summarize_code import SummarizeCode +from metagpt.config import CONFIG +from metagpt.environment import Environment +from metagpt.roles import QaEngineer +from metagpt.schema import Message +from metagpt.utils.common import any_to_str, aread, awrite + + +async def test_qa(): + # Prerequisites + demo_path = Path(__file__).parent / "../../data/demo_project" + CONFIG.src_workspace = Path(CONFIG.git_repo.workdir) / "qa/game_2048" + data = await aread(filename=demo_path / "game.py", encoding="utf-8") + await awrite(filename=CONFIG.src_workspace / "game.py", data=data, encoding="utf-8") + await awrite(filename=Path(CONFIG.git_repo.workdir) / "requirements.txt", data="") + + class MockEnv(Environment): + msgs: List[Message] = Field(default_factory=list) + + def publish_message(self, message: Message, peekable: bool = True) -> bool: + self.msgs.append(message) + return True + + env = MockEnv() + + role = QaEngineer() + role.set_env(env) + await role.run(with_message=Message(content="", cause_by=SummarizeCode)) + assert env.msgs + assert env.msgs[0].cause_by == any_to_str(WriteTest) + msg = env.msgs[0] + env.msgs.clear() + await role.run(with_message=msg) + assert env.msgs + assert env.msgs[0].cause_by == any_to_str(RunCode) + msg = env.msgs[0] + env.msgs.clear() + await role.run(with_message=msg) + assert env.msgs + assert env.msgs[0].cause_by == any_to_str(DebugError) + msg = env.msgs[0] + env.msgs.clear() + role.test_round_allowed = 1 + rsp = await role.run(with_message=msg) + assert "Exceeding" in rsp.content + + +if __name__ == "__main__": + pytest.main([__file__, "-s"]) diff --git a/tests/metagpt/serialize_deserialize/test_action.py b/tests/metagpt/serialize_deserialize/test_action.py index 245b2f252..571fd52ac 100644 --- a/tests/metagpt/serialize_deserialize/test_action.py +++ b/tests/metagpt/serialize_deserialize/test_action.py @@ -28,6 +28,6 @@ async def test_action_deserialize(): new_action = Action(**serialized_data) - assert new_action.name == "" + assert new_action.name == "Action" assert isinstance(new_action.llm, type(LLM())) assert len(await new_action._aask("who are you")) > 0 diff --git a/tests/metagpt/serialize_deserialize/test_write_design.py b/tests/metagpt/serialize_deserialize/test_write_design.py index 283d07be8..72cbdc8a8 100644 --- a/tests/metagpt/serialize_deserialize/test_write_design.py +++ b/tests/metagpt/serialize_deserialize/test_write_design.py @@ -27,7 +27,7 @@ async def test_write_design_deserialize(): action = WriteDesign() serialized_data = action.model_dump() new_action = WriteDesign(**serialized_data) - assert new_action.name == "" + assert new_action.name == "WriteDesign" await new_action.run(with_messages="write a cli snake game") @@ -37,5 +37,5 @@ async def test_write_task_deserialize(): action = WriteTasks() serialized_data = action.model_dump() new_action = WriteTasks(**serialized_data) - assert new_action.name == "CreateTasks" + assert new_action.name == "WriteTasks" await new_action.run(with_messages="write a cli snake game") diff --git a/tests/metagpt/serialize_deserialize/test_write_docstring.py b/tests/metagpt/serialize_deserialize/test_write_docstring.py index 25a36991c..76b602d42 100644 --- a/tests/metagpt/serialize_deserialize/test_write_docstring.py +++ b/tests/metagpt/serialize_deserialize/test_write_docstring.py @@ -39,7 +39,7 @@ async def test_action_deserialize(style: str, part: str): new_action = WriteDocstring(**serialized_data) - assert not new_action.name + assert new_action.name == "WriteDocstring" assert new_action.desc == "Write docstring for code." ret = await new_action.run(code, style=style) assert part in ret diff --git a/tests/metagpt/test_document.py b/tests/metagpt/test_document.py new file mode 100644 index 000000000..18650e112 --- /dev/null +++ b/tests/metagpt/test_document.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2024/1/2 21:00 +@Author : alexanderwu +@File : test_document.py +""" +from metagpt.config import CONFIG +from metagpt.document import Repo +from metagpt.logs import logger + + +def set_existing_repo(path): + repo1 = Repo.from_path(path) + repo1.set("doc/wtf_file.md", "wtf content") + repo1.set("code/wtf_file.py", "def hello():\n print('hello')") + logger.info(repo1) # check doc + + +def load_existing_repo(path): + repo = Repo.from_path(path) + logger.info(repo) + logger.info(repo.eda()) + + assert repo + assert repo.get("doc/wtf_file.md").content == "wtf content" + assert repo.get("code/wtf_file.py").content == "def hello():\n print('hello')" + + +def test_repo_set_load(): + repo_path = CONFIG.workspace_path / "test_repo" + set_existing_repo(repo_path) + load_existing_repo(repo_path) diff --git a/tests/metagpt/test_role.py b/tests/metagpt/test_role.py index 33320715c..52d08e92e 100644 --- a/tests/metagpt/test_role.py +++ b/tests/metagpt/test_role.py @@ -10,15 +10,17 @@ functionality is to be consolidated into the `Environment` class. """ import uuid +from unittest.mock import MagicMock import pytest from pydantic import BaseModel from metagpt.actions import Action, ActionOutput, UserRequirement from metagpt.environment import Environment +from metagpt.provider.base_llm import BaseLLM from metagpt.roles import Role from metagpt.schema import Message -from metagpt.utils.common import any_to_str +from metagpt.utils.common import any_to_name, any_to_str class MockAction(Action): @@ -96,7 +98,7 @@ async def test_react(): @pytest.mark.asyncio -async def test_msg_to(): +async def test_send_to(): m = Message(content="a", send_to=["a", MockRole, Message]) assert m.send_to == {"a", any_to_str(MockRole), any_to_str(Message)} @@ -107,5 +109,50 @@ async def test_msg_to(): assert m.send_to == {"a", any_to_str(MockRole), any_to_str(Message)} +def test_init_action(): + role = Role() + role.init_actions([MockAction, MockAction]) + assert role.action_count == 2 + + +@pytest.mark.asyncio +async def test_recover(): + # Mock LLM actions + mock_llm = MagicMock(spec=BaseLLM) + mock_llm.aask.side_effect = ["1"] + + role = Role() + assert role.is_watch(any_to_str(UserRequirement)) + role.put_message(None) + role.publish_message(None) + + role.llm = mock_llm + role.init_actions([MockAction, MockAction]) + role.recovered = True + role.latest_observed_msg = Message(content="recover_test") + role.rc.state = 0 + assert role.todo == any_to_name(MockAction) + + rsp = await role.run() + assert rsp.cause_by == any_to_str(MockAction) + + +@pytest.mark.asyncio +async def test_think_act(): + # Mock LLM actions + mock_llm = MagicMock(spec=BaseLLM) + mock_llm.aask.side_effect = ["ok"] + + role = Role() + role.init_actions([MockAction]) + await role.think() + role.rc.memory.add(Message("run")) + assert len(role.get_memories()) == 1 + rsp = await role.act() + assert rsp + assert isinstance(rsp, ActionOutput) + assert rsp.content == "run" + + if __name__ == "__main__": pytest.main([__file__, "-s"]) diff --git a/tests/metagpt/test_schema.py b/tests/metagpt/test_schema.py index 1bf0d4c4c..816c186e2 100644 --- a/tests/metagpt/test_schema.py +++ b/tests/metagpt/test_schema.py @@ -16,8 +16,10 @@ from metagpt.actions import Action from metagpt.actions.action_node import ActionNode from metagpt.actions.write_code import WriteCode from metagpt.config import CONFIG +from metagpt.const import SYSTEM_DESIGN_FILE_REPO, TASK_FILE_REPO from metagpt.schema import ( AIMessage, + CodeSummarizeContext, Document, Message, MessageQueue, @@ -61,6 +63,8 @@ def test_message(): assert m.role == "b" assert m.send_to == {"c"} assert m.cause_by == "c" + m.sent_from = "e" + assert m.sent_from == "e" m.cause_by = "Message" assert m.cause_by == "Message" @@ -121,6 +125,8 @@ def test_document(): @pytest.mark.asyncio async def test_message_queue(): mq = MessageQueue() + val = await mq.dump() + assert val == "[]" mq.push(Message(content="1")) mq.push(Message(content="2中文测试aaa")) msg = mq.pop() @@ -132,5 +138,23 @@ async def test_message_queue(): assert new_mq.pop_all() == mq.pop_all() +@pytest.mark.parametrize( + ("file_list", "want"), + [ + ( + [f"{SYSTEM_DESIGN_FILE_REPO}/a.txt", f"{TASK_FILE_REPO}/b.txt"], + CodeSummarizeContext( + design_filename=f"{SYSTEM_DESIGN_FILE_REPO}/a.txt", task_filename=f"{TASK_FILE_REPO}/b.txt" + ), + ) + ], +) +def test_CodeSummarizeContext(file_list, want): + ctx = CodeSummarizeContext.loads(file_list) + assert ctx == want + m = {ctx: ctx} + assert want in m + + if __name__ == "__main__": pytest.main([__file__, "-s"]) diff --git a/tests/metagpt/tools/test_hello.py b/tests/metagpt/tools/test_hello.py index 243206991..7e61532ab 100644 --- a/tests/metagpt/tools/test_hello.py +++ b/tests/metagpt/tools/test_hello.py @@ -18,7 +18,7 @@ from metagpt.config import CONFIG @pytest.mark.asyncio async def test_hello(): workdir = Path(__file__).parent.parent.parent.parent - script_pathname = workdir / "metagpt/tools/hello.py" + script_pathname = workdir / "metagpt/tools/openapi_v3_hello.py" env = CONFIG.new_environ() env["PYTHONPATH"] = str(workdir) + ":" + env.get("PYTHONPATH", "") process = subprocess.Popen(["python", str(script_pathname)], cwd=workdir, env=env) diff --git a/tests/metagpt/utils/test_common.py b/tests/metagpt/utils/test_common.py index 3a0ec18fc..0342a92af 100644 --- a/tests/metagpt/utils/test_common.py +++ b/tests/metagpt/utils/test_common.py @@ -91,6 +91,10 @@ class TestGetProjectRoot: x=(TutorialAssistant, RunCode(), "a"), want={"metagpt.roles.tutorial_assistant.TutorialAssistant", "metagpt.actions.run_code.RunCode", "a"}, ), + Input( + x={"a": TutorialAssistant, "b": RunCode(), "c": "a"}, + want={"a", "metagpt.roles.tutorial_assistant.TutorialAssistant", "metagpt.actions.run_code.RunCode"}, + ), ] for i in inputs: v = any_to_str_set(i.x) diff --git a/tests/metagpt/utils/test_output_parser.py b/tests/metagpt/utils/test_output_parser.py index afacc28ea..f7717e360 100644 --- a/tests/metagpt/utils/test_output_parser.py +++ b/tests/metagpt/utils/test_output_parser.py @@ -119,95 +119,7 @@ def test_extract_struct( case() -if __name__ == "__main__": - t_text = ''' -## Required Python third-party packages -```python -""" -flask==1.1.2 -pygame==2.0.1 -""" -``` - -## Required Other language third-party packages -```python -""" -No third-party packages required for other languages. -""" -``` - -## Full API spec -```python -""" -openapi: 3.0.0 -info: - title: Web Snake Game API - version: 1.0.0 -paths: - /game: - get: - summary: Get the current game state - responses: - '200': - description: A JSON object of the game state - post: - summary: Send a command to the game - requestBody: - required: true - content: - application/json: - schema: - type: object - properties: - command: - type: string - responses: - '200': - description: A JSON object of the updated game state -""" -``` - -## Logic Analysis -```python -[ - ("app.py", "Main entry point for the Flask application. Handles HTTP requests and responses."), - ("game.py", "Contains the Game and Snake classes. Handles the game logic."), - ("static/js/script.js", "Handles user interactions and updates the game UI."), - ("static/css/styles.css", "Defines the styles for the game UI."), - ("templates/index.html", "The main page of the web application. Displays the game UI.") -] -``` - -## Task list -```python -[ - "game.py", - "app.py", - "static/css/styles.css", - "static/js/script.js", - "templates/index.html" -] -``` - -## Shared Knowledge -```python -""" -'game.py' contains the Game and Snake classes which are responsible for the game logic. The Game class uses an instance of the Snake class. - -'app.py' is the main entry point for the Flask application. It creates an instance of the Game class and handles HTTP requests and responses. - -'static/js/script.js' is responsible for handling user interactions and updating the game UI based on the game state returned by 'app.py'. - -'static/css/styles.css' defines the styles for the game UI. - -'templates/index.html' is the main page of the web application. It displays the game UI and loads 'static/js/script.js' and 'static/css/styles.css'. -""" -``` - -## Anything UNCLEAR -We need clarification on how the high score should be stored. Should it persist across sessions (stored in a database or a file) or should it reset every time the game is restarted? Also, should the game speed increase as the snake grows, or should it remain constant throughout the game? - ''' - +def test_parse_with_markdown_mapping(): OUTPUT_MAPPING = { "Original Requirements": (str, ...), "Product Goals": (List[str], ...), @@ -218,7 +130,7 @@ We need clarification on how the high score should be stored. Should it persist "Requirement Pool": (List[Tuple[str, str]], ...), "Anything UNCLEAR": (str, ...), } - t_text1 = """## Original Requirements: + t_text_with_content_tag = """[CONTENT]## Original Requirements: The user wants to create a web-based version of the game "Fly Bird". @@ -286,8 +198,11 @@ The product should be a web-based version of the game "Fly Bird" that is engagin ## Anything UNCLEAR: There are no unclear points. - """ - d = OutputParser.parse_data_with_mapping(t_text1, OUTPUT_MAPPING) +[/CONTENT]""" + t_text_raw = t_text_with_content_tag.replace("[CONTENT]", "").replace("[/CONTENT]", "") + d = OutputParser.parse_data_with_mapping(t_text_with_content_tag, OUTPUT_MAPPING) + import json print(json.dumps(d)) + assert d["Original Requirements"] == t_text_raw.split("## Original Requirements:")[1].split("##")[0].strip() diff --git a/tests/metagpt/utils/test_redis.py b/tests/metagpt/utils/test_redis.py index 7c3fd26a9..b93ff0cdb 100644 --- a/tests/metagpt/utils/test_redis.py +++ b/tests/metagpt/utils/test_redis.py @@ -27,6 +27,19 @@ async def test_redis(): assert await conn.get("test") == b"test" await conn.close() + # Mock session env + old_options = CONFIG.options.copy() + new_options = old_options.copy() + new_options["REDIS_HOST"] = "YOUR_REDIS_HOST" + CONFIG.set_context(new_options) + try: + conn = Redis() + await conn.set("test", "test", timeout_sec=0) + assert not await conn.get("test") == b"test" + await conn.close() + finally: + CONFIG.set_context(old_options) + if __name__ == "__main__": pytest.main([__file__, "-s"]) diff --git a/tests/metagpt/utils/test_s3.py b/tests/metagpt/utils/test_s3.py index edf198028..f74e7b52a 100644 --- a/tests/metagpt/utils/test_s3.py +++ b/tests/metagpt/utils/test_s3.py @@ -41,17 +41,18 @@ async def test_s3(): res = await conn.cache(data, ".bak", "script") assert "http" in res - -@pytest.mark.asyncio -async def test_s3_no_error(): - conn = S3() - key = conn.auth_config["aws_secret_access_key"] - conn.auth_config["aws_secret_access_key"] = "" + # Mock session env + old_options = CONFIG.options.copy() + new_options = old_options.copy() + new_options["S3_ACCESS_KEY"] = "YOUR_S3_ACCESS_KEY" + CONFIG.set_context(new_options) try: + conn = S3() + assert not conn.is_valid res = await conn.cache("ABC", ".bak", "script") assert not res finally: - conn.auth_config["aws_secret_access_key"] = key + CONFIG.set_context(old_options) if __name__ == "__main__":