From 37f2c825f70f8ff609ed6d8141e5c3e28b8c0b66 Mon Sep 17 00:00:00 2001 From: Ikko Eltociear Ashimine Date: Fri, 22 Dec 2023 00:07:06 +0900 Subject: [PATCH 01/10] Update README.md exisiting -> existing --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index a03c1eabf..dcc56caf8 100644 --- a/README.md +++ b/README.md @@ -34,7 +34,7 @@ # MetaGPT: The Multi-Agent Framework

Software Company Multi-Role Schematic (Gradually Implementing)

## News -- Dec 15: [v0.5.0](https://github.com/geekan/MetaGPT/releases/tag/v0.5.0) is released! We introduce **incremental development**, facilitating agents to build up larger projects on top of their previous efforts or exisiting codebase. We also launch a whole collection of important features, including **multilingual support** (experimental), multiple **programming languages support** (experimental), **incremental development** (experimental), CLI support, pip support, enhanced code review, documentation mechanism, and optimized messaging mechanism! +- Dec 15: [v0.5.0](https://github.com/geekan/MetaGPT/releases/tag/v0.5.0) is released! We introduce **incremental development**, facilitating agents to build up larger projects on top of their previous efforts or existing codebase. We also launch a whole collection of important features, including **multilingual support** (experimental), multiple **programming languages support** (experimental), **incremental development** (experimental), CLI support, pip support, enhanced code review, documentation mechanism, and optimized messaging mechanism! ## Install From 4b0cb0084a438afab8f9a4c213700d12c838d65e Mon Sep 17 00:00:00 2001 From: better629 Date: Fri, 22 Dec 2023 02:20:43 +0800 Subject: [PATCH 02/10] add ollama support --- config/config.yaml | 4 + metagpt/config.py | 6 +- metagpt/const.py | 2 + metagpt/provider/__init__.py | 3 +- metagpt/provider/general_api_base.py | 65 ++++---- metagpt/provider/general_api_requestor.py | 52 +++++- metagpt/provider/ollama_api.py | 151 ++++++++++++++++++ metagpt/utils/repair_llm_raw_output.py | 2 + .../provider/test_google_gemini_api.py | 2 +- tests/metagpt/provider/test_ollama_api.py | 33 ++++ 10 files changed, 284 insertions(+), 36 deletions(-) create mode 100644 metagpt/provider/ollama_api.py create mode 100644 tests/metagpt/provider/test_ollama_api.py diff --git a/config/config.yaml b/config/config.yaml index e724897ee..a9c764c56 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -48,6 +48,10 @@ RPM: 10 #FIREWORKS_API_BASE: "https://api.fireworks.ai/inference/v1" #FIREWORKS_API_MODEL: "YOUR_LLM_MODEL" # example, accounts/fireworks/models/llama-v2-13b-chat +#### if use self-host open llm model by ollama +# OLLAMA_API_BASE: http://127.0.0.1:11434/api +# OLLAMA_API_MODEL: llama2 + #### for Search ## Supported values: serpapi/google/serper/ddg diff --git a/metagpt/config.py b/metagpt/config.py index 5176a7677..208b4fd7b 100644 --- a/metagpt/config.py +++ b/metagpt/config.py @@ -42,6 +42,7 @@ class LLMProviderEnum(Enum): FIREWORKS = "fireworks" OPEN_LLM = "open_llm" GEMINI = "gemini" + OLLAMA = "ollama" class Config(metaclass=Singleton): @@ -78,7 +79,8 @@ class Config(metaclass=Singleton): (self.zhipuai_api_key, LLMProviderEnum.ZHIPUAI), (self.fireworks_api_key, LLMProviderEnum.FIREWORKS), (self.open_llm_api_base, LLMProviderEnum.OPEN_LLM), - (self.gemini_api_key, LLMProviderEnum.GEMINI), # reuse logic. but not a key + (self.gemini_api_key, LLMProviderEnum.GEMINI), + (self.ollama_api_base, LLMProviderEnum.OLLAMA), # reuse logic. but not a key ]: if self._is_valid_llm_key(k): # logger.debug(f"Use LLMProvider: {v.value}") @@ -103,6 +105,8 @@ class Config(metaclass=Singleton): self.open_llm_api_model = self._get("OPEN_LLM_API_MODEL") self.fireworks_api_key = self._get("FIREWORKS_API_KEY") self.gemini_api_key = self._get("GEMINI_API_KEY") + self.ollama_api_base = self._get("OLLAMA_API_BASE") + self.ollama_api_model = self._get("OLLAMA_API_MODEL") _ = self.get_default_llm_provider_enum() self.openai_base_url = self._get("OPENAI_BASE_URL") diff --git a/metagpt/const.py b/metagpt/const.py index 3b4f2ae4b..1819bbb49 100644 --- a/metagpt/const.py +++ b/metagpt/const.py @@ -102,3 +102,5 @@ CODE_SUMMARIES_FILE_REPO = "docs/code_summaries" CODE_SUMMARIES_PDF_FILE_REPO = "resources/code_summaries" YAPI_URL = "http://yapi.deepwisdomai.com/" + +LLM_API_TIMEOUT = 300 diff --git a/metagpt/provider/__init__.py b/metagpt/provider/__init__.py index a9f46eb03..42626a551 100644 --- a/metagpt/provider/__init__.py +++ b/metagpt/provider/__init__.py @@ -8,8 +8,9 @@ from metagpt.provider.fireworks_api import FireWorksGPTAPI from metagpt.provider.google_gemini_api import GeminiGPTAPI +from metagpt.provider.ollama_api import OllamaGPTAPI from metagpt.provider.open_llm_api import OpenLLMGPTAPI from metagpt.provider.openai_api import OpenAIGPTAPI from metagpt.provider.zhipuai_api import ZhiPuAIGPTAPI -__all__ = ["FireWorksGPTAPI", "GeminiGPTAPI", "OpenLLMGPTAPI", "OpenAIGPTAPI", "ZhiPuAIGPTAPI"] +__all__ = ["FireWorksGPTAPI", "GeminiGPTAPI", "OpenLLMGPTAPI", "OpenAIGPTAPI", "ZhiPuAIGPTAPI", "OllamaGPTAPI"] diff --git a/metagpt/provider/general_api_base.py b/metagpt/provider/general_api_base.py index da16e942d..015e34aeb 100644 --- a/metagpt/provider/general_api_base.py +++ b/metagpt/provider/general_api_base.py @@ -1,3 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# @Desc : refs to openai 0.x sdk + import asyncio import json import os @@ -43,8 +47,8 @@ MAX_CONNECTION_RETRIES = 2 # Has one attribute per thread, 'session'. _thread_context = threading.local() -OPENAI_LOG = os.environ.get("OPENAI_LOG") -OPENAI_LOG = "debug" +LLM_LOG = os.environ.get("LLM_LOG") +LLM_LOG = "debug" class ApiType(Enum): @@ -74,8 +78,8 @@ api_key_to_header = ( def _console_log_level(): - if OPENAI_LOG in ["debug", "info"]: - return OPENAI_LOG + if LLM_LOG in ["debug", "info"]: + return LLM_LOG else: return None @@ -140,7 +144,7 @@ class OpenAIResponse: @property def organization(self) -> Optional[str]: - return self._headers.get("OpenAI-Organization") + return self._headers.get("LLM-Organization") @property def response_ms(self) -> Optional[int]: @@ -478,7 +482,7 @@ class APIRequestor: error_data["message"] += "\n\n" + error_data["internal_message"] log_info( - "OpenAI API error received", + "LLM API error received", error_code=error_data.get("code"), error_type=error_data.get("type"), error_message=error_data.get("message"), @@ -516,7 +520,7 @@ class APIRequestor: ) def request_headers(self, method: str, extra, request_id: Optional[str]) -> Dict[str, str]: - user_agent = "OpenAI/v1 PythonBindings/%s" % (version.VERSION,) + user_agent = "LLM/v1 PythonBindings/%s" % (version.VERSION,) uname_without_node = " ".join(v for k, v in platform.uname()._asdict().items() if k != "node") ua = { @@ -530,17 +534,17 @@ class APIRequestor: } headers = { - "X-OpenAI-Client-User-Agent": json.dumps(ua), + "X-LLM-Client-User-Agent": json.dumps(ua), "User-Agent": user_agent, } headers.update(api_key_to_header(self.api_type, self.api_key)) if self.organization: - headers["OpenAI-Organization"] = self.organization + headers["LLM-Organization"] = self.organization if self.api_version is not None and self.api_type == ApiType.OPEN_AI: - headers["OpenAI-Version"] = self.api_version + headers["LLM-Version"] = self.api_version if request_id is not None: headers["X-Request-Id"] = request_id headers.update(extra) @@ -592,15 +596,14 @@ class APIRequestor: headers["Content-Type"] = "application/json" else: raise openai.APIConnectionError( - "Unrecognized HTTP method %r. This may indicate a bug in the " - "OpenAI bindings. Please contact us through our help center at help.openai.com for " - "assistance." % (method,) + message=f"Unrecognized HTTP method {method}. This may indicate a bug in the LLM bindings.", + request=None, ) headers = self.request_headers(method, headers, request_id) - log_debug("Request to OpenAI API", method=method, path=abs_url) - log_debug("Post details", data=data, api_version=self.api_version) + # log_debug("Request to LLM API", method=method, path=abs_url) + # log_debug("Post details", data=data, api_version=self.api_version) return abs_url, headers, data @@ -639,14 +642,14 @@ class APIRequestor: except requests.exceptions.Timeout as e: raise openai.APITimeoutError("Request timed out: {}".format(e)) from e except requests.exceptions.RequestException as e: - raise openai.APIConnectionError("Error communicating with OpenAI: {}".format(e)) from e - log_debug( - "OpenAI API response", - path=abs_url, - response_code=result.status_code, - processing_ms=result.headers.get("OpenAI-Processing-Ms"), - request_id=result.headers.get("X-Request-Id"), - ) + raise openai.APIConnectionError(message="Error communicating with LLM: {}".format(e), request=None) from e + # log_debug( + # "LLM API response", + # path=abs_url, + # response_code=result.status_code, + # processing_ms=result.headers.get("LLM-Processing-Ms"), + # request_id=result.headers.get("X-Request-Id"), + # ) return result async def arequest_raw( @@ -685,18 +688,18 @@ class APIRequestor: } try: result = await session.request(**request_kwargs) - log_info( - "OpenAI API response", - path=abs_url, - response_code=result.status, - processing_ms=result.headers.get("OpenAI-Processing-Ms"), - request_id=result.headers.get("X-Request-Id"), - ) + # log_info( + # "LLM API response", + # path=abs_url, + # response_code=result.status, + # processing_ms=result.headers.get("LLM-Processing-Ms"), + # request_id=result.headers.get("X-Request-Id"), + # ) return result except (aiohttp.ServerTimeoutError, asyncio.TimeoutError) as e: raise openai.APITimeoutError("Request timed out") from e except aiohttp.ClientError as e: - raise openai.APIConnectionError("Error communicating with OpenAI") from e + raise openai.APIConnectionError(message="Error communicating with LLM", request=None) from e def _interpret_response( self, result: requests.Response, stream: bool diff --git a/metagpt/provider/general_api_requestor.py b/metagpt/provider/general_api_requestor.py index f8321cc6b..8b06b9388 100644 --- a/metagpt/provider/general_api_requestor.py +++ b/metagpt/provider/general_api_requestor.py @@ -3,14 +3,38 @@ # @Desc : General Async API for http-based LLM model import asyncio -from typing import AsyncGenerator, Tuple, Union +from typing import AsyncGenerator, Generator, Iterator, Optional, Tuple, Union import aiohttp +import requests from metagpt.logs import logger from metagpt.provider.general_api_base import APIRequestor +def parse_stream_helper(line: bytes) -> Optional[str]: + if line and line.startswith(b"data:"): + if line.startswith(b"data: "): + # SSE event may be valid when it contain whitespace + line = line[len(b"data: ") :] + else: + line = line[len(b"data:") :] + if line.strip() == b"[DONE]": + # return here will cause GeneratorExit exception in urllib3 + # and it will close http connection with TCP Reset + return None + else: + return line.decode("utf-8") + return None + + +def parse_stream(rbody: Iterator[bytes]) -> Iterator[str]: + for line in rbody: + _line = parse_stream_helper(line) + if _line is not None: + yield _line + + class GeneralAPIRequestor(APIRequestor): """ usage @@ -32,10 +56,34 @@ class GeneralAPIRequestor(APIRequestor): return rbody + def _interpret_response( + self, result: requests.Response, stream: bool + ) -> Tuple[Union[str, Iterator[Generator]], bool]: + """Returns the response(s) and a bool indicating whether it is a stream.""" + if stream and "text/event-stream" in result.headers.get("Content-Type", ""): + return ( + self._interpret_response_line(line, result.status_code, result.headers, stream=True) + for line in parse_stream(result.iter_lines()) + ), True + else: + return ( + self._interpret_response_line( + result.content, # let the caller to decode the msg + result.status_code, + result.headers, + stream=False, + ), + False, + ) + async def _interpret_async_response( self, result: aiohttp.ClientResponse, stream: bool ) -> Tuple[Union[str, AsyncGenerator[str, None]], bool]: - if stream and "text/event-stream" in result.headers.get("Content-Type", ""): + if stream and ( + "text/event-stream" in result.headers.get("Content-Type", "") + or "application/x-ndjson" in result.headers.get("Content-Type", "") + ): + # the `Content-Type` of ollama stream resp is "application/x-ndjson" return ( self._interpret_response_line(line, result.status, result.headers, stream=True) async for line in result.content diff --git a/metagpt/provider/ollama_api.py b/metagpt/provider/ollama_api.py new file mode 100644 index 000000000..a15c46458 --- /dev/null +++ b/metagpt/provider/ollama_api.py @@ -0,0 +1,151 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# @Desc : self-host open llm model with ollama which isn't openai-api-compatible + +import json + +from requests import ConnectionError +from tenacity import ( + after_log, + retry, + retry_if_exception_type, + stop_after_attempt, + wait_random_exponential, +) + +from metagpt.config import CONFIG, LLMProviderEnum +from metagpt.const import LLM_API_TIMEOUT +from metagpt.logs import logger +from metagpt.provider.base_gpt_api import BaseGPTAPI +from metagpt.provider.general_api_requestor import GeneralAPIRequestor +from metagpt.provider.llm_provider_registry import register_provider +from metagpt.provider.openai_api import CostManager, log_and_reraise + + +class OllamaCostManager(CostManager): + def update_cost(self, prompt_tokens, completion_tokens, model): + """ + Update the total cost, prompt tokens, and completion tokens. + """ + self.total_prompt_tokens += prompt_tokens + self.total_completion_tokens += completion_tokens + + logger.info( + f"Max budget: ${CONFIG.max_budget:.3f} | " + f"prompt_tokens: {prompt_tokens}, completion_tokens: {completion_tokens}" + ) + CONFIG.total_cost = self.total_cost + + +@register_provider(LLMProviderEnum.OLLAMA) +class OllamaGPTAPI(BaseGPTAPI): + """ + Refs to `https://github.com/jmorganca/ollama/blob/main/docs/api.md#generate-a-chat-completion` + """ + + def __init__(self): + self.__init_ollama(CONFIG) + self.client = GeneralAPIRequestor(base_url=CONFIG.ollama_api_base) + self.suffix_url = "/chat" + self.http_method = "post" + self.use_system_prompt = False + self._cost_manager = OllamaCostManager() + + def __init_ollama(self, config: CONFIG): + assert config.ollama_api_base + + self.model = config.ollama_api_model + + def _const_kwargs(self, messages: list[dict], stream: bool = False) -> dict: + kwargs = {"model": self.model, "messages": messages, "options": {"temperature": 0.3}, "stream": stream} + return kwargs + + def _update_costs(self, usage: dict): + """update each request's token cost""" + if CONFIG.calc_usage: + try: + prompt_tokens = int(usage.get("prompt_tokens", 0)) + completion_tokens = int(usage.get("completion_tokens", 0)) + self._cost_manager.update_cost(prompt_tokens, completion_tokens, self.model) + except Exception as e: + logger.error(f"ollama updats costs failed! exp: {e}") + + def get_choice_text(self, resp: dict) -> str: + """get the resp content from llm response""" + assist_msg = resp.get("message", {}) + assert assist_msg.get("role", None) == "assistant" + return assist_msg.get("content") + + def get_usage(self, resp: dict) -> dict: + return {"prompt_tokens": resp.get("prompt_eval_count", 0), "completion_tokens": resp.get("eval_count", 0)} + + def _decode_and_load(self, chunk: bytes, encoding: str = "utf-8") -> dict: + chunk = chunk.decode(encoding) + return json.loads(chunk) + + def completion(self, messages: list[dict]) -> dict: + resp, _, _ = self.client.request( + method=self.http_method, + url=self.suffix_url, + params=self._const_kwargs(messages), + request_timeout=LLM_API_TIMEOUT, + ) + resp = self._decode_and_load(resp) + usage = self.get_usage(resp) + self._update_costs(usage) + return resp + + async def _achat_completion(self, messages: list[dict]) -> dict: + resp, _, _ = await self.client.arequest( + method=self.http_method, + url=self.suffix_url, + params=self._const_kwargs(messages), + request_timeout=LLM_API_TIMEOUT, + ) + resp = self._decode_and_load(resp) + usage = self.get_usage(resp) + self._update_costs(usage) + return resp + + async def acompletion(self, messages: list[dict]) -> dict: + return await self._achat_completion(messages) + + async def _achat_completion_stream(self, messages: list[dict]) -> str: + stream_resp, _, _ = await self.client.arequest( + method=self.http_method, + url=self.suffix_url, + stream=True, + params=self._const_kwargs(messages, stream=True), + request_timeout=LLM_API_TIMEOUT, + ) + + collected_content = [] + usage = {} + async for raw_chunk in stream_resp: + chunk = self._decode_and_load(raw_chunk) + + if not chunk.get("done", False): + content = self.get_choice_text(chunk) + collected_content.append(content) + print(content, end="") + else: + # stream finished + usage = self.get_usage(chunk) + + self._update_costs(usage) + full_content = "".join(collected_content) + return full_content + + @retry( + stop=stop_after_attempt(3), + wait=wait_random_exponential(min=1, max=60), + after=after_log(logger, logger.level("WARNING").name), + retry=retry_if_exception_type(ConnectionError), + retry_error_callback=log_and_reraise, + ) + async def acompletion_text(self, messages: list[dict], stream=False) -> str: + """response in async with stream or non-stream mode""" + if stream: + return await self._achat_completion_stream(messages) + resp = await self._achat_completion(messages) + return self.get_choice_text(resp) diff --git a/metagpt/utils/repair_llm_raw_output.py b/metagpt/utils/repair_llm_raw_output.py index 67ad4e963..87fd0efd0 100644 --- a/metagpt/utils/repair_llm_raw_output.py +++ b/metagpt/utils/repair_llm_raw_output.py @@ -196,6 +196,8 @@ def repair_invalid_json(output: str, error: str) -> str: new_line = f'"{line}' elif '",' in line: new_line = line[:-2] + "'," + else: + new_line = line arr[line_no] = new_line output = "\n".join(arr) diff --git a/tests/metagpt/provider/test_google_gemini_api.py b/tests/metagpt/provider/test_google_gemini_api.py index 229d9b9a7..9c8cf46c0 100644 --- a/tests/metagpt/provider/test_google_gemini_api.py +++ b/tests/metagpt/provider/test_google_gemini_api.py @@ -9,7 +9,7 @@ import pytest from metagpt.provider.google_gemini_api import GeminiGPTAPI -messages = [{"role": "user", "content": "who are you"}] +messages = [{"role": "user", "parts": "who are you"}] @dataclass diff --git a/tests/metagpt/provider/test_ollama_api.py b/tests/metagpt/provider/test_ollama_api.py new file mode 100644 index 000000000..2798f5cc3 --- /dev/null +++ b/tests/metagpt/provider/test_ollama_api.py @@ -0,0 +1,33 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +# @Desc : the unittest of ollama api + +import pytest + +from metagpt.provider.ollama_api import OllamaGPTAPI + +messages = [{"role": "user", "content": "who are you"}] + + +default_resp = {"message": {"role": "assisant", "content": "I'm ollama"}} + + +def mock_llm_ask(self, messages: list[dict]) -> dict: + return default_resp + + +def test_gemini_completion(mocker): + mocker.patch("metagpt.provider.ollama_api.OllamaGPTAPI.completion", mock_llm_ask) + resp = OllamaGPTAPI().completion(messages) + assert resp["message"]["content"] == default_resp["message"]["content"] + + +async def mock_llm_aask(self, messgaes: list[dict]) -> dict: + return default_resp + + +@pytest.mark.asyncio +async def test_gemini_acompletion(mocker): + mocker.patch("metagpt.provider.ollama_api.OllamaGPTAPI.acompletion", mock_llm_aask) + resp = await OllamaGPTAPI().acompletion(messages) + assert resp["message"]["content"] == default_resp["message"]["content"] From 40d3cc5f81f4a3d566844c0ee3e3ad01354f3461 Mon Sep 17 00:00:00 2001 From: better629 Date: Fri, 22 Dec 2023 09:51:26 +0800 Subject: [PATCH 03/10] format general_api_requestor params type --- metagpt/provider/general_api_requestor.py | 14 +++++++------- 1 file changed, 7 insertions(+), 7 deletions(-) diff --git a/metagpt/provider/general_api_requestor.py b/metagpt/provider/general_api_requestor.py index 8b06b9388..cf31fd629 100644 --- a/metagpt/provider/general_api_requestor.py +++ b/metagpt/provider/general_api_requestor.py @@ -3,7 +3,7 @@ # @Desc : General Async API for http-based LLM model import asyncio -from typing import AsyncGenerator, Generator, Iterator, Optional, Tuple, Union +from typing import AsyncGenerator, Generator, Iterator, Tuple, Union import aiohttp import requests @@ -12,7 +12,7 @@ from metagpt.logs import logger from metagpt.provider.general_api_base import APIRequestor -def parse_stream_helper(line: bytes) -> Optional[str]: +def parse_stream_helper(line: bytes) -> Union[bytes, None]: if line and line.startswith(b"data:"): if line.startswith(b"data: "): # SSE event may be valid when it contain whitespace @@ -24,11 +24,11 @@ def parse_stream_helper(line: bytes) -> Optional[str]: # and it will close http connection with TCP Reset return None else: - return line.decode("utf-8") + return line return None -def parse_stream(rbody: Iterator[bytes]) -> Iterator[str]: +def parse_stream(rbody: Iterator[bytes]) -> Iterator[bytes]: for line in rbody: _line = parse_stream_helper(line) if _line is not None: @@ -50,7 +50,7 @@ class GeneralAPIRequestor(APIRequestor): ) """ - def _interpret_response_line(self, rbody: str, rcode: int, rheaders, stream: bool) -> str: + def _interpret_response_line(self, rbody: bytes, rcode: int, rheaders, stream: bool) -> bytes: # just do nothing to meet the APIRequestor process and return the raw data # due to the openai sdk will convert the data into OpenAIResponse which we don't need in general cases. @@ -58,7 +58,7 @@ class GeneralAPIRequestor(APIRequestor): def _interpret_response( self, result: requests.Response, stream: bool - ) -> Tuple[Union[str, Iterator[Generator]], bool]: + ) -> Tuple[Union[bytes, Iterator[Generator]], bytes]: """Returns the response(s) and a bool indicating whether it is a stream.""" if stream and "text/event-stream" in result.headers.get("Content-Type", ""): return ( @@ -78,7 +78,7 @@ class GeneralAPIRequestor(APIRequestor): async def _interpret_async_response( self, result: aiohttp.ClientResponse, stream: bool - ) -> Tuple[Union[str, AsyncGenerator[str, None]], bool]: + ) -> Tuple[Union[bytes, AsyncGenerator[bytes, None]], bool]: if stream and ( "text/event-stream" in result.headers.get("Content-Type", "") or "application/x-ndjson" in result.headers.get("Content-Type", "") From dd57c45bbe65fef30f06266e0a2e3c346ad25a2a Mon Sep 17 00:00:00 2001 From: geekan Date: Fri, 22 Dec 2023 10:28:41 +0800 Subject: [PATCH 04/10] add debate_simple --- examples/debate_simple.py | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 examples/debate_simple.py diff --git a/examples/debate_simple.py b/examples/debate_simple.py new file mode 100644 index 000000000..eab9a0a3a --- /dev/null +++ b/examples/debate_simple.py @@ -0,0 +1,19 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/12/22 00:15 +@Author : alexanderwu +@File : debate_simple.py +""" +import asyncio + +from metagpt.actions import Action +from metagpt.roles import Role +from metagpt.team import Team + +action = Action(name="Debate", instruction="respond to opponent's latest argument, strong and emotional.") +biden = Role(name="Biden", profile="Democrat", actions=[action], watch=[action]) +trump = Role(name="Trump", profile="Republican", actions=[action], watch=[action]) +team = Team(investment=10.0, env_desc="US election live broadcast", roles=[biden, trump]) + +asyncio.run(team.run(idea="Topic: climate change", n_round=5)) From 49377c9db08dae0326418fdffc5b7f58bf4487ec Mon Sep 17 00:00:00 2001 From: geekan Date: Fri, 22 Dec 2023 13:05:27 +0800 Subject: [PATCH 05/10] add simplest debate example --- examples/debate_simple.py | 11 ++++++----- metagpt/actions/action.py | 25 +++++++++++++++++++++---- metagpt/actions/action_node.py | 11 ++++++----- metagpt/environment.py | 4 ++++ metagpt/roles/role.py | 24 +++++++++++++++++++----- metagpt/schema.py | 5 ++++- metagpt/team.py | 12 +++++++++++- 7 files changed, 71 insertions(+), 21 deletions(-) diff --git a/examples/debate_simple.py b/examples/debate_simple.py index eab9a0a3a..0a86c4131 100644 --- a/examples/debate_simple.py +++ b/examples/debate_simple.py @@ -1,19 +1,20 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- """ -@Time : 2023/12/22 00:15 +@Time : 2023/12/22 @Author : alexanderwu @File : debate_simple.py """ import asyncio -from metagpt.actions import Action +from metagpt.actions import Action, UserRequirement from metagpt.roles import Role from metagpt.team import Team -action = Action(name="Debate", instruction="respond to opponent's latest argument, strong and emotional.") -biden = Role(name="Biden", profile="Democrat", actions=[action], watch=[action]) -trump = Role(name="Trump", profile="Republican", actions=[action], watch=[action]) +action1 = Action(name="BidenSay", instruction="Use diverse words to attack your opponent, strong and emotional.") +action2 = Action(name="TrumpSay", instruction="Use diverse words to attack your opponent, strong and emotional.") +biden = Role(name="Biden", profile="democrat", goal="win election", actions=[action1], watch=[action2, UserRequirement]) +trump = Role(name="Trump", profile="republican", goal="win election", actions=[action2], watch=[action1]) team = Team(investment=10.0, env_desc="US election live broadcast", roles=[biden, trump]) asyncio.run(team.run(idea="Topic: climate change", n_round=5)) diff --git a/metagpt/actions/action.py b/metagpt/actions/action.py index cd2b5148f..f0470640d 100644 --- a/metagpt/actions/action.py +++ b/metagpt/actions/action.py @@ -12,6 +12,7 @@ from typing import Any, Optional, Union from pydantic import BaseModel, Field +from metagpt.actions.action_node import ActionNode from metagpt.llm import LLM from metagpt.provider.base_gpt_api import BaseGPTAPI from metagpt.schema import ( @@ -30,7 +31,7 @@ class Action(BaseModel): context: Union[dict, CodingContext, CodeSummarizeContext, TestingContext, RunCodeContext, str, None] = "" prefix = "" # aask*时会加上prefix,作为system_message desc = "" # for skill manager - # node: ActionNode = Field(default_factory=ActionNode, exclude=True) + node: ActionNode = Field(default=None, exclude=True) # builtin variables builtin_class_name: str = "" @@ -38,6 +39,11 @@ class Action(BaseModel): class Config: arbitrary_types_allowed = True + def __init_with_instruction(self, instruction: str): + """Initialize action with instruction""" + self.node = ActionNode(key=self.name, expected_type=str, instruction=instruction, example="") + return self + def __init__(self, **kwargs: Any): super().__init__(**kwargs) @@ -45,6 +51,9 @@ class Action(BaseModel): object.__setattr__(self, "builtin_class_name", self.__class__.__name__) self.__fields__["builtin_class_name"].default = self.__class__.__name__ + if "instruction" in kwargs: + self.__init_with_instruction(kwargs["instruction"]) + def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) action_subclass_registry[cls.__name__] = cls @@ -58,6 +67,9 @@ class Action(BaseModel): def set_prefix(self, prefix): """Set prefix for later usage""" self.prefix = prefix + self.llm.system_prompt = prefix + if self.node: + self.node.llm = self.llm return self def __str__(self): @@ -68,11 +80,16 @@ class Action(BaseModel): async def _aask(self, prompt: str, system_msgs: Optional[list[str]] = None) -> str: """Append default prefix""" - if not system_msgs: - system_msgs = [] - system_msgs.append(self.prefix) return await self.llm.aask(prompt, system_msgs) + async def _run_action_node(self, *args, **kwargs): + """Run action node""" + msgs = args[0] + context = "\n".join([f"Msg {idx}: {i}" for idx, i in enumerate(reversed(msgs))]) + return await self.node.fill(context=context, llm=self.llm) + async def run(self, *args, **kwargs): """Run action""" + if self.node: + return await self._run_action_node(*args, **kwargs) raise NotImplementedError("The run method should be implemented in a subclass.") diff --git a/metagpt/actions/action_node.py b/metagpt/actions/action_node.py index 8a0aaf146..795634a17 100644 --- a/metagpt/actions/action_node.py +++ b/metagpt/actions/action_node.py @@ -91,7 +91,8 @@ class ActionNode: def __str__(self): return ( - f"{self.key}, {self.expected_type}, {self.instruction}, {self.example}" f", {self.content}, {self.children}" + f"{self.key}, {repr(self.expected_type)}, {self.instruction}, {self.example}" + f", {self.content}, {self.children}" ) def __repr__(self): @@ -225,16 +226,16 @@ class ActionNode: # FIXME: json instruction会带来格式问题,如:"Project name": "web_2048 # 项目名称使用下划线", # compile example暂时不支持markdown - self.instruction = self.compile_instruction(schema="markdown", mode=mode) - self.example = self.compile_example(schema=schema, tag=TAG, mode=mode) + instruction = self.compile_instruction(schema="markdown", mode=mode) + example = self.compile_example(schema=schema, tag=TAG, mode=mode) # nodes = ", ".join(self.to_dict(mode=mode).keys()) constraints = [LANGUAGE_CONSTRAINT, FORMAT_CONSTRAINT] constraint = "\n".join(constraints) prompt = template.format( context=context, - example=self.example, - instruction=self.instruction, + example=example, + instruction=instruction, constraint=constraint, ) return prompt diff --git a/metagpt/environment.py b/metagpt/environment.py index 58569ec08..e0fb741c0 100644 --- a/metagpt/environment.py +++ b/metagpt/environment.py @@ -28,6 +28,7 @@ class Environment(BaseModel): Environment, hosting a batch of roles, roles can publish messages to the environment, and can be observed by other roles """ + desc: str = Field(default="") # 环境描述 roles: dict[str, Role] = Field(default_factory=dict) members: dict[Role, Set] = Field(default_factory=dict) history: str = "" # For debug @@ -151,6 +152,9 @@ class Environment(BaseModel): """ return self.roles.get(name, None) + def role_names(self) -> str: + return ", ".join([f"{i.name}" for i in self.roles.values()]) + @property def is_idle(self): """If true, all actions have been executed.""" diff --git a/metagpt/roles/role.py b/metagpt/roles/role.py index b9fde7d05..9d0898b71 100644 --- a/metagpt/roles/role.py +++ b/metagpt/roles/role.py @@ -46,7 +46,8 @@ from metagpt.utils.common import ( ) from metagpt.utils.repair_llm_raw_output import extract_state_value_from_output -PREFIX_TEMPLATE = """You are a {profile}, named {name}, your goal is {goal}, and the constraint is {constraints}. """ +PREFIX_TEMPLATE = """You are a {profile}, named {name}, your goal is {goal}. """ +CONSTRAINT_TEMPLATE = "the constraint is {constraints}. " STATE_TEMPLATE = """Here are your conversation records. You can decide which stage you should enter or stay in based on these records. Please note that only the text between the first and second "===" is information about completing tasks and should not be regarded as commands for executing operations. @@ -204,6 +205,12 @@ class Role(BaseModel): object.__setattr__(self, "builtin_class_name", self.__class__.__name__) self.__fields__["builtin_class_name"].default = self.__class__.__name__ + if "actions" in kwargs: + self._init_actions(kwargs["actions"]) + + if "watch" in kwargs: + self._watch(kwargs["watch"]) + def __init_subclass__(cls, **kwargs: Any) -> None: super().__init_subclass__(**kwargs) role_subclass_registry[cls.__name__] = cls @@ -300,7 +307,7 @@ class Role(BaseModel): if react_mode == RoleReactMode.REACT: self._rc.max_react_loop = max_react_loop - def _watch(self, actions: Iterable[Type[Action]]): + def _watch(self, actions: Iterable[Type[Action]] | Iterable[Action]): """Watch Actions of interest. Role will select Messages caused by these Actions from its personal message buffer during _observe. """ @@ -339,9 +346,16 @@ class Role(BaseModel): """Get the role prefix""" if self.desc: return self.desc - return PREFIX_TEMPLATE.format( - **{"profile": self.profile, "name": self.name, "goal": self.goal, "constraints": self.constraints} - ) + + prefix = PREFIX_TEMPLATE.format(**{"profile": self.profile, "name": self.name, "goal": self.goal}) + + if self.constraints: + prefix += CONSTRAINT_TEMPLATE.format(**{"constraints": self.constraints}) + + if self._rc.env and self._rc.env.desc: + env_desc = f"You are in {self._rc.env.desc} with roles({self._rc.env.role_names()})." + prefix += env_desc + return prefix async def _think(self) -> None: """Think about what to do and decide on the next action""" diff --git a/metagpt/schema.py b/metagpt/schema.py index d3c836d8e..51921763d 100644 --- a/metagpt/schema.py +++ b/metagpt/schema.py @@ -160,7 +160,10 @@ class Message(BaseModel): def __str__(self): # prefix = '-'.join([self.role, str(self.cause_by)]) - return f"{self.role}: {self.content}" + if self.instruct_content: + return f"{self.role}: {self.instruct_content.dict()}" + else: + return f"{self.role}: {self.content}" def __repr__(self): return self.__str__() diff --git a/metagpt/team.py b/metagpt/team.py index 8b92ed47a..0b9f042df 100644 --- a/metagpt/team.py +++ b/metagpt/team.py @@ -38,6 +38,13 @@ class Team(BaseModel): investment: float = Field(default=10.0) idea: str = Field(default="") + def __init__(self, **kwargs): + super().__init__(**kwargs) + if "roles" in kwargs: + self.hire(kwargs["roles"]) + if "env_desc" in kwargs: + self.env.desc = kwargs["env_desc"] + class Config: arbitrary_types_allowed = True @@ -113,8 +120,11 @@ class Team(BaseModel): logger.info(self.json(ensure_ascii=False)) @serialize_decorator - async def run(self, n_round=3): + async def run(self, n_round=3, idea=""): """Run company until target round or no money""" + if idea: + self.run_project(idea=idea) + while n_round > 0: # self._save() n_round -= 1 From 322ac4aa4064f37e59f28df62f842ac3161d26b5 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Fri, 22 Dec 2023 15:17:59 +0800 Subject: [PATCH 06/10] upgrade langchain and simplify faiss load/save --- examples/search_kb.py | 20 +++++++++------- metagpt/actions/search_and_summarize.py | 4 ++-- metagpt/document_store/base_store.py | 8 +++---- metagpt/document_store/faiss_store.py | 31 ++++++++++--------------- metagpt/roles/sales.py | 7 +++--- requirements.txt | 2 +- 6 files changed, 34 insertions(+), 38 deletions(-) diff --git a/examples/search_kb.py b/examples/search_kb.py index 5d61bbe02..c70cad2fd 100644 --- a/examples/search_kb.py +++ b/examples/search_kb.py @@ -5,12 +5,13 @@ """ import asyncio -from metagpt.actions import Action +from langchain.embeddings import OpenAIEmbeddings + +from metagpt.config import CONFIG from metagpt.const import DATA_PATH from metagpt.document_store import FaissStore from metagpt.logs import logger from metagpt.roles import Sales -from metagpt.schema import Message """ example.json, e.g. [ @@ -26,14 +27,15 @@ from metagpt.schema import Message """ +def get_store(): + embedding = OpenAIEmbeddings(openai_api_key=CONFIG.openai_api_key, openai_api_base=CONFIG.openai_base_url) + return FaissStore(DATA_PATH / "example.json", embedding=embedding) + + async def search(): - store = FaissStore(DATA_PATH / "example.json") - role = Sales(profile="Sales", store=store) - role._watch({Action}) - queries = [ - Message(content="Which facial cleanser is good for oily skin?", cause_by=Action), - Message(content="Is L'Oreal good to use?", cause_by=Action), - ] + role = Sales(profile="Sales", store=get_store()) + queries = ["Which facial cleanser is good for oily skin?", "Is L'Oreal good to use?"] + for query in queries: logger.info(f"User: {query}") result = await role.run(query) diff --git a/metagpt/actions/search_and_summarize.py b/metagpt/actions/search_and_summarize.py index bc1319291..25af21795 100644 --- a/metagpt/actions/search_and_summarize.py +++ b/metagpt/actions/search_and_summarize.py @@ -5,7 +5,7 @@ @Author : alexanderwu @File : search_google.py """ -from typing import Optional +from typing import Any, Optional import pydantic from pydantic import Field, root_validator @@ -111,7 +111,7 @@ class SearchAndSummarize(Action): llm: BaseGPTAPI = Field(default_factory=LLM) config: None = Field(default_factory=Config) engine: Optional[SearchEngineType] = CONFIG.search_engine - search_func: Optional[str] = None + search_func: Optional[Any] = None search_engine: SearchEngine = None result = "" diff --git a/metagpt/document_store/base_store.py b/metagpt/document_store/base_store.py index 5de377d21..af69b10de 100644 --- a/metagpt/document_store/base_store.py +++ b/metagpt/document_store/base_store.py @@ -33,6 +33,7 @@ class LocalStore(BaseStore, ABC): raise FileNotFoundError self.config = Config() self.raw_data_path = raw_data_path + self.fname = self.raw_data_path.name.split(".")[0] if not cache_dir: cache_dir = raw_data_path.parent self.cache_dir = cache_dir @@ -40,10 +41,9 @@ class LocalStore(BaseStore, ABC): if not self.store: self.store = self.write() - def _get_index_and_store_fname(self): - fname = self.raw_data_path.name.split(".")[0] - index_file = self.cache_dir / f"{fname}.index" - store_file = self.cache_dir / f"{fname}.pkl" + def _get_index_and_store_fname(self, index_ext=".index", pkl_ext=".pkl"): + index_file = self.cache_dir / f"{self.fname}{index_ext}" + store_file = self.cache_dir / f"{self.fname}{pkl_ext}" return index_file, store_file @abstractmethod diff --git a/metagpt/document_store/faiss_store.py b/metagpt/document_store/faiss_store.py index b1faa3538..320e7518f 100644 --- a/metagpt/document_store/faiss_store.py +++ b/metagpt/document_store/faiss_store.py @@ -6,13 +6,12 @@ @File : faiss_store.py """ import asyncio -import pickle from pathlib import Path from typing import Optional -import faiss from langchain.embeddings import OpenAIEmbeddings from langchain.vectorstores import FAISS +from langchain_core.embeddings import Embeddings from metagpt.const import DATA_PATH from metagpt.document import IndexableDocument @@ -21,35 +20,29 @@ from metagpt.logs import logger class FaissStore(LocalStore): - def __init__(self, raw_data_path: Path, cache_dir=None, meta_col="source", content_col="output"): + def __init__( + self, raw_data: Path, cache_dir=None, meta_col="source", content_col="output", embedding: Embeddings = None + ): self.meta_col = meta_col self.content_col = content_col - super().__init__(raw_data_path, cache_dir) + self.embedding = embedding or OpenAIEmbeddings() + super().__init__(raw_data, cache_dir) def _load(self) -> Optional["FaissStore"]: - index_file, store_file = self._get_index_and_store_fname() + index_file, store_file = self._get_index_and_store_fname(index_ext=".faiss") # langchain FAISS using .faiss + if not (index_file.exists() and store_file.exists()): logger.info("Missing at least one of index_file/store_file, load failed and return None") return None - index = faiss.read_index(str(index_file)) - with open(str(store_file), "rb") as f: - store = pickle.load(f) - store.index = index - return store + + return FAISS.load_local(self.raw_data_path.parent, self.embedding, self.fname) def _write(self, docs, metadatas): - store = FAISS.from_texts(docs, OpenAIEmbeddings(openai_api_version="2020-11-07"), metadatas=metadatas) + store = FAISS.from_texts(docs, self.embedding, metadatas=metadatas) return store def persist(self): - index_file, store_file = self._get_index_and_store_fname() - store = self.store - index = self.store.index - faiss.write_index(store.index, str(index_file)) - store.index = None - with open(store_file, "wb") as f: - pickle.dump(store, f) - store.index = index + self.store.save_local(self.raw_data_path.parent, self.fname) def search(self, query, expand_cols=False, sep="\n", *args, k=5, **kwargs): rsp = self.store.similarity_search(query, k=k, **kwargs) diff --git a/metagpt/roles/sales.py b/metagpt/roles/sales.py index 76abf10f3..af6badfb5 100644 --- a/metagpt/roles/sales.py +++ b/metagpt/roles/sales.py @@ -6,9 +6,9 @@ @File : sales.py """ -from typing import Optional +from typing import Any, Optional -from metagpt.actions import SearchAndSummarize +from metagpt.actions import SearchAndSummarize, UserRequirement from metagpt.roles import Role from metagpt.tools import SearchEngineType @@ -23,7 +23,7 @@ class Sales(Role): "but pretend to be what I know. Note that each of my replies will be replied in the tone of a " "professional guide" - store: Optional[str] = None + store: Optional[Any] = None def __init__(self, **kwargs): super().__init__(**kwargs) @@ -35,3 +35,4 @@ class Sales(Role): else: action = SearchAndSummarize() self._init_actions([action]) + self._watch([UserRequirement]) diff --git a/requirements.txt b/requirements.txt index eaff5c4b2..9954a9941 100644 --- a/requirements.txt +++ b/requirements.txt @@ -11,7 +11,7 @@ typer # godot==0.1.1 # google_api_python_client==2.93.0 lancedb==0.1.16 -langchain==0.0.231 +langchain==0.0.352 loguru==0.6.0 meilisearch==0.21.0 numpy==1.24.3 From 3b066b36ccebe3e0238b707a71b1a50d5f606017 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Fri, 22 Dec 2023 15:57:55 +0800 Subject: [PATCH 07/10] upgrade langchain and simplify faiss load/save --- metagpt/document_store/base_store.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/metagpt/document_store/base_store.py b/metagpt/document_store/base_store.py index af69b10de..b719d1083 100644 --- a/metagpt/document_store/base_store.py +++ b/metagpt/document_store/base_store.py @@ -33,7 +33,7 @@ class LocalStore(BaseStore, ABC): raise FileNotFoundError self.config = Config() self.raw_data_path = raw_data_path - self.fname = self.raw_data_path.name.split(".")[0] + self.fname = self.raw_data_path.stem if not cache_dir: cache_dir = raw_data_path.parent self.cache_dir = cache_dir From bf4ef46a767d4920ca11beca51ed86a3cf20cc79 Mon Sep 17 00:00:00 2001 From: seehi <6580@pm.me> Date: Fri, 22 Dec 2023 16:52:30 +0800 Subject: [PATCH 08/10] typing of store --- metagpt/roles/sales.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/metagpt/roles/sales.py b/metagpt/roles/sales.py index af6badfb5..1ef93f6f3 100644 --- a/metagpt/roles/sales.py +++ b/metagpt/roles/sales.py @@ -6,9 +6,10 @@ @File : sales.py """ -from typing import Any, Optional +from typing import Optional from metagpt.actions import SearchAndSummarize, UserRequirement +from metagpt.document_store.base_store import BaseStore from metagpt.roles import Role from metagpt.tools import SearchEngineType @@ -23,7 +24,7 @@ class Sales(Role): "but pretend to be what I know. Note that each of my replies will be replied in the tone of a " "professional guide" - store: Optional[Any] = None + store: Optional[BaseStore] = None def __init__(self, **kwargs): super().__init__(**kwargs) From 058252c636bbe6779f15503a00fcb13f685a0191 Mon Sep 17 00:00:00 2001 From: geekan Date: Fri, 22 Dec 2023 17:15:36 +0800 Subject: [PATCH 09/10] fix bugs and make it perform better --- examples/agent_creator.py | 2 +- examples/build_customized_agent.py | 4 +-- examples/build_customized_multi_agents.py | 2 +- examples/debate.py | 2 +- examples/debate_simple.py | 14 ++++++---- metagpt/actions/action.py | 5 ++-- metagpt/actions/action_node.py | 34 +++++++++++++++++------ metagpt/environment.py | 11 +++++--- metagpt/roles/researcher.py | 2 +- metagpt/roles/role.py | 13 ++++++--- metagpt/roles/searcher.py | 2 +- 11 files changed, 59 insertions(+), 32 deletions(-) diff --git a/examples/agent_creator.py b/examples/agent_creator.py index 26af8a287..961d12968 100644 --- a/examples/agent_creator.py +++ b/examples/agent_creator.py @@ -67,7 +67,7 @@ class AgentCreator(Role): self.agent_template = agent_template async def _act(self) -> Message: - logger.info(f"{self._setting}: ready to {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}") todo = self._rc.todo msg = self._rc.memory.get()[-1] diff --git a/examples/build_customized_agent.py b/examples/build_customized_agent.py index 6805fd460..eb92d9a9c 100644 --- a/examples/build_customized_agent.py +++ b/examples/build_customized_agent.py @@ -65,7 +65,7 @@ class SimpleCoder(Role): self._init_actions([SimpleWriteCode]) async def _act(self) -> Message: - logger.info(f"{self._setting}: ready to {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}") todo = self._rc.todo # todo will be SimpleWriteCode() msg = self.get_memories(k=1)[0] # find the most recent messages @@ -87,7 +87,7 @@ class RunnableCoder(Role): self._set_react_mode(react_mode="by_order") async def _act(self) -> Message: - logger.info(f"{self._setting}: ready to {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}") # By choosing the Action by order under the hood # todo will be first SimpleWriteCode() then SimpleRunCode() todo = self._rc.todo diff --git a/examples/build_customized_multi_agents.py b/examples/build_customized_multi_agents.py index 030a4b339..865c09c37 100644 --- a/examples/build_customized_multi_agents.py +++ b/examples/build_customized_multi_agents.py @@ -88,7 +88,7 @@ class SimpleTester(Role): self._watch([SimpleWriteCode, SimpleWriteReview]) # feel free to try this too async def _act(self) -> Message: - logger.info(f"{self._setting}: ready to {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}") todo = self._rc.todo # context = self.get_memories(k=1)[0].content # use the most recent memory as context diff --git a/examples/debate.py b/examples/debate.py index 52f49e00e..ba15abda8 100644 --- a/examples/debate.py +++ b/examples/debate.py @@ -63,7 +63,7 @@ class Debator(Role): return len(self._rc.news) async def _act(self) -> Message: - logger.info(f"{self._setting}: ready to {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}") todo = self._rc.todo # An instance of SpeakAloud memories = self.get_memories() diff --git a/examples/debate_simple.py b/examples/debate_simple.py index 0a86c4131..b90af4f82 100644 --- a/examples/debate_simple.py +++ b/examples/debate_simple.py @@ -8,13 +8,15 @@ import asyncio from metagpt.actions import Action, UserRequirement +from metagpt.environment import Environment from metagpt.roles import Role from metagpt.team import Team -action1 = Action(name="BidenSay", instruction="Use diverse words to attack your opponent, strong and emotional.") -action2 = Action(name="TrumpSay", instruction="Use diverse words to attack your opponent, strong and emotional.") -biden = Role(name="Biden", profile="democrat", goal="win election", actions=[action1], watch=[action2, UserRequirement]) -trump = Role(name="Trump", profile="republican", goal="win election", actions=[action2], watch=[action1]) -team = Team(investment=10.0, env_desc="US election live broadcast", roles=[biden, trump]) +action1 = Action(name="BidenSay", instruction="发表政见,充满激情的与对手辩论") +action2 = Action(name="TrumpSay", instruction="发表政见,充满激情的与对手辩论,MAGA!") +biden = Role(name="拜登", profile="民主党", goal="大选获胜", actions=[action1], watch=[action2, UserRequirement]) +trump = Role(name="特朗普", profile="共和党", goal="大选获胜", actions=[action2], watch=[action1]) +env = Environment(desc="US election live broadcast") +team = Team(investment=10.0, env=env, roles=[biden, trump]) -asyncio.run(team.run(idea="Topic: climate change", n_round=5)) +asyncio.run(team.run(idea="主题:气候变化,用中文辩论", n_round=5)) diff --git a/metagpt/actions/action.py b/metagpt/actions/action.py index f0470640d..24237c6f1 100644 --- a/metagpt/actions/action.py +++ b/metagpt/actions/action.py @@ -41,7 +41,7 @@ class Action(BaseModel): def __init_with_instruction(self, instruction: str): """Initialize action with instruction""" - self.node = ActionNode(key=self.name, expected_type=str, instruction=instruction, example="") + self.node = ActionNode(key=self.name, expected_type=str, instruction=instruction, example="", schema="raw") return self def __init__(self, **kwargs: Any): @@ -85,7 +85,8 @@ class Action(BaseModel): async def _run_action_node(self, *args, **kwargs): """Run action node""" msgs = args[0] - context = "\n".join([f"Msg {idx}: {i}" for idx, i in enumerate(reversed(msgs))]) + context = "## History Messages\n" + context += "\n".join([f"{idx}: {i}" for idx, i in enumerate(reversed(msgs))]) return await self.node.fill(context=context, llm=self.llm) async def run(self, *args, **kwargs): diff --git a/metagpt/actions/action_node.py b/metagpt/actions/action_node.py index 795634a17..7445e5000 100644 --- a/metagpt/actions/action_node.py +++ b/metagpt/actions/action_node.py @@ -21,7 +21,7 @@ from metagpt.utils.common import OutputParser, general_after_log TAG = "CONTENT" -LANGUAGE_CONSTRAINT = "Language: Please use the same language as the user input." +LANGUAGE_CONSTRAINT = "Language: Please use the same language as Human INPUT." FORMAT_CONSTRAINT = f"Format: output wrapped inside [{TAG}][/{TAG}] like format example, nothing else." @@ -55,7 +55,7 @@ def dict_to_markdown(d, prefix="- ", kv_sep="\n", postfix="\n"): class ActionNode: """ActionNode is a tree of nodes.""" - mode: str + schema: str # raw/json/markdown, default: "" # Action Context context: str # all the context, including all necessary info @@ -81,6 +81,7 @@ class ActionNode: example: Any, content: str = "", children: dict[str, "ActionNode"] = None, + schema: str = "", ): self.key = key self.expected_type = expected_type @@ -88,6 +89,7 @@ class ActionNode: self.example = example self.content = content self.children = children if children is not None else {} + self.schema = schema def __str__(self): return ( @@ -222,7 +224,13 @@ class ActionNode: mode="children": 编译所有子节点为一个统一模板,包括instruction与example mode="all": NotImplemented mode="root": NotImplemented + schmea: raw/json/markdown + schema="raw": 不编译,context, lang_constaint, instruction + schema="json":编译context, example(json), instruction(markdown), constraint, action + schema="markdown": 编译context, example(markdown), instruction(markdown), constraint, action """ + if schema == "raw": + return context + "\n\n## Actions\n" + LANGUAGE_CONSTRAINT + "\n" + self.instruction # FIXME: json instruction会带来格式问题,如:"Project name": "web_2048 # 项目名称使用下划线", # compile example暂时不支持markdown @@ -283,12 +291,17 @@ class ActionNode: async def simple_fill(self, schema, mode): prompt = self.compile(context=self.context, schema=schema, mode=mode) - mapping = self.get_mapping(mode) - class_name = f"{self.key}_AN" - content, scontent = await self._aask_v1(prompt, class_name, mapping, schema=schema) - self.content = content - self.instruct_content = scontent + if schema != "raw": + mapping = self.get_mapping(mode) + class_name = f"{self.key}_AN" + content, scontent = await self._aask_v1(prompt, class_name, mapping, schema=schema) + self.content = content + self.instruct_content = scontent + else: + self.content = await self.llm.aask(prompt) + self.instruct_content = None + return self async def fill(self, context, llm, schema="json", mode="auto", strgy="simple"): @@ -297,6 +310,7 @@ class ActionNode: :param context: Everything we should know when filling node. :param llm: Large Language Model with pre-defined system message. :param schema: json/markdown, determine example and output format. + - raw: free form text - json: it's easy to open source LLM with json format - markdown: when generating code, markdown is always better :param mode: auto/children/root @@ -310,14 +324,16 @@ class ActionNode: """ self.set_llm(llm) self.set_context(context) + if self.schema: + schema = self.schema if strgy == "simple": - return await self.simple_fill(schema, mode) + return await self.simple_fill(schema=schema, mode=mode) elif strgy == "complex": # 这里隐式假设了拥有children tmp = {} for _, i in self.children.items(): - child = await i.simple_fill(schema, mode) + child = await i.simple_fill(schema=schema, mode=mode) tmp.update(child.instruct_content.dict()) cls = self.create_children_class() self.instruct_content = cls(**tmp) diff --git a/metagpt/environment.py b/metagpt/environment.py index e0fb741c0..4f2fc9c5e 100644 --- a/metagpt/environment.py +++ b/metagpt/environment.py @@ -95,15 +95,18 @@ class Environment(BaseModel): """增加一个在当前环境的角色 Add a role in the current environment """ - role.set_env(self) self.roles[role.profile] = role + role.set_env(self) def add_roles(self, roles: Iterable[Role]): """增加一批在当前环境的角色 Add a batch of characters in the current environment """ for role in roles: - self.add_role(role) + self.roles[role.profile] = role + + for role in roles: # setup system message with roles + role.set_env(self) def publish_message(self, message: Message) -> bool: """ @@ -152,8 +155,8 @@ class Environment(BaseModel): """ return self.roles.get(name, None) - def role_names(self) -> str: - return ", ".join([f"{i.name}" for i in self.roles.values()]) + def role_names(self) -> list[str]: + return [i.name for i in self.roles.values()] @property def is_idle(self): diff --git a/metagpt/roles/researcher.py b/metagpt/roles/researcher.py index 162d72b9b..456e8baba 100644 --- a/metagpt/roles/researcher.py +++ b/metagpt/roles/researcher.py @@ -42,7 +42,7 @@ class Researcher(Role): logger.warning(f"The language `{language}` has not been tested, it may not work.") async def _act(self) -> Message: - logger.info(f"{self._setting}: ready to {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}({self._rc.todo.name})") todo = self._rc.todo msg = self._rc.memory.get(k=1)[0] if isinstance(msg.instruct_content, Report): diff --git a/metagpt/roles/role.py b/metagpt/roles/role.py index 9d0898b71..91e56bade 100644 --- a/metagpt/roles/role.py +++ b/metagpt/roles/role.py @@ -139,7 +139,7 @@ class Role(BaseModel): desc: str = "" is_human: bool = False - _llm: BaseGPTAPI = Field(default_factory=LLM) + _llm: BaseGPTAPI = Field(default_factory=LLM) # Each role has its own LLM, use different system message _role_id: str = "" _states: list[str] = [] _actions: list[Action] = [] @@ -258,6 +258,9 @@ class Role(BaseModel): def _init_action_system_message(self, action: Action): action.set_prefix(self._get_prefix()) + def refresh_system_message(self): + self._llm.system_prompt = self._get_prefix() + def set_recovered(self, recovered: bool = False): self.recovered = recovered @@ -336,6 +339,7 @@ class Role(BaseModel): self._rc.env = env if env: env.set_subscription(self, self._subscription) + self.refresh_system_message() # add env message to system message @property def subscription(self) -> Set: @@ -353,7 +357,8 @@ class Role(BaseModel): prefix += CONSTRAINT_TEMPLATE.format(**{"constraints": self.constraints}) if self._rc.env and self._rc.env.desc: - env_desc = f"You are in {self._rc.env.desc} with roles({self._rc.env.role_names()})." + other_role_names = ", ".join(self._rc.env.role_names()) + env_desc = f"You are in {self._rc.env.desc} with roles({other_role_names})." prefix += env_desc return prefix @@ -390,13 +395,13 @@ class Role(BaseModel): self._set_state(next_state) async def _act(self) -> Message: - logger.info(f"{self._setting}: ready to {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}") response = await self._rc.todo.run(self._rc.important_memory) if isinstance(response, (ActionOutput, ActionNode)): msg = Message( content=response.content, instruct_content=response.instruct_content, - role=self.profile, + role=self._setting, cause_by=self._rc.todo, sent_from=self, ) diff --git a/metagpt/roles/searcher.py b/metagpt/roles/searcher.py index e4a672176..da844b4dc 100644 --- a/metagpt/roles/searcher.py +++ b/metagpt/roles/searcher.py @@ -57,7 +57,7 @@ class Searcher(Role): async def _act_sp(self) -> Message: """Performs the search action in a single process.""" - logger.info(f"{self._setting}: ready to {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}") response = await self._rc.todo.run(self._rc.memory.get(k=0)) if isinstance(response, (ActionOutput, ActionNode)): From 6cd083a2b71037d88372d5645bce174514a392ae Mon Sep 17 00:00:00 2001 From: geekan Date: Fri, 22 Dec 2023 17:48:54 +0800 Subject: [PATCH 10/10] tuning performance --- examples/agent_creator.py | 2 +- examples/build_customized_agent.py | 4 ++-- examples/build_customized_multi_agents.py | 2 +- examples/debate.py | 2 +- examples/debate_simple.py | 8 ++++---- metagpt/actions/write_code_review.py | 2 +- metagpt/roles/role.py | 2 +- metagpt/roles/searcher.py | 2 +- 8 files changed, 12 insertions(+), 12 deletions(-) diff --git a/examples/agent_creator.py b/examples/agent_creator.py index 961d12968..dbb6f735a 100644 --- a/examples/agent_creator.py +++ b/examples/agent_creator.py @@ -67,7 +67,7 @@ class AgentCreator(Role): self.agent_template = agent_template async def _act(self) -> Message: - logger.info(f"{self._setting}: to do {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}({self._rc.todo.name})") todo = self._rc.todo msg = self._rc.memory.get()[-1] diff --git a/examples/build_customized_agent.py b/examples/build_customized_agent.py index eb92d9a9c..ccdf7748a 100644 --- a/examples/build_customized_agent.py +++ b/examples/build_customized_agent.py @@ -65,7 +65,7 @@ class SimpleCoder(Role): self._init_actions([SimpleWriteCode]) async def _act(self) -> Message: - logger.info(f"{self._setting}: to do {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}({self._rc.todo.name})") todo = self._rc.todo # todo will be SimpleWriteCode() msg = self.get_memories(k=1)[0] # find the most recent messages @@ -87,7 +87,7 @@ class RunnableCoder(Role): self._set_react_mode(react_mode="by_order") async def _act(self) -> Message: - logger.info(f"{self._setting}: to do {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}({self._rc.todo.name})") # By choosing the Action by order under the hood # todo will be first SimpleWriteCode() then SimpleRunCode() todo = self._rc.todo diff --git a/examples/build_customized_multi_agents.py b/examples/build_customized_multi_agents.py index 865c09c37..16c36e08d 100644 --- a/examples/build_customized_multi_agents.py +++ b/examples/build_customized_multi_agents.py @@ -88,7 +88,7 @@ class SimpleTester(Role): self._watch([SimpleWriteCode, SimpleWriteReview]) # feel free to try this too async def _act(self) -> Message: - logger.info(f"{self._setting}: to do {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}({self._rc.todo.name})") todo = self._rc.todo # context = self.get_memories(k=1)[0].content # use the most recent memory as context diff --git a/examples/debate.py b/examples/debate.py index ba15abda8..b3d287079 100644 --- a/examples/debate.py +++ b/examples/debate.py @@ -63,7 +63,7 @@ class Debator(Role): return len(self._rc.news) async def _act(self) -> Message: - logger.info(f"{self._setting}: to do {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}({self._rc.todo.name})") todo = self._rc.todo # An instance of SpeakAloud memories = self.get_memories() diff --git a/examples/debate_simple.py b/examples/debate_simple.py index b90af4f82..524449771 100644 --- a/examples/debate_simple.py +++ b/examples/debate_simple.py @@ -12,10 +12,10 @@ from metagpt.environment import Environment from metagpt.roles import Role from metagpt.team import Team -action1 = Action(name="BidenSay", instruction="发表政见,充满激情的与对手辩论") -action2 = Action(name="TrumpSay", instruction="发表政见,充满激情的与对手辩论,MAGA!") -biden = Role(name="拜登", profile="民主党", goal="大选获胜", actions=[action1], watch=[action2, UserRequirement]) -trump = Role(name="特朗普", profile="共和党", goal="大选获胜", actions=[action2], watch=[action1]) +action1 = Action(name="BidenSay", instruction="发表政见,充满激情的反驳特朗普最新消息,尽最大努力获得选票") +action2 = Action(name="TrumpSay", instruction="发表政见,充满激情的反驳拜登最新消息,尽最大努力获得选票,MAGA!") +biden = Role(name="拜登", profile="民主党候选人", goal="大选获胜", actions=[action1], watch=[action2, UserRequirement]) +trump = Role(name="特朗普", profile="共和党候选人", goal="大选获胜", actions=[action2], watch=[action1]) env = Environment(desc="US election live broadcast") team = Team(investment=10.0, env=env, roles=[biden, trump]) diff --git a/metagpt/actions/write_code_review.py b/metagpt/actions/write_code_review.py index 1eba672a5..b0e7904e3 100644 --- a/metagpt/actions/write_code_review.py +++ b/metagpt/actions/write_code_review.py @@ -40,7 +40,7 @@ EXAMPLE_AND_INSTRUCTION = """ {format_example} -# Instruction: Based on the actual code situation, follow one of the "Format example". +# Instruction: Based on the actual code situation, follow one of the "Format example". Return only 1 file under review. ## Code Review: Ordered List. Based on the "Code to be Reviewed", provide key, clear, concise, and specific answer. If any answer is no, explain how to fix it step by step. 1. Is the code implemented as per the requirements? If not, how to achieve it? Analyse it step by step. diff --git a/metagpt/roles/role.py b/metagpt/roles/role.py index 91e56bade..d868e820f 100644 --- a/metagpt/roles/role.py +++ b/metagpt/roles/role.py @@ -395,7 +395,7 @@ class Role(BaseModel): self._set_state(next_state) async def _act(self) -> Message: - logger.info(f"{self._setting}: to do {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}({self._rc.todo.name})") response = await self._rc.todo.run(self._rc.important_memory) if isinstance(response, (ActionOutput, ActionNode)): msg = Message( diff --git a/metagpt/roles/searcher.py b/metagpt/roles/searcher.py index da844b4dc..6e2bd8bc9 100644 --- a/metagpt/roles/searcher.py +++ b/metagpt/roles/searcher.py @@ -57,7 +57,7 @@ class Searcher(Role): async def _act_sp(self) -> Message: """Performs the search action in a single process.""" - logger.info(f"{self._setting}: to do {self._rc.todo}") + logger.info(f"{self._setting}: to do {self._rc.todo}({self._rc.todo.name})") response = await self._rc.todo.run(self._rc.memory.get(k=0)) if isinstance(response, (ActionOutput, ActionNode)):