diff --git a/.gitignore b/.gitignore index cec4b10e4..350df1514 100644 --- a/.gitignore +++ b/.gitignore @@ -172,3 +172,5 @@ tests/metagpt/utils/file_repo_git htmlcov htmlcov.* *.dot +*.pkl + 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/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/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 f74c32fea..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 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 71faff834..b5bb41f26 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/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/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/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/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/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()