diff --git a/.devcontainer/postCreateCommand.sh b/.devcontainer/postCreateCommand.sh index 06d12e408..46788e306 100644 --- a/.devcontainer/postCreateCommand.sh +++ b/.devcontainer/postCreateCommand.sh @@ -4,4 +4,4 @@ sudo npm install -g @mermaid-js/mermaid-cli # Step 2: Ensure that Python 3.9+ is installed on your system. You can check this by using: python --version -python setup.py install \ No newline at end of file +pip install -e. \ No newline at end of file diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..32555a806 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +*.html linguist-detectable=false + diff --git a/.gitignore b/.gitignore index 6352a90e5..8f074a5ea 100644 --- a/.gitignore +++ b/.gitignore @@ -16,7 +16,8 @@ dist/ downloads/ eggs/ .eggs/ -lib/ +lib/* +!/metagpt/mineflayer_env/mineflayer/lib/* # Mineflayer lib64/ parts/ sdist/ @@ -114,6 +115,7 @@ venv/ ENV/ env.bak/ venv.bak/ +*/ckpt # Spyder project settings .spyderproject @@ -148,8 +150,7 @@ allure-results .DS_Store .vscode - -*.txt +log.txt docs/scripts/set_env.sh key.yaml output.json @@ -164,3 +165,5 @@ workspace/* tmp output.wav metagpt/roles/idea_agent.py +.aider* + diff --git a/Dockerfile b/Dockerfile index 537bbc72e..8ab180e28 100644 --- a/Dockerfile +++ b/Dockerfile @@ -18,7 +18,7 @@ COPY . /app/metagpt WORKDIR /app/metagpt RUN mkdir workspace &&\ pip install --no-cache-dir -r requirements.txt &&\ - python setup.py install + pip install -e. # Running with an infinite loop using the tail command CMD ["sh", "-c", "tail -f /dev/null"] diff --git a/README.md b/README.md index 62f623df8..396742077 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,13 @@ # MetaGPT: The Multi-Agent Framework

Software Company Multi-Role Schematic (Gradually Implementing)

+## MetaGPT's Abilities + + +https://github.com/geekan/MetaGPT/assets/34952977/34345016-5d13-489d-b9f9-b82ace413419 + + + ## Examples (fully generated by GPT-4) For example, if you type `python startup.py "Design a RecSys like Toutiao"`, you would get many outputs, one of them is data & api design @@ -41,6 +48,9 @@ ## Examples (fully generated by GPT-4) It costs approximately **$0.2** (in GPT-4 API fees) to generate one example with analysis and design, and around **$2.0** for a full project. + + + ## Installation ### Installation Video Guide @@ -50,7 +60,7 @@ ### Installation Video Guide ### Traditional Installation ```bash -# Step 1: Ensure that NPM is installed on your system. Then install mermaid-js. +# Step 1: Ensure that NPM is installed on your system. Then install mermaid-js. (If you don't have npm in your computer, please go to the Node.js offical website to install Node.js https://nodejs.org/ and then you will have npm tool in your computer.) npm --version sudo npm install -g @mermaid-js/mermaid-cli @@ -60,7 +70,7 @@ # Step 2: Ensure that Python 3.9+ is installed on your system. You can check thi # Step 3: Clone the repository to your local machine, and install it. git clone https://github.com/geekan/metagpt cd metagpt -python setup.py install +pip install -e. ``` **Note:** @@ -81,7 +91,72 @@ # Step 3: Clone the repository to your local machine, and install it. MMDC: "./node_modules/.bin/mmdc" ``` -- if `python setup.py install` fails with error `[Errno 13] Permission denied: '/usr/local/lib/python3.11/dist-packages/test-easy-install-13129.write-test'`, try instead running `python setup.py install --user` +- if `pip install -e.` fails with error `[Errno 13] Permission denied: '/usr/local/lib/python3.11/dist-packages/test-easy-install-13129.write-test'`, try instead running `pip install -e. --user` + +- To convert Mermaid charts to SVG, PNG, and PDF formats. In addition to the Node.js version of Mermaid-CLI, you now have the option to use Python version Playwright, pyppeteer or mermaid.ink for this task. + + - Playwright + - **Install Playwright** + + ```bash + pip install playwright + ``` + + - **Install the Required Browsers** + + to support PDF conversion, please install Chrominum. + + ```bash + playwright install --with-deps chromium + ``` + + - **modify `config.yaml`** + + uncomment MERMAID_ENGINE from config.yaml and change it to `playwright` + + ```yaml + MERMAID_ENGINE: playwright + ``` + + - pyppeteer + - **Install pyppeteer** + + ```bash + pip install pyppeteer + ``` + + - **Use your own Browsers** + + pyppeteer alow you use installed browsers, please set the following envirment + + ```bash + export PUPPETEER_EXECUTABLE_PATH = /path/to/your/chromium or edge or chrome + ``` + + please do not use this command to install browser, it is too old + + ```bash + pyppeteer-install + ``` + + - **modify `config.yaml`** + + uncomment MERMAID_ENGINE from config.yaml and change it to `pyppeteer` + + ```yaml + MERMAID_ENGINE: pyppeteer + ``` + + - mermaid.ink + - **modify `config.yaml`** + + uncomment MERMAID_ENGINE from config.yaml and change it to `ink` + + ```yaml + MERMAID_ENGINE: ink + ``` + + Note: this method does not support pdf export. ### Installation by Docker diff --git a/Temp.md b/Temp.md new file mode 100644 index 000000000..e5972438c --- /dev/null +++ b/Temp.md @@ -0,0 +1,69 @@ +## MG-MC记录文档 + +### 0926: 环境信息获取和更新 on_event()实际内容 + +1.Nodejs + Mineflayer配置 + + A.自行安装[Node.js (nodejs.org)](https://nodejs.org/en) + + B.clone完之后,必须重新继续Mineflayer配置 + + ```bash + cd metagpt/mineflayer_env/mineflayer + npm install -g npx + npm install + cd mineflayer-collectblock + npm install + npx tsc + cd .. + npm install + ``` + +2.在mg环境上额外执行 + +```python +pip install -r mc_requirement.txt +``` + +3.配置完游戏后,在 minecraft_run.py 下修改 + +```python + mc_player.set_port(2465) # Modify this to your LAN port +``` + +python minecraft_run.py + + + + + +### 0927:Action_developer 更新 + +对应需实现 GenerateActionCode ,完成对应的和 GameEnvironment 的交 +互和 Environment 的信息传递 + +测试结果 + +![action_developer](docs/resources/workspace/minecraft_tests/action_developer.png) + + + +### 0930:Curriculum agent 更新 + +对应需实现 DesignTask和DesignCurriculum,以及与Environment 的信息传递。 + + + +**BUG FIX(0930):** + +A.在前面的提交中,由于ignore了mineflayer下的lib,会造成如下报错 + +```bash +metagpt.minecraft_team:on_event:143 - Failed to retrieve Minecraft events: HTTPConnectionPool(host='127.0.0.1', port=3000): Max retries exceeded with url: /start (Caused by NewConnectionError(': Failed to establish a new connection: [Errno 111] Connection refused')) +``` + +解决方法: + +1. 若本地已克隆项目不好更改可尝试:删除 metagpt/mineflayer_env/mineflayer + 重新copy voyager/env/mineflayer到目录下 + (npm install...0926.B命令) +2. 重新拉取最新提交+重新配置 + diff --git a/config/config.yaml b/config/config.yaml index 4519288d3..2f16efa1b 100644 --- a/config/config.yaml +++ b/config/config.yaml @@ -5,9 +5,9 @@ ## The official OPENAI_API_BASE is https://api.openai.com/v1 ## If the official OPENAI_API_BASE is not available, we recommend using the [openai-forward](https://github.com/beidongjiedeguang/openai-forward). ## Or, you can configure OPENAI_PROXY to access official OPENAI_API_BASE. -OPENAI_API_BASE: "https://api.openai.com/v1" +OPENAI_API_BASE: "https://openai-forward.metadl.com/v1" #OPENAI_PROXY: "http://127.0.0.1:8118" -#OPENAI_API_KEY: "YOUR_API_KEY" +OPENAI_API_KEY: "YOUR_API_KEY" OPENAI_API_MODEL: "gpt-4" MAX_TOKENS: 1500 RPM: 10 @@ -76,3 +76,12 @@ SD_T2I_API: "/sdapi/v1/txt2img" ### for Research MODEL_FOR_RESEARCHER_SUMMARY: gpt-3.5-turbo MODEL_FOR_RESEARCHER_REPORT: gpt-3.5-turbo-16k + +### choose the engine for mermaid conversion, +# default is nodejs, you can change it to playwright,pyppeteer or ink +# MERMAID_ENGINE: nodejs + +### browser path for pyppeteer engine, support Chrome, Chromium,MS Edge +#PYPPETEER_EXECUTABLE_PATH: "/usr/bin/google-chrome-stable" + +PROMPT_FORMAT: json #json or markdown \ No newline at end of file diff --git a/docs/FAQ-EN.md b/docs/FAQ-EN.md index b5ae9184b..4c86ed150 100644 --- a/docs/FAQ-EN.md +++ b/docs/FAQ-EN.md @@ -17,14 +17,15 @@ 1. EN 1. Demo Video: [MetaGPT: Multi-Agent AI Programming Framework](https://www.youtube.com/watch?v=8RNzxZBTW8M) - 1. Tutorial: [MetaGPT: Deploy POWERFUL Autonomous Ai Agents BETTER Than SUPERAGI!](https://www.youtube.com/watch?v=q16Gi9pTG_M&t=659s) + 2. Tutorial: [MetaGPT: Deploy POWERFUL Autonomous Ai Agents BETTER Than SUPERAGI!](https://www.youtube.com/watch?v=q16Gi9pTG_M&t=659s) + 3. Author's thoughts video(EN): [MetaGPT Matthew Berman](https://youtu.be/uT75J_KG_aY?si=EgbfQNAwD8F5Y1Ak) 1. CN 1. Demo Video: [MetaGPT:一行代码搭建你的虚拟公司_哔哩哔哩_bilibili](https://www.bilibili.com/video/BV1NP411C7GW/?spm_id_from=333.999.0.0&vd_source=735773c218b47da1b4bd1b98a33c5c77) 1. Tutorial: [一个提示词写游戏 Flappy bird, 比AutoGPT强10倍的MetaGPT,最接近AGI的AI项目](https://youtu.be/Bp95b8yIH5c) - 1. Author's thoughts video(CN): [MetaGPT作者深度解析直播回放_哔哩哔哩_bilibili](https://www.bilibili.com/video/BV1Ru411V7XL/?spm_id_from=333.337.search-card.all.click) - + 2. Author's thoughts video(CN): [MetaGPT作者深度解析直播回放_哔哩哔哩_bilibili](https://www.bilibili.com/video/BV1Ru411V7XL/?spm_id_from=333.337.search-card.all.click) + 3. ### How to become a contributor? @@ -153,6 +154,7 @@ 1. Youtube(CN):[一个提示词写游戏 Flappy bird, 比AutoGPT强10倍的MetaGPT,最接近AGI的AI项目=一个软件公司产品经理+程序员](https://youtu.be/Bp95b8yIH5c) 1. Youtube(EN)https://www.youtube.com/watch?v=q16Gi9pTG_M&t=659s + 2. video(EN): [MetaGPT Matthew Berman](https://youtu.be/uT75J_KG_aY?si=EgbfQNAwD8F5Y1Ak) 1. openai.error.RateLimitError: You exceeded your current quota, please check your plan and billing details @@ -161,7 +163,7 @@ 1. What does "borg" mean in n_borg? - 1. https://en.wikipedia.org/wiki/Borg + 1. [Wikipedia borg meaning ](https://en.wikipedia.org/wiki/Borg) 1. The Borg civilization operates based on a hive or collective mentality, known as "the Collective." Every Borg individual is connected to the collective via a sophisticated subspace network, ensuring continuous oversight and guidance for every member. This collective consciousness allows them to not only "share the same thoughts" but also to adapt swiftly to new strategies. While individual members of the collective rarely communicate, the collective "voice" sometimes transmits aboard ships. 1. How to use the Claude API? diff --git a/docs/README_CN.md b/docs/README_CN.md index 4ee4c7408..1372bf9f4 100644 --- a/docs/README_CN.md +++ b/docs/README_CN.md @@ -33,6 +33,11 @@ # MetaGPT: 多智能体框架

软件公司多角色示意图(正在逐步实现)

+## MetaGPT 的能力 + +https://github.com/geekan/MetaGPT/assets/34952977/34345016-5d13-489d-b9f9-b82ace413419 + + ## 示例(均由 GPT-4 生成) 例如,键入`python startup.py "写个类似今日头条的推荐系统"`并回车,你会获得一系列输出,其一是数据结构与API设计 @@ -56,7 +61,7 @@ # 第 2 步:确保您的系统上安装了 Python 3.9+。您可以使用以下 # 第 3 步:克隆仓库到您的本地机器,并进行安装。 git clone https://github.com/geekan/metagpt cd metagpt -python setup.py install +pip install -e. ``` **注意:** @@ -76,7 +81,7 @@ # 第 3 步:克隆仓库到您的本地机器,并进行安装。 MMDC: "./node_modules/.bin/mmdc" ``` -- 如果`python setup.py install`失败并显示错误`[Errno 13] Permission denied: '/usr/local/lib/python3.11/dist-packages/test-easy-install-13129.write-test'`,请尝试使用`python setup.py install --user`运行。 +- 如果`pip install -e.`失败并显示错误`[Errno 13] Permission denied: '/usr/local/lib/python3.11/dist-packages/test-easy-install-13129.write-test'`,请尝试使用`pip install -e. --user`运行。 ### Docker安装 @@ -131,10 +136,10 @@ # 复制配置文件并进行必要的修改 cp config/config.yaml config/key.yaml ``` -| 变量名 | config/key.yaml | env | -|--------------------------------------------|-------------------------------------------|--------------------------------| -| OPENAI_API_KEY # 用您自己的密钥替换 | OPENAI_API_KEY: "sk-..." | export OPENAI_API_KEY="sk-..." | -| OPENAI_API_BASE # 可选 | OPENAI_API_BASE: "https:///v1" | export OPENAI_API_BASE="https:///v1" | +| 变量名 | config/key.yaml | env | +| ----------------------------------- | ----------------------------------------- | ----------------------------------------------- | +| OPENAI_API_KEY # 用您自己的密钥替换 | OPENAI_API_KEY: "sk-..." | export OPENAI_API_KEY="sk-..." | +| OPENAI_API_BASE # 可选 | OPENAI_API_BASE: "https:///v1" | export OPENAI_API_BASE="https:///v1" | ## 示例:启动一个创业公司 diff --git a/docs/README_JA.md b/docs/README_JA.md index 158ad8ceb..8d6c2fe84 100644 --- a/docs/README_JA.md +++ b/docs/README_JA.md @@ -33,6 +33,11 @@ # MetaGPT: マルチエージェントフレームワーク

ソフトウェア会社のマルチロール図式(順次導入)

+## MetaGPTの能力 + +https://github.com/geekan/MetaGPT/assets/34952977/34345016-5d13-489d-b9f9-b82ace413419 + + ## 例(GPT-4 で完全生成) 例えば、`python startup.py "Toutiao のような RecSys をデザインする"`と入力すると、多くの出力が得られます @@ -43,6 +48,10 @@ ## 例(GPT-4 で完全生成) ## インストール +### インストールビデオガイド + +- [Matthew Berman: How To Install MetaGPT - Build A Startup With One Prompt!!](https://youtu.be/uT75J_KG_aY) + ### 伝統的なインストール ```bash @@ -56,7 +65,7 @@ # ステップ 2: Python 3.9+ がシステムにインストールされてい # ステップ 3: リポジトリをローカルマシンにクローンし、インストールする。 git clone https://github.com/geekan/metagpt cd metagpt -python setup.py install +pip install -e. ``` **注:** @@ -66,18 +75,18 @@ # ステップ 3: リポジトリをローカルマシンにクローンし、 - このツールをグローバルにインストールする[問題を抱えている](https://github.com/mermaidjs/mermaid.cli/issues/15)人もいます。ローカルにインストールするのが代替の解決策です、 - ```bash - npm install @mermaid-js/mermaid-cli - ``` + ```bash + npm install @mermaid-js/mermaid-cli + ``` - config.yml に mmdc のコンフィギュレーションを記述するのを忘れないこと - ```yml - PUPPETEER_CONFIG: "./config/puppeteer-config.json" - MMDC: "./node_modules/.bin/mmdc" - ``` + ```yml + PUPPETEER_CONFIG: "./config/puppeteer-config.json" + MMDC: "./node_modules/.bin/mmdc" + ``` -- もし `python setup.py install` がエラー `[Errno 13] Permission denied: '/usr/local/lib/python3.11/dist-packages/test-easy-install-13129.write-test'` で失敗したら、代わりに `python setup.py install --user` を実行してみてください +- もし `pip install -e.` がエラー `[Errno 13] Permission denied: '/usr/local/lib/python3.11/dist-packages/test-easy-install-13129.write-test'` で失敗したら、代わりに `pip install -e. --user` を実行してみてください ### Docker によるインストール @@ -132,26 +141,32 @@ # 設定ファイルをコピーし、必要な修正を加える。 cp config/config.yaml config/key.yaml ``` -| 変数名 | config/key.yaml | env | -| ------------------------------------------ | ----------------------------------------- | ----------------------------------------------- | -| OPENAI_API_KEY # 自分のキーに置き換える | OPENAI_API_KEY: "sk-..." | export OPENAI_API_KEY="sk-..." | -| OPENAI_API_BASE # オプション | OPENAI_API_BASE: "https:///v1" | export OPENAI_API_BASE="https:///v1" | +| 変数名 | config/key.yaml | env | +| --------------------------------------- | ----------------------------------------- | ----------------------------------------------- | +| OPENAI_API_KEY # 自分のキーに置き換える | OPENAI_API_KEY: "sk-..." | export OPENAI_API_KEY="sk-..." | +| OPENAI_API_BASE # オプション | OPENAI_API_BASE: "https:///v1" | export OPENAI_API_BASE="https:///v1" | ## チュートリアル: スタートアップの開始 ```shell +# スクリプトの実行 python startup.py "Write a cli snake game" -# コードレビューを利用すれば、コストはかかるが、より良いコード品質を選ぶことができます。 +# プロジェクトの実施にエンジニアを雇わないこと +python startup.py "Write a cli snake game" --implement False +# エンジニアを雇い、コードレビューを行う python startup.py "Write a cli snake game" --code_review True ``` スクリプトを実行すると、`workspace/` ディレクトリに新しいプロジェクトが見つかります。 + ### プラットフォームまたはツールの設定 要件を述べるときに、どのプラットフォームまたはツールを使用するかを指定できます。 + ```shell python startup.py "pygame をベースとした cli ヘビゲームを書く" ``` + ### 使用方法 ``` @@ -200,16 +215,18 @@ ### コードウォークスルー `examples` でシングル・ロール(ナレッジ・ベース付き)と LLM のみの例を詳しく見ることができます。 ## クイックスタート + ローカル環境のインストールや設定は、ユーザーによっては難しいものです。以下のチュートリアルで MetaGPT の魅力をすぐに体験できます。 - [MetaGPT クイックスタート](https://deepwisdom.feishu.cn/wiki/CyY9wdJc4iNqArku3Lncl4v8n2b) -試着する Huggingface Space +Hugging Face Space で試す - https://huggingface.co/spaces/deepwisdom/MetaGPT ## 引用 現時点では、[Arxiv 論文](https://arxiv.org/abs/2308.00352)を引用してください: + ```bibtex @misc{hong2023metagpt, title={MetaGPT: Meta Programming for Multi-Agent Collaborative Framework}, @@ -233,3 +250,10 @@ ## お問い合わせ先 ## デモ https://github.com/geekan/MetaGPT/assets/2707039/5e8c1062-8c35-440f-bb20-2b0320f8d27d + +## 参加する + +📢 Discord チャンネルに参加してください! +https://discord.gg/ZRHeExS6xv + +お会いできることを楽しみにしています! 🎉 diff --git a/docs/resources/workspace/minecraft_tests/action_developer.png b/docs/resources/workspace/minecraft_tests/action_developer.png new file mode 100644 index 000000000..397b4bdee Binary files /dev/null and b/docs/resources/workspace/minecraft_tests/action_developer.png differ diff --git a/docs/resources/workspace/minecraft_tests/on_event.jpeg b/docs/resources/workspace/minecraft_tests/on_event.jpeg new file mode 100644 index 000000000..56d80bd16 Binary files /dev/null and b/docs/resources/workspace/minecraft_tests/on_event.jpeg differ diff --git a/examples/agent_creator.py b/examples/agent_creator.py new file mode 100644 index 000000000..e03a88c6b --- /dev/null +++ b/examples/agent_creator.py @@ -0,0 +1,100 @@ +''' +Filename: MetaGPT/examples/agent_creator.py +Created Date: Tuesday, September 12th 2023, 3:28:37 pm +Author: garylin2099 +''' +import re + +from metagpt.const import PROJECT_ROOT, WORKSPACE_ROOT +from metagpt.actions import Action +from metagpt.roles import Role +from metagpt.schema import Message +from metagpt.logs import logger + +with open(PROJECT_ROOT / "examples/build_customized_agent.py", "r") as f: + # use official example script to guide AgentCreator + MULTI_ACTION_AGENT_CODE_EXAMPLE = f.read() + +class CreateAgent(Action): + + PROMPT_TEMPLATE = """ + ### BACKGROUND + You are using an agent framework called metagpt to write agents capable of different actions, + the usage of metagpt can be illustrated by the following example: + ### EXAMPLE STARTS AT THIS LINE + {example} + ### EXAMPLE ENDS AT THIS LINE + ### TASK + Now you should create an agent with appropriate actions based on the instruction, consider carefully about + the PROMPT_TEMPLATE of all actions and when to call self._aask() + ### INSTRUCTION + {instruction} + ### YOUR CODE + Return ```python your_code_here ``` with NO other texts, your code: + """ + + async def run(self, example: str, instruction: str): + + prompt = self.PROMPT_TEMPLATE.format(example=example, instruction=instruction) + # logger.info(prompt) + + rsp = await self._aask(prompt) + + code_text = CreateAgent.parse_code(rsp) + + return code_text + + @staticmethod + def parse_code(rsp): + pattern = r'```python(.*)```' + match = re.search(pattern, rsp, re.DOTALL) + code_text = match.group(1) if match else "" + with open(WORKSPACE_ROOT / "agent_created_agent.py", "w") as f: + f.write(code_text) + return code_text + +class AgentCreator(Role): + def __init__( + self, + name: str = "Matrix", + profile: str = "AgentCreator", + agent_template: str = MULTI_ACTION_AGENT_CODE_EXAMPLE, + **kwargs, + ): + super().__init__(name, profile, **kwargs) + self._init_actions([CreateAgent]) + self.agent_template = agent_template + + async def _act(self) -> Message: + logger.info(f"{self._setting}: ready to {self._rc.todo}") + todo = self._rc.todo + msg = self._rc.memory.get()[-1] + + instruction = msg.content + code_text = await CreateAgent().run(example=self.agent_template, instruction=instruction) + msg = Message(content=code_text, role=self.profile, cause_by=todo) + + return msg + +if __name__ == "__main__": + import asyncio + + async def main(): + + agent_template = MULTI_ACTION_AGENT_CODE_EXAMPLE + + creator = AgentCreator(agent_template=agent_template) + + # msg = """Write an agent called SimpleTester that will take any code snippet (str) + # and return a testing code (str) for testing + # the given code snippet. Use pytest as the testing framework.""" + + msg = """ + Write an agent called SimpleTester that will take any code snippet (str) and do the following: + 1. write a testing code (str) for testing the given code snippet, save the testing code as a .py file in the current working diretory; + 2. run the testing code. + You can use pytest as the testing framework. + """ + await creator.run(msg) + + asyncio.run(main()) diff --git a/examples/build_customized_agent.py b/examples/build_customized_agent.py new file mode 100644 index 000000000..87d7a9c76 --- /dev/null +++ b/examples/build_customized_agent.py @@ -0,0 +1,139 @@ +''' +Filename: MetaGPT/examples/build_customized_agent.py +Created Date: Tuesday, September 19th 2023, 6:52:25 pm +Author: garylin2099 +''' +import re +import subprocess +import asyncio + +import fire + +from metagpt.actions import Action +from metagpt.roles import Role +from metagpt.schema import Message +from metagpt.logs import logger + +class SimpleWriteCode(Action): + + PROMPT_TEMPLATE = """ + Write a python function that can {instruction} and provide two runnnable test cases. + Return ```python your_code_here ``` with NO other texts, + example: + ```python + # function + def add(a, b): + return a + b + # test cases + print(add(1, 2)) + print(add(3, 4)) + ``` + your code: + """ + + def __init__(self, name="SimpleWriteCode", context=None, llm=None): + super().__init__(name, context, llm) + + async def run(self, instruction: str): + + prompt = self.PROMPT_TEMPLATE.format(instruction=instruction) + + rsp = await self._aask(prompt) + + code_text = SimpleWriteCode.parse_code(rsp) + + return code_text + + @staticmethod + def parse_code(rsp): + pattern = r'```python(.*)```' + match = re.search(pattern, rsp, re.DOTALL) + code_text = match.group(1) if match else rsp + return code_text + +class SimpleRunCode(Action): + def __init__(self, name="SimpleRunCode", context=None, llm=None): + super().__init__(name, context, llm) + + async def run(self, code_text: str): + result = subprocess.run(["python3", "-c", code_text], capture_output=True, text=True) + code_result = result.stdout + logger.info(f"{code_result=}") + return code_result + +class SimpleCoder(Role): + def __init__( + self, + name: str = "Alice", + profile: str = "SimpleCoder", + **kwargs, + ): + super().__init__(name, profile, **kwargs) + self._init_actions([SimpleWriteCode]) + + async def _act(self) -> Message: + logger.info(f"{self._setting}: ready to {self._rc.todo}") + todo = self._rc.todo + + msg = self._rc.memory.get()[-1] # retrieve the latest memory + instruction = msg.content + + code_text = await SimpleWriteCode().run(instruction) + msg = Message(content=code_text, role=self.profile, cause_by=todo) + + return msg + +class RunnableCoder(Role): + def __init__( + self, + name: str = "Alice", + profile: str = "RunnableCoder", + **kwargs, + ): + super().__init__(name, profile, **kwargs) + self._init_actions([SimpleWriteCode, SimpleRunCode]) + + async def _think(self) -> None: + if self._rc.todo is None: + self._set_state(0) + return + + if self._rc.state + 1 < len(self._states): + self._set_state(self._rc.state + 1) + else: + self._rc.todo = None + + async def _act(self) -> Message: + logger.info(f"{self._setting}: ready to {self._rc.todo}") + todo = self._rc.todo + msg = self._rc.memory.get()[-1] + + if isinstance(todo, SimpleWriteCode): + instruction = msg.content + result = await SimpleWriteCode().run(instruction) + + elif isinstance(todo, SimpleRunCode): + code_text = msg.content + result = await SimpleRunCode().run(code_text) + + msg = Message(content=result, role=self.profile, cause_by=todo) + self._rc.memory.add(msg) + return msg + + async def _react(self) -> Message: + while True: + await self._think() + if self._rc.todo is None: + break + await self._act() + return Message(content="All job done", role=self.profile) + +def main(msg="write a function that calculates the sum of a list"): + # role = SimpleCoder() + role = RunnableCoder() + logger.info(msg) + result = asyncio.run(role.run(msg)) + logger.info(result) + +if __name__ == '__main__': + fire.Fire(main) diff --git a/examples/debate.py b/examples/debate.py new file mode 100644 index 000000000..05db28070 --- /dev/null +++ b/examples/debate.py @@ -0,0 +1,148 @@ +''' +Filename: MetaGPT/examples/debate.py +Created Date: Tuesday, September 19th 2023, 6:52:25 pm +Author: garylin2099 +''' +import asyncio +import platform +import fire + +from metagpt.software_company import SoftwareCompany +from metagpt.actions import Action, BossRequirement +from metagpt.roles import Role +from metagpt.schema import Message +from metagpt.logs import logger + +class ShoutOut(Action): + """Action: Shout out loudly in a debate (quarrel)""" + + PROMPT_TEMPLATE = """ + ## BACKGROUND + Suppose you are {name}, you are in a debate with {opponent_name}. + ## DEBATE HISTORY + Previous rounds: + {context} + ## YOUR TURN + Now it's your turn, you should closely respond to your opponent's latest argument, state your position, defend your arguments, and attack your opponent's arguments, + craft a strong and emotional response in 80 words, in {name}'s rhetoric and viewpoints, your will argue: + """ + + def __init__(self, name="ShoutOut", context=None, llm=None): + super().__init__(name, context, llm) + + async def run(self, context: str, name: str, opponent_name: str): + + prompt = self.PROMPT_TEMPLATE.format(context=context, name=name, opponent_name=opponent_name) + # logger.info(prompt) + + rsp = await self._aask(prompt) + + return rsp + +class Trump(Role): + def __init__( + self, + name: str = "Trump", + profile: str = "Republican", + **kwargs, + ): + super().__init__(name, profile, **kwargs) + self._init_actions([ShoutOut]) + self._watch([ShoutOut]) + self.name = "Trump" + self.opponent_name = "Biden" + + async def _observe(self) -> int: + await super()._observe() + # accept messages sent (from opponent) to self, disregard own messages from the last round + self._rc.news = [msg for msg in self._rc.news if msg.send_to == self.name] + return len(self._rc.news) + + async def _act(self) -> Message: + logger.info(f"{self._setting}: ready to {self._rc.todo}") + + msg_history = self._rc.memory.get_by_actions([ShoutOut]) + context = [] + for m in msg_history: + context.append(str(m)) + context = "\n".join(context) + + rsp = await ShoutOut().run(context=context, name=self.name, opponent_name=self.opponent_name) + + msg = Message( + content=rsp, + role=self.profile, + cause_by=ShoutOut, + sent_from=self.name, + send_to=self.opponent_name, + ) + + return msg + +class Biden(Role): + def __init__( + self, + name: str = "Biden", + profile: str = "Democrat", + **kwargs, + ): + super().__init__(name, profile, **kwargs) + self._init_actions([ShoutOut]) + self._watch([BossRequirement, ShoutOut]) + self.name = "Biden" + self.opponent_name = "Trump" + + async def _observe(self) -> int: + await super()._observe() + # accept the very first human instruction (the debate topic) or messages sent (from opponent) to self, + # disregard own messages from the last round + self._rc.news = [msg for msg in self._rc.news if msg.cause_by == BossRequirement or msg.send_to == self.name] + return len(self._rc.news) + + async def _act(self) -> Message: + logger.info(f"{self._setting}: ready to {self._rc.todo}") + + msg_history = self._rc.memory.get_by_actions([BossRequirement, ShoutOut]) + context = [] + for m in msg_history: + context.append(str(m)) + context = "\n".join(context) + + rsp = await ShoutOut().run(context=context, name=self.name, opponent_name=self.opponent_name) + + msg = Message( + content=rsp, + role=self.profile, + cause_by=ShoutOut, + sent_from=self.name, + send_to=self.opponent_name, + ) + + return msg + +async def startup(idea: str, investment: float = 3.0, n_round: int = 5, + code_review: bool = False, run_tests: bool = False): + """We reuse the startup paradigm for roles to interact with each other. + Now we run a startup of presidents and watch they quarrel. :) """ + company = SoftwareCompany() + company.hire([Biden(), Trump()]) + company.invest(investment) + company.start_project(idea) + await company.run(n_round=n_round) + + +def main(idea: str, investment: float = 3.0, n_round: int = 10): + """ + :param idea: Debate topic, such as "Topic: The U.S. should commit more in climate change fighting" + or "Trump: Climate change is a hoax" + :param investment: contribute a certain dollar amount to watch the debate + :param n_round: maximum rounds of the debate + :return: + """ + if platform.system() == "Windows": + asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) + asyncio.run(startup(idea, investment, n_round)) + + +if __name__ == '__main__': + fire.Fire(main) diff --git a/examples/sk_agent.py b/examples/sk_agent.py new file mode 100644 index 000000000..a7513e838 --- /dev/null +++ b/examples/sk_agent.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/13 12:36 +@Author : femto Zheng +@File : sk_agent.py +""" +import asyncio + +from semantic_kernel.core_skills import FileIOSkill, MathSkill, TextSkill, TimeSkill +from semantic_kernel.planning import SequentialPlanner + +# from semantic_kernel.planning import SequentialPlanner +from semantic_kernel.planning.action_planner.action_planner import ActionPlanner + +from metagpt.actions import BossRequirement +from metagpt.const import SKILL_DIRECTORY +from metagpt.roles.sk_agent import SkAgent +from metagpt.schema import Message +from metagpt.tools.search_engine import SkSearchEngine + + +async def main(): + # await basic_planner_example() + # await action_planner_example() + + # await sequential_planner_example() + await basic_planner_web_search_example() + + +async def basic_planner_example(): + task = """ + Tomorrow is Valentine's day. I need to come up with a few date ideas. She speaks French so write it in French. + Convert the text to uppercase""" + role = SkAgent() + + # let's give the agent some skills + role.import_semantic_skill_from_directory(SKILL_DIRECTORY, "SummarizeSkill") + role.import_semantic_skill_from_directory(SKILL_DIRECTORY, "WriterSkill") + role.import_skill(TextSkill(), "TextSkill") + # using BasicPlanner + await role.run(Message(content=task, cause_by=BossRequirement)) + + +async def sequential_planner_example(): + task = """ + Tomorrow is Valentine's day. I need to come up with a few date ideas. She speaks French so write it in French. + Convert the text to uppercase""" + role = SkAgent(planner_cls=SequentialPlanner) + + # let's give the agent some skills + role.import_semantic_skill_from_directory(SKILL_DIRECTORY, "SummarizeSkill") + role.import_semantic_skill_from_directory(SKILL_DIRECTORY, "WriterSkill") + role.import_skill(TextSkill(), "TextSkill") + # using BasicPlanner + await role.run(Message(content=task, cause_by=BossRequirement)) + + +async def basic_planner_web_search_example(): + task = """ + Question: Who made the 1989 comic book, the film version of which Jon Raymond Polito appeared in?""" + role = SkAgent() + + role.import_skill(SkSearchEngine(), "WebSearchSkill") + # role.import_semantic_skill_from_directory(skills_directory, "QASkill") + + await role.run(Message(content=task, cause_by=BossRequirement)) + + +async def action_planner_example(): + role = SkAgent(planner_cls=ActionPlanner) + # let's give the agent 4 skills + role.import_skill(MathSkill(), "math") + role.import_skill(FileIOSkill(), "fileIO") + role.import_skill(TimeSkill(), "time") + role.import_skill(TextSkill(), "text") + task = "What is the sum of 110 and 990?" + await role.run(Message(content=task, cause_by=BossRequirement)) # it will choose mathskill.Add + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/examples/use_off_the_shelf_agent.py b/examples/use_off_the_shelf_agent.py new file mode 100644 index 000000000..2e10068bd --- /dev/null +++ b/examples/use_off_the_shelf_agent.py @@ -0,0 +1,18 @@ +''' +Filename: MetaGPT/examples/use_off_the_shelf_agent.py +Created Date: Tuesday, September 19th 2023, 6:52:25 pm +Author: garylin2099 +''' +import asyncio + +from metagpt.roles.product_manager import ProductManager +from metagpt.logs import logger + +async def main(): + msg = "Write a PRD for a snake game" + role = ProductManager() + result = await role.run(msg) + logger.info(result.content[:100]) + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/examples/write_tutorial.py b/examples/write_tutorial.py new file mode 100644 index 000000000..71ece5527 --- /dev/null +++ b/examples/write_tutorial.py @@ -0,0 +1,21 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/4 21:40:57 +@Author : Stitch-z +@File : tutorial_assistant.py +""" +import asyncio + +from metagpt.roles.tutorial_assistant import TutorialAssistant + + +async def main(): + topic = "Write a tutorial about MySQL" + role = TutorialAssistant(language="Chinese") + await role.run(topic) + + +if __name__ == '__main__': + asyncio.run(main()) + diff --git a/mc_requirements.txt b/mc_requirements.txt new file mode 100644 index 000000000..0b391b793 --- /dev/null +++ b/mc_requirements.txt @@ -0,0 +1,4 @@ +javascript +requests +psutil +chromadb==0.3.29 \ No newline at end of file diff --git a/metagpt/__init__.py b/metagpt/__init__.py index b9c530d24..71ddd1aff 100644 --- a/metagpt/__init__.py +++ b/metagpt/__init__.py @@ -1,5 +1,7 @@ -#!/usr/bin/env python +#!/usr/bin/env python # -*- coding: utf-8 -*- # @Time : 2023/4/24 22:26 # @Author : alexanderwu # @File : __init__.py + +from metagpt import _compat as _ # noqa: F401 diff --git a/metagpt/_compat.py b/metagpt/_compat.py new file mode 100644 index 000000000..91bc1e5a1 --- /dev/null +++ b/metagpt/_compat.py @@ -0,0 +1,20 @@ +import platform +import sys +import warnings + +if sys.implementation.name == "cpython" and platform.system() == "Windows" and sys.version_info[:2] == (3, 9): + import asyncio + from asyncio.proactor_events import _ProactorBasePipeTransport + + from semantic_kernel.orchestration import sk_function as _ # noqa: F401 + + # https://github.com/python/cpython/pull/92842 + def pacth_del(self, _warn=warnings.warn): + if self._sock is not None: + _warn(f"unclosed transport {self!r}", ResourceWarning, source=self) + self._sock.close() + + _ProactorBasePipeTransport.__del__ = pacth_del + + # caused by https://github.com/microsoft/semantic-kernel/pull/1416 + asyncio.set_event_loop_policy(asyncio.WindowsProactorEventLoopPolicy()) diff --git a/metagpt/actions/__init__.py b/metagpt/actions/__init__.py index b004bd58e..ce5d96edf 100644 --- a/metagpt/actions/__init__.py +++ b/metagpt/actions/__init__.py @@ -10,41 +10,32 @@ from enum import Enum from metagpt.actions.action import Action from metagpt.actions.action_output import ActionOutput from metagpt.actions.add_requirement import BossRequirement +''' from metagpt.actions.debug_error import DebugError from metagpt.actions.design_api import WriteDesign -from metagpt.actions.design_api_review import DesignReview -from metagpt.actions.design_filenames import DesignFilenames from metagpt.actions.project_management import AssignTasks, WriteTasks -from metagpt.actions.research import CollectLinks, WebBrowseAndSummarize, ConductResearch from metagpt.actions.run_code import RunCode -from metagpt.actions.search_and_summarize import SearchAndSummarize +#from metagpt.actions.search_and_summarize import SearchAndSummarize from metagpt.actions.write_code import WriteCode from metagpt.actions.write_code_review import WriteCodeReview from metagpt.actions.write_prd import WritePRD -from metagpt.actions.write_prd_review import WritePRDReview from metagpt.actions.write_test import WriteTest - +''' class ActionType(Enum): """All types of Actions, used for indexing.""" ADD_REQUIREMENT = BossRequirement - WRITE_PRD = WritePRD - WRITE_PRD_REVIEW = WritePRDReview - WRITE_DESIGN = WriteDesign - DESIGN_REVIEW = DesignReview - DESIGN_FILENAMES = DesignFilenames - WRTIE_CODE = WriteCode - WRITE_CODE_REVIEW = WriteCodeReview - WRITE_TEST = WriteTest - RUN_CODE = RunCode - DEBUG_ERROR = DebugError - WRITE_TASKS = WriteTasks - ASSIGN_TASKS = AssignTasks - SEARCH_AND_SUMMARIZE = SearchAndSummarize - COLLECT_LINKS = CollectLinks - WEB_BROWSE_AND_SUMMARIZE = WebBrowseAndSummarize - CONDUCT_RESEARCH = ConductResearch + #WRITE_PRD = WritePRD + #WRITE_DESIGN = WriteDesign + #WRTIE_CODE = WriteCode + #WRITE_CODE_REVIEW = WriteCodeReview + #WRITE_TEST = WriteTest + #RUN_CODE = RunCode + #DEBUG_ERROR = DebugError + #WRITE_TASKS = WriteTasks + #ASSIGN_TASKS = AssignTasks + # SEARCH_AND_SUMMARIZE = SearchAndSummarize __all__ = [ diff --git a/metagpt/actions/action.py b/metagpt/actions/action.py index edd21d320..790295d55 100644 --- a/metagpt/actions/action.py +++ b/metagpt/actions/action.py @@ -5,6 +5,7 @@ @Author : alexanderwu @File : action.py """ +import re from abc import ABC from typing import Optional @@ -12,11 +13,13 @@ from tenacity import retry, stop_after_attempt, wait_fixed from metagpt.actions.action_output import ActionOutput from metagpt.llm import LLM -from metagpt.utils.common import OutputParser from metagpt.logs import logger +from metagpt.utils.common import OutputParser +from metagpt.utils.custom_decoder import CustomDecoder + class Action(ABC): - def __init__(self, name: str = '', context=None, llm: LLM = None): + def __init__(self, name: str = "", context=None, llm: LLM = None): self.name: str = name if llm is None: llm = LLM() @@ -46,10 +49,15 @@ class Action(ABC): system_msgs.append(self.prefix) return await self.llm.aask(prompt, system_msgs) - @retry(stop=stop_after_attempt(2), wait=wait_fixed(1)) - async def _aask_v1(self, prompt: str, output_class_name: str, - output_data_mapping: dict, - system_msgs: Optional[list[str]] = None) -> ActionOutput: + @retry(stop=stop_after_attempt(3), wait=wait_fixed(1)) + async def _aask_v1( + self, + prompt: str, + output_class_name: str, + output_data_mapping: dict, + system_msgs: Optional[list[str]] = None, + format="markdown", # compatible to original format + ) -> ActionOutput: """Append default prefix""" if not system_msgs: system_msgs = [] @@ -57,7 +65,21 @@ class Action(ABC): content = await self.llm.aask(prompt, system_msgs) logger.debug(content) output_class = ActionOutput.create_model_class(output_class_name, output_data_mapping) - parsed_data = OutputParser.parse_data_with_mapping(content, output_data_mapping) + + if format == "json": + pattern = r"\[CONTENT\](\s*\{.*?\}\s*)\[/CONTENT\]" + matches = re.findall(pattern, content, re.DOTALL) + + for match in matches: + if match: + content = match + break + + parsed_data = CustomDecoder(strict=False).decode(content) + + else: # using markdown parser + parsed_data = OutputParser.parse_data_with_mapping(content, output_data_mapping) + logger.debug(parsed_data) instruct_content = output_class(**parsed_data) return ActionOutput(content, instruct_content) @@ -65,4 +87,3 @@ class Action(ABC): async def run(self, *args, **kwargs): """Run action""" raise NotImplementedError("The run method should be implemented in a subclass.") - \ No newline at end of file diff --git a/metagpt/actions/analyze_dep_libs.py b/metagpt/actions/analyze_dep_libs.py deleted file mode 100644 index 53d40200a..000000000 --- a/metagpt/actions/analyze_dep_libs.py +++ /dev/null @@ -1,37 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/19 12:01 -@Author : alexanderwu -@File : analyze_dep_libs.py -""" - -from metagpt.actions import Action - -PROMPT = """You are an AI developer, trying to write a program that generates code for users based on their intentions. - -For the user's prompt: - ---- -The API is: {prompt} ---- - -We decide the generated files are: {filepaths_string} - -Now that we have a file list, we need to understand the shared dependencies they have. -Please list and briefly describe the shared contents between the files we are generating, including exported variables, -data patterns, id names of all DOM elements that javascript functions will use, message names and function names. -Focus only on the names of shared dependencies, do not add any other explanations. -""" - - -class AnalyzeDepLibs(Action): - def __init__(self, name, context=None, llm=None): - super().__init__(name, context, llm) - self.desc = "Analyze the runtime dependencies of the program based on the context" - - async def run(self, requirement, filepaths_string): - # prompt = f"Below is the product requirement document (PRD):\n\n{prd}\n\n{PROMPT}" - prompt = PROMPT.format(prompt=requirement, filepaths_string=filepaths_string) - design_filenames = await self._aask(prompt) - return design_filenames diff --git a/metagpt/actions/azure_tts.py b/metagpt/actions/azure_tts.py deleted file mode 100644 index c13a4750d..000000000 --- a/metagpt/actions/azure_tts.py +++ /dev/null @@ -1,53 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/6/9 22:22 -@Author : Leo Xiao -@File : azure_tts.py -""" -from azure.cognitiveservices.speech import AudioConfig, SpeechConfig, SpeechSynthesizer - -from metagpt.actions.action import Action -from metagpt.config import Config - - -class AzureTTS(Action): - def __init__(self, name, context=None, llm=None): - super().__init__(name, context, llm) - self.config = Config() - - # Parameters reference: https://learn.microsoft.com/zh-cn/azure/cognitive-services/speech-service/language-support?tabs=tts#voice-styles-and-roles - def synthesize_speech(self, lang, voice, role, text, output_file): - subscription_key = self.config.get('AZURE_TTS_SUBSCRIPTION_KEY') - region = self.config.get('AZURE_TTS_REGION') - speech_config = SpeechConfig( - subscription=subscription_key, region=region) - - speech_config.speech_synthesis_voice_name = voice - audio_config = AudioConfig(filename=output_file) - synthesizer = SpeechSynthesizer( - speech_config=speech_config, - audio_config=audio_config) - - # if voice=="zh-CN-YunxiNeural": - ssml_string = f""" - - - - {text} - - - - """ - - synthesizer.speak_ssml_async(ssml_string).get() - - -if __name__ == "__main__": - azure_tts = AzureTTS("azure_tts") - azure_tts.synthesize_speech( - "zh-CN", - "zh-CN-YunxiNeural", - "Boy", - "Hello, I am Kaka", - "output.wav") diff --git a/metagpt/actions/design_api.py b/metagpt/actions/design_api.py index abd1f9d4c..f19fcbeaa 100644 --- a/metagpt/actions/design_api.py +++ b/metagpt/actions/design_api.py @@ -10,12 +10,69 @@ from pathlib import Path from typing import List from metagpt.actions import Action, ActionOutput +from metagpt.config import CONFIG from metagpt.const import WORKSPACE_ROOT from metagpt.logs import logger from metagpt.utils.common import CodeParser +from metagpt.utils.get_template import get_template +from metagpt.utils.json_to_markdown import json_to_markdown from metagpt.utils.mermaid import mermaid_to_file -PROMPT_TEMPLATE = """ +templates = { + "json": { + "PROMPT_TEMPLATE": """ +# Context +{context} + +## Format example +{format_example} +----- +Role: You are an architect; the goal is to design a SOTA PEP8-compliant python system; make the best use of good open source tools +Requirement: Fill in the following missing information based on the context, each section name is a key in json +Max Output: 8192 chars or 2048 tokens. Try to use them up. + +## Implementation approach: Provide as Plain text. Analyze the difficult points of the requirements, select the appropriate open-source framework. + +## Python package name: Provide as Python str with python triple quoto, concise and clear, characters only use a combination of all lowercase and underscores + +## File list: Provided as Python list[str], the list of ONLY REQUIRED files needed to write the program(LESS IS MORE!). Only need relative paths, comply with PEP8 standards. ALWAYS write a main.py or app.py here + +## Data structures and interface definitions: Use mermaid classDiagram code syntax, including classes (INCLUDING __init__ method) and functions (with type annotations), CLEARLY MARK the RELATIONSHIPS between classes, and comply with PEP8 standards. The data structures SHOULD BE VERY DETAILED and the API should be comprehensive with a complete design. + +## Program call flow: Use sequenceDiagram code syntax, COMPLETE and VERY DETAILED, using CLASSES AND API DEFINED ABOVE accurately, covering the CRUD AND INIT of each object, SYNTAX MUST BE CORRECT. + +## Anything UNCLEAR: Provide as Plain text. Make clear here. + +output a properly formatted JSON, wrapped inside [CONTENT][/CONTENT] like format example, +and only output the json inside this tag, nothing else +""", + "FORMAT_EXAMPLE": """ +[CONTENT] +{ + "Implementation approach": "We will ...", + "Python package name": "snake_game", + "File list": ["main.py"], + "Data structures and interface definitions": ' + classDiagram + class Game{ + +int score + } + ... + Game "1" -- "1" Food: has + ', + "Program call flow": ' + sequenceDiagram + participant M as Main + ... + G->>M: end game + ', + "Anything UNCLEAR": "The requirement is clear to me." +} +[/CONTENT] +""", + }, + "markdown": { + "PROMPT_TEMPLATE": """ # Context {context} @@ -39,8 +96,8 @@ Attention: Use '##' to split sections, not '#', and '## ' SHOULD W ## Anything UNCLEAR: Provide as Plain text. Make clear here. -""" -FORMAT_EXAMPLE = """ +""", + "FORMAT_EXAMPLE": """ --- ## Implementation approach We will ... @@ -78,7 +135,10 @@ sequenceDiagram ## Anything UNCLEAR The requirement is clear to me. --- -""" +""", + }, +} + OUTPUT_MAPPING = { "Implementation approach": (str, ...), "Python package name": (str, ...), @@ -92,9 +152,11 @@ OUTPUT_MAPPING = { class WriteDesign(Action): def __init__(self, name, context=None, llm=None): super().__init__(name, context, llm) - self.desc = "Based on the PRD, think about the system design, and design the corresponding APIs, " \ - "data structures, library tables, processes, and paths. Please provide your design, feedback " \ - "clearly and in detail." + self.desc = ( + "Based on the PRD, think about the system design, and design the corresponding APIs, " + "data structures, library tables, processes, and paths. Please provide your design, feedback " + "clearly and in detail." + ) def recreate_workspace(self, workspace: Path): try: @@ -103,42 +165,47 @@ class WriteDesign(Action): pass # Folder does not exist, but we don't care workspace.mkdir(parents=True, exist_ok=True) - def _save_prd(self, docs_path, resources_path, prd): - prd_file = docs_path / 'prd.md' - quadrant_chart = CodeParser.parse_code(block="Competitive Quadrant Chart", text=prd) - mermaid_to_file(quadrant_chart, resources_path / 'competitive_analysis') - logger.info(f"Saving PRD to {prd_file}") - prd_file.write_text(prd) + async def _save_prd(self, docs_path, resources_path, context): + prd_file = docs_path / "prd.md" + if context[-1].instruct_content and context[-1].instruct_content.dict()["Competitive Quadrant Chart"]: + quadrant_chart = context[-1].instruct_content.dict()["Competitive Quadrant Chart"] + await mermaid_to_file(quadrant_chart, resources_path / "competitive_analysis") - def _save_system_design(self, docs_path, resources_path, content): - data_api_design = CodeParser.parse_code(block="Data structures and interface definitions", text=content) - seq_flow = CodeParser.parse_code(block="Program call flow", text=content) - mermaid_to_file(data_api_design, resources_path / 'data_api_design') - mermaid_to_file(seq_flow, resources_path / 'seq_flow') - system_design_file = docs_path / 'system_design.md' + if context[-1].instruct_content: + logger.info(f"Saving PRD to {prd_file}") + prd_file.write_text(json_to_markdown(context[-1].instruct_content.dict())) + + async def _save_system_design(self, docs_path, resources_path, system_design): + data_api_design = system_design.instruct_content.dict()[ + "Data structures and interface definitions" + ] # CodeParser.parse_code(block="Data structures and interface definitions", text=content) + seq_flow = system_design.instruct_content.dict()[ + "Program call flow" + ] # CodeParser.parse_code(block="Program call flow", text=content) + await mermaid_to_file(data_api_design, resources_path / "data_api_design") + await mermaid_to_file(seq_flow, resources_path / "seq_flow") + system_design_file = docs_path / "system_design.md" logger.info(f"Saving System Designs to {system_design_file}") - system_design_file.write_text(content) + system_design_file.write_text((json_to_markdown(system_design.instruct_content.dict()))) - def _save(self, context, system_design): + async def _save(self, context, system_design): if isinstance(system_design, ActionOutput): - content = system_design.content - ws_name = CodeParser.parse_str(block="Python package name", text=content) + ws_name = system_design.instruct_content.dict()["Python package name"] else: - content = system_design ws_name = CodeParser.parse_str(block="Python package name", text=system_design) workspace = WORKSPACE_ROOT / ws_name self.recreate_workspace(workspace) - docs_path = workspace / 'docs' - resources_path = workspace / 'resources' + docs_path = workspace / "docs" + resources_path = workspace / "resources" docs_path.mkdir(parents=True, exist_ok=True) resources_path.mkdir(parents=True, exist_ok=True) - self._save_prd(docs_path, resources_path, context[-1].content) - self._save_system_design(docs_path, resources_path, content) + await self._save_prd(docs_path, resources_path, context) + await self._save_system_design(docs_path, resources_path, system_design) - async def run(self, context): - prompt = PROMPT_TEMPLATE.format(context=context, format_example=FORMAT_EXAMPLE) + async def run(self, context, format=CONFIG.prompt_format): + prompt_template, format_example = get_template(templates, format) + prompt = prompt_template.format(context=context, format_example=format_example) # system_design = await self._aask(prompt) - system_design = await self._aask_v1(prompt, "system_design", OUTPUT_MAPPING) - self._save(context, system_design) + system_design = await self._aask_v1(prompt, "system_design", OUTPUT_MAPPING, format=format) + await self._save(context, system_design) return system_design - \ No newline at end of file diff --git a/metagpt/actions/design_api_review.py b/metagpt/actions/design_api_review.py deleted file mode 100644 index 9bb822a62..000000000 --- a/metagpt/actions/design_api_review.py +++ /dev/null @@ -1,22 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/11 19:31 -@Author : alexanderwu -@File : design_api_review.py -""" -from metagpt.actions.action import Action - - -class DesignReview(Action): - def __init__(self, name, context=None, llm=None): - super().__init__(name, context, llm) - - async def run(self, prd, api_design): - prompt = f"Here is the Product Requirement Document (PRD):\n\n{prd}\n\nHere is the list of APIs designed " \ - f"based on this PRD:\n\n{api_design}\n\nPlease review whether this API design meets the requirements" \ - f" of the PRD, and whether it complies with good design practices." - - api_review = await self._aask(prompt) - return api_review - \ No newline at end of file diff --git a/metagpt/actions/design_filenames.py b/metagpt/actions/design_filenames.py deleted file mode 100644 index 29400e950..000000000 --- a/metagpt/actions/design_filenames.py +++ /dev/null @@ -1,29 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/19 11:50 -@Author : alexanderwu -@File : design_filenames.py -""" -from metagpt.actions import Action -from metagpt.logs import logger - -PROMPT = """You are an AI developer, trying to write a program that generates code for users based on their intentions. -When given their intentions, provide a complete and exhaustive list of file paths needed to write the program for the user. -Only list the file paths you will write and return them as a Python string list. -Do not add any other explanations, just return a Python string list.""" - - -class DesignFilenames(Action): - def __init__(self, name, context=None, llm=None): - super().__init__(name, context, llm) - self.desc = "Based on the PRD, consider system design, and carry out the basic design of the corresponding " \ - "APIs, data structures, and database tables. Please give your design, feedback clearly and in detail." - - async def run(self, prd): - prompt = f"The following is the Product Requirement Document (PRD):\n\n{prd}\n\n{PROMPT}" - design_filenames = await self._aask(prompt) - logger.debug(prompt) - logger.debug(design_filenames) - return design_filenames - \ No newline at end of file diff --git a/metagpt/actions/minecraft/__init__.py b/metagpt/actions/minecraft/__init__.py new file mode 100644 index 000000000..227bf7eb3 --- /dev/null +++ b/metagpt/actions/minecraft/__init__.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 14:26 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +from enum import Enum + +from metagpt.actions.action import Action +from metagpt.actions.action_output import ActionOutput +from metagpt.actions.minecraft.design_curriculumn import DesignTask, DesignCurriculum +from metagpt.actions.minecraft.generate_actions import GenerateActionCode +from metagpt.actions.minecraft.manage_skills import RetrieveSkills, GenerateSkillDescription, AddNewSkills +from metagpt.actions.minecraft.review_task import VerifyTask +from metagpt.actions.minecraft.player_action import PlayerActions + + +class ActionType(Enum): + """All types of Actions, used for indexing.""" + + Design_Task = DesignTask + Design_Curriculum = DesignCurriculum + Generate_Action_Code = GenerateActionCode + Retrieve_Skills = RetrieveSkills + Generate_Skill_Description = GenerateSkillDescription + Add_New_Skills = AddNewSkills + Verify_Task = VerifyTask + Player_Actions = PlayerActions + + + +__all__ = [ + "ActionType", + "Action", + "ActionOutput", +] diff --git a/metagpt/actions/minecraft/control_primitives/.prettierrc.json b/metagpt/actions/minecraft/control_primitives/.prettierrc.json new file mode 100644 index 000000000..0a02bcefd --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "tabWidth": 4 +} diff --git a/metagpt/actions/minecraft/control_primitives/__init__.py b/metagpt/actions/minecraft/control_primitives/__init__.py new file mode 100644 index 000000000..f7ba48f03 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/__init__.py @@ -0,0 +1,16 @@ +import os +import metagpt.utils.minecraft as utils +from metagpt.logs import logger + + +def load_skills_code(skill_names=None): + skills_dir = os.path.dirname(os.path.abspath(__file__)) + if skill_names is None: + skill_names = [ + skill[:-3] for skill in os.listdir(f"{skills_dir}") if skill.endswith(".js") + ] + skills = [ + utils.load_text(os.path.join(skills_dir, f"{skill_name}.js")) + for skill_name in skill_names + ] + return skills diff --git a/metagpt/actions/minecraft/control_primitives/craftHelper.js b/metagpt/actions/minecraft/control_primitives/craftHelper.js new file mode 100644 index 000000000..41ae1f091 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/craftHelper.js @@ -0,0 +1,61 @@ +function failedCraftFeedback(bot, name, item, craftingTable) { + const recipes = bot.recipesAll(item.id, null, craftingTable); + if (!recipes.length) { + throw new Error(`No crafting table nearby`); + } else { + const recipes = bot.recipesAll( + item.id, + null, + mcData.blocksByName.crafting_table.id + ); + // find the recipe with the fewest missing ingredients + var min = 999; + var min_recipe = null; + for (const recipe of recipes) { + const delta = recipe.delta; + var missing = 0; + for (const delta_item of delta) { + if (delta_item.count < 0) { + const inventory_item = bot.inventory.findInventoryItem( + mcData.items[delta_item.id].name, + null + ); + if (!inventory_item) { + missing += -delta_item.count; + } else { + missing += Math.max( + -delta_item.count - inventory_item.count, + 0 + ); + } + } + } + if (missing < min) { + min = missing; + min_recipe = recipe; + } + } + const delta = min_recipe.delta; + let message = ""; + for (const delta_item of delta) { + if (delta_item.count < 0) { + const inventory_item = bot.inventory.findInventoryItem( + mcData.items[delta_item.id].name, + null + ); + if (!inventory_item) { + message += ` ${-delta_item.count} more ${ + mcData.items[delta_item.id].name + }, `; + } else { + if (inventory_item.count < -delta_item.count) { + message += `${ + -delta_item.count - inventory_item.count + } more ${mcData.items[delta_item.id].name}`; + } + } + } + } + bot.chat(`I cannot make ${name} because I need: ${message}`); + } +} diff --git a/metagpt/actions/minecraft/control_primitives/craftItem.js b/metagpt/actions/minecraft/control_primitives/craftItem.js new file mode 100644 index 000000000..a26090582 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/craftItem.js @@ -0,0 +1,43 @@ +async function craftItem(bot, name, count = 1) { + // return if name is not string + if (typeof name !== "string") { + throw new Error("name for craftItem must be a string"); + } + // return if count is not number + if (typeof count !== "number") { + throw new Error("count for craftItem must be a number"); + } + const itemByName = mcData.itemsByName[name]; + if (!itemByName) { + throw new Error(`No item named ${name}`); + } + const craftingTable = bot.findBlock({ + matching: mcData.blocksByName.crafting_table.id, + maxDistance: 32, + }); + if (!craftingTable) { + bot.chat("Craft without a crafting table"); + } else { + await bot.pathfinder.goto( + new GoalLookAtBlock(craftingTable.position, bot.world) + ); + } + const recipe = bot.recipesFor(itemByName.id, null, 1, craftingTable)[0]; + if (recipe) { + bot.chat(`I can make ${name}`); + try { + await bot.craft(recipe, count, craftingTable); + bot.chat(`I did the recipe for ${name} ${count} times`); + } catch (err) { + bot.chat(`I cannot do the recipe for ${name} ${count} times`); + } + } else { + failedCraftFeedback(bot, name, itemByName, craftingTable); + _craftItemFailCount++; + if (_craftItemFailCount > 10) { + throw new Error( + "craftItem failed too many times, check chat log to see what happened" + ); + } + } +} diff --git a/metagpt/actions/minecraft/control_primitives/exploreUntil.js b/metagpt/actions/minecraft/control_primitives/exploreUntil.js new file mode 100644 index 000000000..c73dcf3a9 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/exploreUntil.js @@ -0,0 +1,87 @@ +// Explore downward for 60 seconds: exploreUntil(bot, new Vec3(0, -1, 0), 60); +async function exploreUntil( + bot, + direction, + maxTime = 60, + callback = () => { + return false; + } +) { + if (typeof maxTime !== "number") { + throw new Error("maxTime must be a number"); + } + if (typeof callback !== "function") { + throw new Error("callback must be a function"); + } + const test = callback(); + if (test) { + bot.chat("Explore success."); + return Promise.resolve(test); + } + if (direction.x === 0 && direction.y === 0 && direction.z === 0) { + throw new Error("direction cannot be 0, 0, 0"); + } + if ( + !( + (direction.x === 0 || direction.x === 1 || direction.x === -1) && + (direction.y === 0 || direction.y === 1 || direction.y === -1) && + (direction.z === 0 || direction.z === 1 || direction.z === -1) + ) + ) { + throw new Error( + "direction must be a Vec3 only with value of -1, 0 or 1" + ); + } + maxTime = Math.min(maxTime, 1200); + return new Promise((resolve, reject) => { + const dx = direction.x; + const dy = direction.y; + const dz = direction.z; + + let explorationInterval; + let maxTimeTimeout; + + const cleanUp = () => { + clearInterval(explorationInterval); + clearTimeout(maxTimeTimeout); + bot.pathfinder.setGoal(null); + }; + + const explore = () => { + const x = + bot.entity.position.x + + Math.floor(Math.random() * 20 + 10) * dx; + const y = + bot.entity.position.y + + Math.floor(Math.random() * 20 + 10) * dy; + const z = + bot.entity.position.z + + Math.floor(Math.random() * 20 + 10) * dz; + let goal = new GoalNear(x, y, z); + if (dy === 0) { + goal = new GoalNearXZ(x, z); + } + bot.pathfinder.setGoal(goal); + + try { + const result = callback(); + if (result) { + cleanUp(); + bot.chat("Explore success."); + resolve(result); + } + } catch (err) { + cleanUp(); + reject(err); + } + }; + + explorationInterval = setInterval(explore, 2000); + + maxTimeTimeout = setTimeout(() => { + cleanUp(); + bot.chat("Max exploration time reached"); + resolve(null); + }, maxTime * 1000); + }); +} diff --git a/metagpt/actions/minecraft/control_primitives/givePlacedItemBack.js b/metagpt/actions/minecraft/control_primitives/givePlacedItemBack.js new file mode 100644 index 000000000..57d3537f4 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/givePlacedItemBack.js @@ -0,0 +1,38 @@ +async function givePlacedItemBack(bot, name, position) { + await bot.chat("/gamerule doTileDrops false"); + // iterate name and position + const history = []; + for (let i = 0; i < name.length; i++) { + await givePlacedItemBackSingle(bot, name[i], position[i]); + } + await bot.chat("/gamerule doTileDrops true"); + + async function givePlacedItemBackSingle(bot, name, position) { + bot.chat(`/give bot ${name} 1`); + const x = Math.floor(position.x); + const y = Math.floor(position.y); + const z = Math.floor(position.z); + // loop through 125 blocks around the block + const size = 3; + for (let dx = -size; dx <= size; dx++) { + for (let dy = -size; dy <= size; dy++) { + for (let dz = -size; dz <= size; dz++) { + const block = bot.blockAt(new Vec3(x + dx, y + dy, z + dz)); + if ( + block?.name === name && + !history.includes(block.position) + ) { + await bot.chat( + `/setblock ${x + dx} ${y + dy} ${ + z + dz + } air destroy` + ); + history.push(block.position); + await bot.waitForTicks(20); + return; + } + } + } + } + } +} diff --git a/metagpt/actions/minecraft/control_primitives/killMob.js b/metagpt/actions/minecraft/control_primitives/killMob.js new file mode 100644 index 000000000..3466077b2 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/killMob.js @@ -0,0 +1,51 @@ +async function killMob(bot, mobName, timeout = 300) { + // return if mobName is not string + if (typeof mobName !== "string") { + throw new Error(`mobName for killMob must be a string`); + } + // return if timeout is not number + if (typeof timeout !== "number") { + throw new Error(`timeout for killMob must be a number`); + } + + const weaponsForShooting = [ + "bow", + "crossbow", + "snowball", + "ender_pearl", + "egg", + "splash_potion", + "trident", + ]; + const mainHandItem = bot.inventory.slots[bot.getEquipmentDestSlot("hand")]; + + const entity = bot.nearestEntity( + (entity) => + entity.name === mobName && + // kill mob distance should be slightly bigger than explore distance + entity.position.distanceTo(bot.entity.position) < 48 + ); + if (!entity) { + bot.chat(`No ${mobName} nearby, please explore first`); + _killMobFailCount++; + if (_killMobFailCount > 10) { + throw new Error( + `killMob failed too many times, make sure you explore before calling killMob` + ); + } + return; + } + + let droppedItem; + if (mainHandItem && weaponsForShooting.includes(mainHandItem.name)) { + bot.hawkEye.autoAttack(entity, mainHandItem.name); + droppedItem = await waitForMobShot(bot, entity, timeout); + } else { + await bot.pvp.attack(entity); + droppedItem = await waitForMobRemoved(bot, entity, timeout); + } + if (droppedItem) { + await bot.collectBlock.collect(droppedItem, { ignoreNoPath: true }); + } + bot.save(`${mobName}_killed`); +} diff --git a/metagpt/actions/minecraft/control_primitives/mineBlock.js b/metagpt/actions/minecraft/control_primitives/mineBlock.js new file mode 100644 index 000000000..5746091f4 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/mineBlock.js @@ -0,0 +1,37 @@ +async function mineBlock(bot, name, count = 1) { + // return if name is not string + if (typeof name !== "string") { + throw new Error(`name for mineBlock must be a string`); + } + if (typeof count !== "number") { + throw new Error(`count for mineBlock must be a number`); + } + const blockByName = mcData.blocksByName[name]; + if (!blockByName) { + throw new Error(`No block named ${name}`); + } + const blocks = bot.findBlocks({ + matching: [blockByName.id], + maxDistance: 32, + count: 1024, + }); + if (blocks.length === 0) { + bot.chat(`No ${name} nearby, please explore first`); + _mineBlockFailCount++; + if (_mineBlockFailCount > 10) { + throw new Error( + "mineBlock failed too many times, make sure you explore before calling mineBlock" + ); + } + return; + } + const targets = []; + for (let i = 0; i < blocks.length; i++) { + targets.push(bot.blockAt(blocks[i])); + } + await bot.collectBlock.collect(targets, { + ignoreNoPath: true, + count: count, + }); + bot.save(`${name}_mined`); +} diff --git a/metagpt/actions/minecraft/control_primitives/placeItem.js b/metagpt/actions/minecraft/control_primitives/placeItem.js new file mode 100644 index 000000000..90175a7ca --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/placeItem.js @@ -0,0 +1,79 @@ +async function placeItem(bot, name, position) { + // return if name is not string + if (typeof name !== "string") { + throw new Error(`name for placeItem must be a string`); + } + // return if position is not Vec3 + if (!(position instanceof Vec3)) { + throw new Error(`position for placeItem must be a Vec3`); + } + const itemByName = mcData.itemsByName[name]; + if (!itemByName) { + throw new Error(`No item named ${name}`); + } + const item = bot.inventory.findInventoryItem(itemByName.id); + if (!item) { + bot.chat(`No ${name} in inventory`); + return; + } + const item_count = item.count; + // find a reference block + const faceVectors = [ + new Vec3(0, 1, 0), + new Vec3(0, -1, 0), + new Vec3(1, 0, 0), + new Vec3(-1, 0, 0), + new Vec3(0, 0, 1), + new Vec3(0, 0, -1), + ]; + let referenceBlock = null; + let faceVector = null; + for (const vector of faceVectors) { + const block = bot.blockAt(position.minus(vector)); + if (block?.name !== "air") { + referenceBlock = block; + faceVector = vector; + bot.chat(`Placing ${name} on ${block.name} at ${block.position}`); + break; + } + } + if (!referenceBlock) { + bot.chat( + `No block to place ${name} on. You cannot place a floating block.` + ); + _placeItemFailCount++; + if (_placeItemFailCount > 10) { + throw new Error( + `placeItem failed too many times. You cannot place a floating block.` + ); + } + return; + } + + // You must use try catch to placeBlock + try { + // You must first go to the block position you want to place + await bot.pathfinder.goto(new GoalPlaceBlock(position, bot.world, {})); + // You must equip the item right before calling placeBlock + await bot.equip(item, "hand"); + await bot.placeBlock(referenceBlock, faceVector); + bot.chat(`Placed ${name}`); + bot.save(`${name}_placed`); + } catch (err) { + const item = bot.inventory.findInventoryItem(itemByName.id); + if (item?.count === item_count) { + bot.chat( + `Error placing ${name}: ${err.message}, please find another position to place` + ); + _placeItemFailCount++; + if (_placeItemFailCount > 10) { + throw new Error( + `placeItem failed too many times, please find another position to place.` + ); + } + } else { + bot.chat(`Placed ${name}`); + bot.save(`${name}_placed`); + } + } +} diff --git a/metagpt/actions/minecraft/control_primitives/shoot.js b/metagpt/actions/minecraft/control_primitives/shoot.js new file mode 100644 index 000000000..c0f862a0c --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/shoot.js @@ -0,0 +1,34 @@ +// shoot 1 pig with a bow: shoot(bot, "bow", "pig"); +async function shoot(bot, weapon, target) { + const validWeapons = [ + "bow", + "crossbow", + "snowball", + "ender_pearl", + "egg", + "splash_potion", + "trident", + ]; + if (!validWeapons.includes(weapon)) { + bot.chat(`${weapon} is not a valid weapon for shooting`); + return; + } + + const weaponItem = mcData.itemsByName[weapon]; + if (!bot.inventory.findInventoryItem(weaponItem.id, null)) { + bot.chat(`No ${weapon} in inventory for shooting`); + return; + } + + const targetEntity = bot.nearestEntity( + (entity) => + entity.name === target + ); + if (!targetEntity) { + bot.chat(`No ${target} nearby`); + return; + } + bot.hawkEye.autoAttack(targetEntity, "bow"); + bot.on('auto_shot_stopped', (target) => { + }) +} diff --git a/metagpt/actions/minecraft/control_primitives/smeltItem.js b/metagpt/actions/minecraft/control_primitives/smeltItem.js new file mode 100644 index 000000000..4f06817ad --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/smeltItem.js @@ -0,0 +1,68 @@ +async function smeltItem(bot, itemName, fuelName, count = 1) { + // return if itemName or fuelName is not string + if (typeof itemName !== "string" || typeof fuelName !== "string") { + throw new Error("itemName or fuelName for smeltItem must be a string"); + } + // return if count is not a number + if (typeof count !== "number") { + throw new Error("count for smeltItem must be a number"); + } + const item = mcData.itemsByName[itemName]; + const fuel = mcData.itemsByName[fuelName]; + if (!item) { + throw new Error(`No item named ${itemName}`); + } + if (!fuel) { + throw new Error(`No item named ${fuelName}`); + } + const furnaceBlock = bot.findBlock({ + matching: mcData.blocksByName.furnace.id, + maxDistance: 32, + }); + if (!furnaceBlock) { + throw new Error("No furnace nearby"); + } else { + await bot.pathfinder.goto( + new GoalLookAtBlock(furnaceBlock.position, bot.world) + ); + } + const furnace = await bot.openFurnace(furnaceBlock); + let success_count = 0; + for (let i = 0; i < count; i++) { + if (!bot.inventory.findInventoryItem(item.id, null)) { + bot.chat(`No ${itemName} to smelt in inventory`); + break; + } + if (furnace.fuelSeconds < 15 && furnace.fuelItem()?.name !== fuelName) { + if (!bot.inventory.findInventoryItem(fuel.id, null)) { + bot.chat(`No ${fuelName} as fuel in inventory`); + break; + } + await furnace.putFuel(fuel.id, null, 1); + await bot.waitForTicks(20); + if (!furnace.fuel && furnace.fuelItem()?.name !== fuelName) { + throw new Error(`${fuelName} is not a valid fuel`); + } + } + await furnace.putInput(item.id, null, 1); + await bot.waitForTicks(12 * 20); + if (!furnace.outputItem()) { + throw new Error(`${itemName} is not a valid input`); + } + await furnace.takeOutput(); + success_count++; + } + furnace.close(); + if (success_count > 0) bot.chat(`Smelted ${success_count} ${itemName}.`); + else { + bot.chat( + `Failed to smelt ${itemName}, please check the fuel and input.` + ); + _smeltItemFailCount++; + if (_smeltItemFailCount > 10) { + throw new Error( + `smeltItem failed too many times, please check the fuel and input.` + ); + } + } +} diff --git a/metagpt/actions/minecraft/control_primitives/useChest.js b/metagpt/actions/minecraft/control_primitives/useChest.js new file mode 100644 index 000000000..64f02da80 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/useChest.js @@ -0,0 +1,133 @@ +async function getItemFromChest(bot, chestPosition, itemsToGet) { + // return if chestPosition is not Vec3 + if (!(chestPosition instanceof Vec3)) { + bot.chat("chestPosition for getItemFromChest must be a Vec3"); + return; + } + await moveToChest(bot, chestPosition); + const chestBlock = bot.blockAt(chestPosition); + const chest = await bot.openContainer(chestBlock); + for (const name in itemsToGet) { + const itemByName = mcData.itemsByName[name]; + if (!itemByName) { + bot.chat(`No item named ${name}`); + continue; + } + + const item = chest.findContainerItem(itemByName.id); + if (!item) { + bot.chat(`I don't see ${name} in this chest`); + continue; + } + try { + await chest.withdraw(item.type, null, itemsToGet[name]); + } catch (err) { + bot.chat(`Not enough ${name} in chest.`); + } + } + await closeChest(bot, chestBlock); +} + +async function depositItemIntoChest(bot, chestPosition, itemsToDeposit) { + // return if chestPosition is not Vec3 + if (!(chestPosition instanceof Vec3)) { + throw new Error( + "chestPosition for depositItemIntoChest must be a Vec3" + ); + } + await moveToChest(bot, chestPosition); + const chestBlock = bot.blockAt(chestPosition); + const chest = await bot.openContainer(chestBlock); + for (const name in itemsToDeposit) { + const itemByName = mcData.itemsByName[name]; + if (!itemByName) { + bot.chat(`No item named ${name}`); + continue; + } + const item = bot.inventory.findInventoryItem(itemByName.id); + if (!item) { + bot.chat(`No ${name} in inventory`); + continue; + } + try { + await chest.deposit(item.type, null, itemsToDeposit[name]); + } catch (err) { + bot.chat(`Not enough ${name} in inventory.`); + } + } + await closeChest(bot, chestBlock); +} + +async function checkItemInsideChest(bot, chestPosition) { + // return if chestPosition is not Vec3 + if (!(chestPosition instanceof Vec3)) { + throw new Error( + "chestPosition for depositItemIntoChest must be a Vec3" + ); + } + await moveToChest(bot, chestPosition); + const chestBlock = bot.blockAt(chestPosition); + await bot.openContainer(chestBlock); + await closeChest(bot, chestBlock); +} + +async function moveToChest(bot, chestPosition) { + if (!(chestPosition instanceof Vec3)) { + throw new Error( + "chestPosition for depositItemIntoChest must be a Vec3" + ); + } + if (chestPosition.distanceTo(bot.entity.position) > 32) { + bot.chat( + `/tp ${chestPosition.x} ${chestPosition.y} ${chestPosition.z}` + ); + await bot.waitForTicks(20); + } + const chestBlock = bot.blockAt(chestPosition); + if (chestBlock.name !== "chest") { + bot.emit("removeChest", chestPosition); + throw new Error( + `No chest at ${chestPosition}, it is ${chestBlock.name}` + ); + } + await bot.pathfinder.goto( + new GoalLookAtBlock(chestBlock.position, bot.world, {}) + ); + return chestBlock; +} + +async function listItemsInChest(bot, chestBlock) { + const chest = await bot.openContainer(chestBlock); + const items = chest.containerItems(); + if (items.length > 0) { + const itemNames = items.reduce((acc, obj) => { + if (acc[obj.name]) { + acc[obj.name] += obj.count; + } else { + acc[obj.name] = obj.count; + } + return acc; + }, {}); + bot.emit("closeChest", itemNames, chestBlock.position); + } else { + bot.emit("closeChest", {}, chestBlock.position); + } + return chest; +} + +async function closeChest(bot, chestBlock) { + try { + const chest = await listItemsInChest(bot, chestBlock); + await chest.close(); + } catch (err) { + await bot.closeWindow(chestBlock); + } +} + +function itemByName(items, name) { + for (let i = 0; i < items.length; ++i) { + const item = items[i]; + if (item && item.name === name) return item; + } + return null; +} diff --git a/metagpt/actions/minecraft/control_primitives/waitForMobRemoved.js b/metagpt/actions/minecraft/control_primitives/waitForMobRemoved.js new file mode 100644 index 000000000..fa83d4310 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives/waitForMobRemoved.js @@ -0,0 +1,84 @@ +function waitForMobRemoved(bot, entity, timeout = 300) { + return new Promise((resolve, reject) => { + let success = false; + let droppedItem = null; + // Set up timeout + const timeoutId = setTimeout(() => { + success = false; + bot.pvp.stop(); + }, timeout * 1000); + + // Function to handle entityRemoved event + function onEntityGone(e) { + if (e === entity) { + success = true; + clearTimeout(timeoutId); + bot.chat(`Killed ${entity.name}!`); + bot.pvp.stop(); + } + } + + function onItemDrop(item) { + if (entity.position.distanceTo(item.position) <= 1) { + droppedItem = item; + } + } + + function onStoppedAttacking() { + clearTimeout(timeoutId); + bot.removeListener("entityGone", onEntityGone); + bot.removeListener("stoppedAttacking", onStoppedAttacking); + bot.removeListener("itemDrop", onItemDrop); + if (!success) reject(new Error(`Failed to kill ${entity.name}.`)); + else resolve(droppedItem); + } + + // Listen for entityRemoved event + bot.on("entityGone", onEntityGone); + bot.on("stoppedAttacking", onStoppedAttacking); + bot.on("itemDrop", onItemDrop); + }); +} + + +function waitForMobShot(bot, entity, timeout = 300) { + return new Promise((resolve, reject) => { + let success = false; + let droppedItem = null; + // Set up timeout + const timeoutId = setTimeout(() => { + success = false; + bot.hawkEye.stop(); + }, timeout * 1000); + + // Function to handle entityRemoved event + function onEntityGone(e) { + if (e === entity) { + success = true; + clearTimeout(timeoutId); + bot.chat(`Shot ${entity.name}!`); + bot.hawkEye.stop(); + } + } + + function onItemDrop(item) { + if (entity.position.distanceTo(item.position) <= 1) { + droppedItem = item; + } + } + + function onAutoShotStopped() { + clearTimeout(timeoutId); + bot.removeListener("entityGone", onEntityGone); + bot.removeListener("auto_shot_stopped", onAutoShotStopped); + bot.removeListener("itemDrop", onItemDrop); + if (!success) reject(new Error(`Failed to shoot ${entity.name}.`)); + else resolve(droppedItem); + } + + // Listen for entityRemoved event + bot.on("entityGone", onEntityGone); + bot.on("auto_shot_stopped", onAutoShotStopped); + bot.on("itemDrop", onItemDrop); + }); +} diff --git a/metagpt/actions/minecraft/control_primitives_context/__init__.py b/metagpt/actions/minecraft/control_primitives_context/__init__.py new file mode 100644 index 000000000..9c3d08169 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives_context/__init__.py @@ -0,0 +1,20 @@ +import os +import metagpt.utils.minecraft as utils +from metagpt.logs import logger + +def load_skills_code_context(skill_names=None): + skills_dir = os.path.dirname(os.path.abspath(__file__)) + if skill_names is None: + skill_names = [ + skill[:-3] for skill in os.listdir(f"{skills_dir}") if skill.endswith(".js") + ] + skills = [ + utils.load_text(os.path.join(skills_dir, f"{skill_name}.js")) + for skill_name in skill_names + ] + return skills + + +if __name__ == "__main__": + logger.info(load_skills_code_context(["craftItem", "exploreUntil"])) + diff --git a/metagpt/actions/minecraft/control_primitives_context/craftItem.js b/metagpt/actions/minecraft/control_primitives_context/craftItem.js new file mode 100644 index 000000000..806811d46 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives_context/craftItem.js @@ -0,0 +1,14 @@ +// Craft 8 oak_planks from 2 oak_log (do the recipe 2 times): craftItem(bot, "oak_planks", 2); +// You must place a crafting table before calling this function +async function craftItem(bot, name, count = 1) { + const item = mcData.itemsByName[name]; + const craftingTable = bot.findBlock({ + matching: mcData.blocksByName.crafting_table.id, + maxDistance: 32, + }); + await bot.pathfinder.goto( + new GoalLookAtBlock(craftingTable.position, bot.world) + ); + const recipe = bot.recipesFor(item.id, null, 1, craftingTable)[0]; + await bot.craft(recipe, count, craftingTable); +} diff --git a/metagpt/actions/minecraft/control_primitives_context/exploreUntil.js b/metagpt/actions/minecraft/control_primitives_context/exploreUntil.js new file mode 100644 index 000000000..55c62a453 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives_context/exploreUntil.js @@ -0,0 +1,31 @@ +/* +Explore until find an iron_ore, use Vec3(0, -1, 0) because iron ores are usually underground +await exploreUntil(bot, new Vec3(0, -1, 0), 60, () => { + const iron_ore = bot.findBlock({ + matching: mcData.blocksByName["iron_ore"].id, + maxDistance: 32, + }); + return iron_ore; +}); + +Explore until find a pig, use Vec3(1, 0, 1) because pigs are usually on the surface +let pig = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => { + const pig = bot.nearestEntity((entity) => { + return ( + entity.name === "pig" && + entity.position.distanceTo(bot.entity.position) < 32 + ); + }); + return pig; +}); +*/ +async function exploreUntil(bot, direction, maxTime = 60, callback) { + /* + Implementation of this function is omitted. + direction: Vec3, can only contain value of -1, 0 or 1 + maxTime: number, the max time for exploration + callback: function, early stop condition, will be called each second, exploration will stop if return value is not null + + Return: null if explore timeout, otherwise return the return value of callback + */ +} diff --git a/metagpt/actions/minecraft/control_primitives_context/killMob.js b/metagpt/actions/minecraft/control_primitives_context/killMob.js new file mode 100644 index 000000000..670ca9753 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives_context/killMob.js @@ -0,0 +1,12 @@ +// Kill a pig and collect the dropped item: killMob(bot, "pig", 300); +async function killMob(bot, mobName, timeout = 300) { + const entity = bot.nearestEntity( + (entity) => + entity.name === mobName && + entity.position.distanceTo(bot.entity.position) < 32 + ); + await bot.pvp.attack(entity); + await bot.pathfinder.goto( + new GoalBlock(entity.position.x, entity.position.y, entity.position.z) + ); +} diff --git a/metagpt/actions/minecraft/control_primitives_context/mineBlock.js b/metagpt/actions/minecraft/control_primitives_context/mineBlock.js new file mode 100644 index 000000000..c6a7559e6 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives_context/mineBlock.js @@ -0,0 +1,15 @@ +// Mine 3 cobblestone: mineBlock(bot, "stone", 3); +async function mineBlock(bot, name, count = 1) { + const blocks = bot.findBlocks({ + matching: (block) => { + return block.name === name; + }, + maxDistance: 32, + count: count, + }); + const targets = []; + for (let i = 0; i < Math.min(blocks.length, count); i++) { + targets.push(bot.blockAt(blocks[i])); + } + await bot.collectBlock.collect(targets, { ignoreNoPath: true }); +} diff --git a/metagpt/actions/minecraft/control_primitives_context/mineflayer.js b/metagpt/actions/minecraft/control_primitives_context/mineflayer.js new file mode 100644 index 000000000..43217885c --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives_context/mineflayer.js @@ -0,0 +1,22 @@ +await bot.pathfinder.goto(goal); // A very useful function. This function may change your main-hand equipment. +// Following are some Goals you can use: +new GoalNear(x, y, z, range); // Move the bot to a block within the specified range of the specified block. `x`, `y`, `z`, and `range` are `number` +new GoalXZ(x, z); // Useful for long-range goals that don't have a specific Y level. `x` and `z` are `number` +new GoalGetToBlock(x, y, z); // Not get into the block, but get directly adjacent to it. Useful for fishing, farming, filling bucket, and beds. `x`, `y`, and `z` are `number` +new GoalFollow(entity, range); // Follow the specified entity within the specified range. `entity` is `Entity`, `range` is `number` +new GoalPlaceBlock(position, bot.world, {}); // Position the bot in order to place a block. `position` is `Vec3` +new GoalLookAtBlock(position, bot.world, {}); // Path into a position where a blockface of the block at position is visible. `position` is `Vec3` + +// These are other Mineflayer functions you can use: +bot.isABed(bedBlock); // Return true if `bedBlock` is a bed +bot.blockAt(position); // Return the block at `position`. `position` is `Vec3` + +// These are other Mineflayer async functions you can use: +await bot.equip(item, destination); // Equip the item in the specified destination. `item` is `Item`, `destination` can only be "hand", "head", "torso", "legs", "feet", "off-hand" +await bot.consume(); // Consume the item in the bot's hand. You must equip the item to consume first. Useful for eating food, drinking potions, etc. +await bot.fish(); // Let bot fish. Before calling this function, you must first get to a water block and then equip a fishing rod. The bot will automatically stop fishing when it catches a fish +await bot.sleep(bedBlock); // Sleep until sunrise. You must get to a bed block first +await bot.activateBlock(block); // This is the same as right-clicking a block in the game. Useful for buttons, doors, etc. You must get to the block first +await bot.lookAt(position); // Look at the specified position. You must go near the position before you look at it. To fill bucket with water, you must lookAt first. `position` is `Vec3` +await bot.activateItem(); // This is the same as right-clicking to use the item in the bot's hand. Useful for using buckets, etc. You must equip the item to activate first +await bot.useOn(entity); // This is the same as right-clicking an entity in the game. Useful for shearing sheep, equipping harnesses, etc. You must get to the entity first diff --git a/metagpt/actions/minecraft/control_primitives_context/placeItem.js b/metagpt/actions/minecraft/control_primitives_context/placeItem.js new file mode 100644 index 000000000..99e06089c --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives_context/placeItem.js @@ -0,0 +1,28 @@ +// Place a crafting_table near the player, Vec3(1, 0, 0) is just an example, you shouldn't always use that: placeItem(bot, "crafting_table", bot.entity.position.offset(1, 0, 0)); +async function placeItem(bot, name, position) { + const item = bot.inventory.findInventoryItem(mcData.itemsByName[name].id); + // find a reference block + const faceVectors = [ + new Vec3(0, 1, 0), + new Vec3(0, -1, 0), + new Vec3(1, 0, 0), + new Vec3(-1, 0, 0), + new Vec3(0, 0, 1), + new Vec3(0, 0, -1), + ]; + let referenceBlock = null; + let faceVector = null; + for (const vector of faceVectors) { + const block = bot.blockAt(position.minus(vector)); + if (block?.name !== "air") { + referenceBlock = block; + faceVector = vector; + break; + } + } + // You must first go to the block position you want to place + await bot.pathfinder.goto(new GoalPlaceBlock(position, bot.world, {})); + // You must equip the item right before calling placeBlock + await bot.equip(item, "hand"); + await bot.placeBlock(referenceBlock, faceVector); +} diff --git a/metagpt/actions/minecraft/control_primitives_context/smeltItem.js b/metagpt/actions/minecraft/control_primitives_context/smeltItem.js new file mode 100644 index 000000000..0a3c76257 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives_context/smeltItem.js @@ -0,0 +1,22 @@ +// Smelt 1 raw_iron into 1 iron_ingot using 1 oak_planks as fuel: smeltItem(bot, "raw_iron", "oak_planks"); +// You must place a furnace before calling this function +async function smeltItem(bot, itemName, fuelName, count = 1) { + const item = mcData.itemsByName[itemName]; + const fuel = mcData.itemsByName[fuelName]; + const furnaceBlock = bot.findBlock({ + matching: mcData.blocksByName.furnace.id, + maxDistance: 32, + }); + await bot.pathfinder.goto( + new GoalLookAtBlock(furnaceBlock.position, bot.world) + ); + const furnace = await bot.openFurnace(furnaceBlock); + for (let i = 0; i < count; i++) { + await furnace.putFuel(fuel.id, null, 1); + await furnace.putInput(item.id, null, 1); + // Wait 12 seconds for the furnace to smelt the item + await bot.waitForTicks(12 * 20); + await furnace.takeOutput(); + } + await furnace.close(); +} diff --git a/metagpt/actions/minecraft/control_primitives_context/useChest.js b/metagpt/actions/minecraft/control_primitives_context/useChest.js new file mode 100644 index 000000000..e80af3fd9 --- /dev/null +++ b/metagpt/actions/minecraft/control_primitives_context/useChest.js @@ -0,0 +1,35 @@ +// Get a torch from chest at (30, 65, 100): getItemFromChest(bot, new Vec3(30, 65, 100), {"torch": 1}); +// This function will work no matter how far the bot is from the chest. +async function getItemFromChest(bot, chestPosition, itemsToGet) { + await moveToChest(bot, chestPosition); + const chestBlock = bot.blockAt(chestPosition); + const chest = await bot.openContainer(chestBlock); + for (const name in itemsToGet) { + const itemByName = mcData.itemsByName[name]; + const item = chest.findContainerItem(itemByName.id); + await chest.withdraw(item.type, null, itemsToGet[name]); + } + await closeChest(bot, chestBlock); +} +// Deposit a torch into chest at (30, 65, 100): depositItemIntoChest(bot, new Vec3(30, 65, 100), {"torch": 1}); +// This function will work no matter how far the bot is from the chest. +async function depositItemIntoChest(bot, chestPosition, itemsToDeposit) { + await moveToChest(bot, chestPosition); + const chestBlock = bot.blockAt(chestPosition); + const chest = await bot.openContainer(chestBlock); + for (const name in itemsToDeposit) { + const itemByName = mcData.itemsByName[name]; + const item = bot.inventory.findInventoryItem(itemByName.id); + await chest.deposit(item.type, null, itemsToDeposit[name]); + } + await closeChest(bot, chestBlock); +} +// Check the items inside the chest at (30, 65, 100): checkItemInsideChest(bot, new Vec3(30, 65, 100)); +// You only need to call this function once without any action to finish task of checking items inside the chest. +async function checkItemInsideChest(bot, chestPosition) { + await moveToChest(bot, chestPosition); + const chestBlock = bot.blockAt(chestPosition); + await bot.openContainer(chestBlock); + // You must close the chest after opening it if you are asked to open a chest + await closeChest(bot, chestBlock); +} diff --git a/metagpt/actions/minecraft/design_curriculumn.py b/metagpt/actions/minecraft/design_curriculumn.py new file mode 100644 index 000000000..28299c620 --- /dev/null +++ b/metagpt/actions/minecraft/design_curriculumn.py @@ -0,0 +1,247 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 14:56 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +import json +import re + +from langchain.embeddings.openai import OpenAIEmbeddings +from langchain.vectorstores import Chroma +from metagpt.document_store import FaissStore + +from metagpt.logs import logger +from metagpt.actions import Action +from metagpt.utils.minecraft import load_prompt, fix_and_parse_json +from metagpt.schema import HumanMessage, SystemMessage +from metagpt.const import CKPT_DIR + +# from metagpt.actions.minecraft import PlayerActions + + +class DesignTask(Action): + """ + Action class for decomposing a task. + Refer to the code in the voyager/agents/curriculum.py for implementation details. + """ + + def __init__(self, name="", context=None, llm=None): + super().__init__(name, context, llm) + + async def decompose_task(self, query, events): + system_msgs = SystemMessage( + content=load_prompt("curriculum_task_decomposition") + ) + prompt = self.render_human_message( + events=events, chest_observation="" + ) + HumanMessage(content=f"Final task: {query}") + logger.info(f"Curriculum Agent task decomposition\nFinal task: {query}") + + rsp = await self._aask(prompt=prompt, system_msgs=system_msgs) + logger.info(f"Curriculum Agent task decomposition\n{rsp}") + return fix_and_parse_json(rsp) + + def parse_llm_response(self, llm_resp): + task = "" + for line in llm_resp.split("\n"): + if line.startswith("Task:"): + task = line[5:].replace(".", "").strip() + assert task, "Task not found in Curriculum Agent response" + return {"next_task": task} + + async def generate_task(self, human_msg, system_msg, max_retries=5): + """ + Refer to the code in the voyager/agents/curriculum.py propose_next_ai_task() for implementation details. + Returns: task & context + + """ + + if max_retries == 0: + raise RuntimeError("Max retries reached, failed to propose task.") + curriculum = await self._aask(prompt=human_msg, system_msgs=system_msg) + logger.info(f"Curriculum Agent message\n{curriculum}") + try: + response = self.parse_llm_response( + curriculum + ) # Task: Craft 4 wooden planks. + assert "next_task" in response + return response["next_task"] + except Exception as e: + logger.info(f"Error parsing curriculum response: {e}. Trying again!") + return self.generate_task( + human_msg=human_msg, + system_msg=system_msg, + max_retries=max_retries - 1, + ) + + async def run(self, human_msg, system_msg, *args, **kwargs): + logger.info(f"run {self.__repr__()}") + + # Call the language model to generate a response. + + task = await self.generate_task(human_msg=human_msg, system_msg=system_msg) + + return task + + +class DesignCurriculum(Action): + """ + Action class for designing curriculum-related questions. + Refer to the code in the voyager/agents/curriculum.py for implementation details. + """ + + def __init__(self, name="", context=None, llm=None): + super().__init__(name, context, llm) + # voyager vectordb using + self.qa_cache = {} + self.qa_cache_questions_vectordb = Chroma( + collection_name="qa_cache_questions_vectordb", + embedding_function=OpenAIEmbeddings(), + persist_directory=f"{CKPT_DIR}/curriculum/vectordb", + ) + # TODO: change to FaissStore + # self.qa_cache_questions_vectordb = FaissStore( {CKPT_DIR}/ 'curriculum/vectordb') + # TODO: + # assert self.qa_cache_questions_vectordb._collection.count() == len( + # self.qa_cache + # ), ( + # f"Curriculum Agent's qa cache question vectordb is not synced with qa_cache.json.\n" + # f"There are {self.qa_cache_questions_vectordb._collection.count()} questions in vectordb " + # f"but {len(self.qa_cache)} questions in qa_cache.json.\n" + # f"Did you set resume=False when initializing the agent?\n" + # f"You may need to manually delete the qa cache question vectordb directory for running from scratch.\n" + # ) + + @classmethod + def set_qa_cache(cls, qa_cache): + cls.qa_cache = qa_cache + # Check if qa_cache right using + + @classmethod + def generate_qa(cls, events, chest_observation): + """ + Generate qa for DesignTask's HumanMessage + """ + questions_new, _ = cls.generate_qa_step1( + events=events, chest_observation=chest_observation + ) + questions = [] + answers = [] + for question in questions_new: + if cls.qa_cache_questions_vectordb._collection.count() > 0: + docs_and_scores = ( + cls.qa_cache_questions_vectordb.similarity_search_with_score( + question, k=1 + ) + ) + if docs_and_scores and docs_and_scores[0][1] < 0.05: + question_cached = docs_and_scores[0][0].page_content + assert question_cached in cls.qa_cache + answer_cached = cls.qa_cache[question_cached] + questions.append(question_cached) + answers.append(answer_cached) + continue + answer = cls.generate_qa_step2(question=question) + assert question not in cls.qa_cache + cls.qa_cache[question] = answer + cls.qa_cache_questions_vectordb.add_texts( + texts=[question], + ) + with open(f"{CKPT_DIR}/curriculum/qa_cache.json", "w") as f: + json.dump(cls.qa_cache, f) + cls.qa_cache_questions_vectordb.persist() + questions.append(question) + answers.append(answer) + assert len(questions_new) == len(questions) == len(answers) + return questions, answers + + async def generate_qa_step1(self, events, human_msg, system_msg): + biome = events[-1][1]["status"]["biome"].replace("_", " ") + questions = [ + f"What are the blocks that I can find in the {biome} in Minecraft?", + f"What are the items that I can find in the {biome} in Minecraft?", + f"What are the mobs that I can find in the {biome} in Minecraft?", + ] + qa_response = await self._aask(prompt=human_msg, system_msgs=system_msg) + + try: + # Regex pattern to extract question and concept pairs + pattern = r"Question \d+: (.+)\nConcept \d+: (.+)" + # Extracting all question and concept pairs from the text + pairs = re.findall(pattern, qa_response) + # Storing each question and concept in separate lists + questions_new = [pair[0] for pair in pairs] + questions.extend(questions_new) + except Exception as e: + logger.error( + f"Error parsing curriculum response for " + f"QA step 1 ask questions: {e}." + ) + return questions + + async def generate_qa_step2(self, question): + # Implement the logic for another specific step in generating questions and answers. + logger.info(f"Curriculum Agent Question: {question}") + human_msg = HumanMessage(content=f"Question: {question}").content + system_msg = [ + SystemMessage( + content=load_prompt("curriculum_qa_step2_answer_questions") + ).content + ] + answer = await self._aask(prompt=human_msg, system_msgs=system_msg) + logger.info(f"Curriculum Agent {answer}") + return answer + + async def get_context_from_task(self, task): + """ + Args: task + Returns: context: "Question: {question}\n{answer}" + if include ore in question, gpt will try to use tool with skill touch enhancement to mine + """ + + question = ( + f"How to {task.replace('_', ' ').replace(' ore', '').replace(' ores', '').replace('.', '').strip().lower()}" + f" in Minecraft?" + ) + if question in self.qa_cache: + answer = self.qa_cache[question] + else: + answer = await self.generate_qa_step2(question=question) + self.qa_cache[question] = answer + self.qa_cache_questions_vectordb.add_texts( + texts=[question], + ) + with open(f"{CKPT_DIR}/curriculum/qa_cache.json", "w") as f: + json.dump(self.qa_cache, f) + self.qa_cache_questions_vectordb.persist() + context = f"Question: {question}\n{answer}" + return context + + async def generate_context(self, task, max_retries=5): + """ + Refer to the code in the voyager/agents/curriculum.py propose_next_ai_task() for implementation details. + Returns: context + + """ + + if max_retries == 0: + raise RuntimeError("Max retries reached, failed to propose context.") + try: + context = await self.get_context_from_task( + task=task + ) # Curriculum Agent Question: How to craft 4 wooden planks in Minecraft? & Curriculum Agent Answer: ... + return context + except Exception as e: + logger.info(f"Error parsing curriculum response: {e}. Trying again!") + return self.generate_context( + task=task, + max_retries=max_retries - 1, + ) + + async def run(self, task, human_msg, system_msg, *args, **kwargs): + logger.info(f"run {self.__repr__()}") + # Generate curriculum-related questions and answers. + # curriculum_qustion = await self.generate_qa_step1(events, human_msg, system_msg) + curriculum_context = await self.generate_context(task) + + # Return the generated questions and answers. + return curriculum_context diff --git a/metagpt/actions/minecraft/generate_actions.py b/metagpt/actions/minecraft/generate_actions.py new file mode 100644 index 000000000..8cc32ec08 --- /dev/null +++ b/metagpt/actions/minecraft/generate_actions.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 15:44 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +from metagpt.logs import logger +from metagpt.actions import Action +from metagpt.utils.minecraft import parse_action_response + + +class GenerateActionCode(Action): + """ + Action class for generating action code. + Refer to the code in the voyager/agents/action.py for implementation details. + """ + + def __init__(self, name="", context=None, llm=None): + super().__init__(name, context, llm) + + async def generate_code(self, human_msg, system_msg=[]): + """ + Generate action code logic. + + Implement the logic for generating action code here. + """ + rsp = await self._aask(prompt=human_msg, system_msgs=system_msg) + parsed_result = parse_action_response(rsp) + # logger.info(f"parsed_result is HERE: {parsed_result}") + + try: + return ( + parsed_result["program_code"] + "\n" + parsed_result["exec_code"], + parsed_result["program_name"], + ) + except: + logger.error(f"Failed to parse response: {parsed_result}") + return None, None + + async def run(self, human_msg, system_msg, *args, **kwargs): + logger.info(f"run {self.__repr__()}") + # Generate action code. + generated_code, program_name = await self.generate_code( + human_msg=human_msg, system_msg=system_msg + ) + + # Return the generated code. + return generated_code, program_name diff --git a/metagpt/actions/minecraft/manage_skills.py b/metagpt/actions/minecraft/manage_skills.py new file mode 100644 index 000000000..bee726f15 --- /dev/null +++ b/metagpt/actions/minecraft/manage_skills.py @@ -0,0 +1,139 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 14:56 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +import os +import json + +from langchain.embeddings.openai import OpenAIEmbeddings +from langchain.vectorstores import Chroma +from metagpt.document_store import FaissStore +from metagpt.logs import logger +from metagpt.actions import Action +from metagpt.const import CKPT_DIR + + +class RetrieveSkills(Action): + """ + Action class for retrieving skills. + Refer to the code in the voyager/agents/skill.py for implementation details. + """ + + def __init__(self, name="", context=None, llm=None): + super().__init__(name, context, llm) + # TODO: mv to PlayerAction + self.retrieval_top_k = 5 + self.vectordb = Chroma( + collection_name="skill_vectordb", + embedding_function=OpenAIEmbeddings(), + persist_directory=f"{CKPT_DIR}/skill/vectordb", + ) + # Check if skills right using + # TODO: + # assert self.vectordb._collection.count() == len(self.skills), ( + # f"Skill Manager's vectordb is not synced with skills.json.\n" + # f"There are {self.vectordb._collection.count()} skills in vectordb but {len(self.skills)} skills in skills.json.\n" + # f"Did you set resume=False when initializing the manager?\n" + # f"You may need to manually delete the vectordb directory for running from scratch." + # ) + + async def run(self, query, skills, *args, **kwargs): + # Implement the logic for retrieving skills here. + k = min(self.vectordb._collection.count(), self.retrieval_top_k) + if k == 0: + return [] + logger.info(f"Skill Manager retrieving for {k} skills") + docs_and_scores = self.vectordb.similarity_search_with_score(query, k=k) + logger.info( + f"Skill Manager retrieved skills: " + f"{', '.join([doc.metadata['name'] for doc, _ in docs_and_scores])}" + ) + retrieve_skills = [] + for doc, _ in docs_and_scores: + retrieve_skills.append(skills[doc.metadata["name"]]["code"]) + return retrieve_skills + + +class AddNewSkills(Action): + """ + Action class for adding new skills. + Refer to the code in the voyager/agents/skill.py for implementation details. + """ + + def __init__(self, name="", context=None, llm=None): + super().__init__(name, context, llm) + # TODO: mv to PlayerAction + self.vectordb = Chroma( + collection_name="skill_vectordb", + embedding_function=OpenAIEmbeddings(), + persist_directory=f"{CKPT_DIR}/skill/vectordb", + ) + # TODO: change to FaissStore + # self.qa_cache_questions_vectordb = FaissStore( {CKPT_DIR}/ 'skill/vectordb') + # TODO: + # Check if skills right using + # assert self.vectordb._collection.count() == len(self.skills), ( + # f"Skill Manager's vectordb is not synced with skills.json.\n" + # f"There are {self.vectordb._collection.count()} skills in vectordb but {len(self.skills)} skills in skills.json.\n" + # f"Did you set resume=False when initializing the manager?\n" + # f"You may need to manually delete the vectordb directory for running from scratch." + # ) + + async def run( + self, task, program_name, program_code, skills, skill_desp, *args, **kwargs + ): + # Implement the logic for adding new skills here. + # TODO: Fix this + if task.startswith("Deposit useless items into the chest at"): + # No need to reuse the deposit skill + return {} + logger.info( + f"Skill Manager generated description for {program_name}:\n{skill_desp}\033[0m" + ) + if program_name in skills: + logger.info(f"Skill {program_name} already exists. Rewriting!") + self.vectordb._collection.delete(ids=[program_name]) + i = 2 + while f"{program_name}V{i}.js" in os.listdir(f"{CKPT_DIR}/skill/code"): + i += 1 + dumped_program_name = f"{program_name}V{i}" + else: + dumped_program_name = program_name + self.vectordb.add_texts( + texts=[skill_desp], + ids=[program_name], + metadatas=[{"name": program_name}], + ) + + # FIXME + # assert self.vectordb._collection.count() == len( + # skills + # ), "vectordb is not synced with skills.json" + + with open(f"{CKPT_DIR}/skill/code/{dumped_program_name}.js", "w") as f: + f.write(program_code) + with open(f"{CKPT_DIR}/skill/description/{dumped_program_name}.txt", "w") as f: + f.write(skill_desp) + with open(f"{CKPT_DIR}/skill/skills.json", "w") as f: + json.dump(skills, f) + self.vectordb.persist() + return { + "code": program_code, + "description": skill_desp, + } + + +class GenerateSkillDescription(Action): + """ + Action class for generating skill descriptions. + Refer to the code in the voyager/agents/skill.py for implementation details. + """ + + def __init__(self, name="", context=None, llm=None): + super().__init__(name, context, llm) + + async def run(self, program_name, human_message, system_message, *args, **kwargs): + # Implement the logic for generating skill descriptions here. + rsp = await self._aask(prompt=human_message, system_msgs=system_message) + skill_description = f" // { rsp}" + return f"async function {program_name}(bot) {{\n{skill_description}\n}}" diff --git a/metagpt/actions/minecraft/player_action.py b/metagpt/actions/minecraft/player_action.py new file mode 100644 index 000000000..6597fc9a1 --- /dev/null +++ b/metagpt/actions/minecraft/player_action.py @@ -0,0 +1,10 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 17:06 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +from metagpt.actions import Action + +class PlayerActions(Action): + """Minecraft player info without any implementation details""" + async def run(self, *args, **kwargs): + raise NotImplementedError \ No newline at end of file diff --git a/metagpt/actions/minecraft/review_task.py b/metagpt/actions/minecraft/review_task.py new file mode 100644 index 000000000..b532fb370 --- /dev/null +++ b/metagpt/actions/minecraft/review_task.py @@ -0,0 +1,46 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 14:56 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +from metagpt.logs import logger +from metagpt.actions import Action +from metagpt.utils.minecraft import fix_and_parse_json + + +class VerifyTask(Action): + """ + Action class for verifying a task. + Refer to the code in the voyager/agents/critic.py for implementation details. + """ + + def __init__(self, name="", context=None, llm=None): + super().__init__(name, context, llm) + self.vect_db = "" + + async def run(self,human_msg, system_msg, max_retries=5, *args, **kwargs): + # Implement the logic to verify the task here. + + # Example: Verify the completion of a task. + + # If verification is successful, return a success message. + # task, status, review_info = "", True, "Task verified successfully." + + if max_retries == 0: + logger.info(f"Failed to parse Critic Agent response. Consider updating your prompt.") + return False, "" + + if human_msg or system_msg is None: + return False, "" + critic = await self._aask(prompt=human_msg, system_msgs=system_msg) + try: + response = fix_and_parse_json(critic) + assert response["success"] in [True, False] + if "critique" not in response: + response["critique"] = "" + logger.info("Task verified successfully.") + return response["success"], response["critique"] + except Exception as e: + logger.error(f"Error verifying the task: {str(e)}") + return await self.run(human_msg, system_msg, max_retries=max_retries-1) + + diff --git a/metagpt/actions/project_management.py b/metagpt/actions/project_management.py index 3096eb94b..b395fa64e 100644 --- a/metagpt/actions/project_management.py +++ b/metagpt/actions/project_management.py @@ -5,13 +5,74 @@ @Author : alexanderwu @File : project_management.py """ -from typing import List, Tuple +from typing import List from metagpt.actions.action import Action +from metagpt.config import CONFIG from metagpt.const import WORKSPACE_ROOT from metagpt.utils.common import CodeParser +from metagpt.utils.get_template import get_template +from metagpt.utils.json_to_markdown import json_to_markdown -PROMPT_TEMPLATE = ''' +templates = { + "json": { + "PROMPT_TEMPLATE": """ +# Context +{context} + +## Format example +{format_example} +----- +Role: You are a project manager; the goal is to break down tasks according to PRD/technical design, give a task list, and analyze task dependencies to start with the prerequisite modules +Requirements: Based on the context, fill in the following missing information, each section name is a key in json. Here the granularity of the task is a file, if there are any missing files, you can supplement them +Attention: Use '##' to split sections, not '#', and '## ' SHOULD WRITE BEFORE the code and triple quote. + +## Required Python third-party packages: Provided in requirements.txt format + +## Required Other language third-party packages: Provided in requirements.txt format + +## Full API spec: Use OpenAPI 3.0. Describe all APIs that may be used by both frontend and backend. + +## Logic Analysis: Provided as a Python list[list[str]. the first is filename, the second is class/method/function should be implemented in this file. Analyze the dependencies between the files, which work should be done first + +## Task list: Provided as Python list[str]. Each str is a filename, the more at the beginning, the more it is a prerequisite dependency, should be done first + +## Shared Knowledge: Anything that should be public like utils' functions, config's variables details that should make clear first. + +## Anything UNCLEAR: Provide as Plain text. Make clear here. For example, don't forget a main entry. don't forget to init 3rd party libs. + +output a properly formatted JSON, wrapped inside [CONTENT][/CONTENT] like format example, +and only output the json inside this tag, nothing else +""", + "FORMAT_EXAMPLE": ''' +{ + "Required Python third-party packages": [ + "flask==1.1.2", + "bcrypt==3.2.0" + ], + "Required Other language third-party packages": [ + "No third-party ..." + ], + "Full API spec": """ + openapi: 3.0.0 + ... + description: A JSON object ... + """, + "Logic Analysis": [ + ["game.py","Contains..."] + ], + "Task list": [ + "game.py" + ], + "Shared Knowledge": """ + 'game.py' contains ... + """, + "Anything UNCLEAR": "We need ... how to start." +} +''', + }, + "markdown": { + "PROMPT_TEMPLATE": """ # Context {context} @@ -28,7 +89,7 @@ Attention: Use '##' to split sections, not '#', and '## ' SHOULD W ## Full API spec: Use OpenAPI 3.0. Describe all APIs that may be used by both frontend and backend. -## Logic Analysis: Provided as a Python list[str, str]. the first is filename, the second is class/method/function should be implemented in this file. Analyze the dependencies between the files, which work should be done first +## Logic Analysis: Provided as a Python list[list[str]. the first is filename, the second is class/method/function should be implemented in this file. Analyze the dependencies between the files, which work should be done first ## Task list: Provided as Python list[str]. Each str is a filename, the more at the beginning, the more it is a prerequisite dependency, should be done first @@ -36,9 +97,8 @@ Attention: Use '##' to split sections, not '#', and '## ' SHOULD W ## Anything UNCLEAR: Provide as Plain text. Make clear here. For example, don't forget a main entry. don't forget to init 3rd party libs. -''' - -FORMAT_EXAMPLE = ''' +""", + "FORMAT_EXAMPLE": ''' --- ## Required Python third-party packages ```python @@ -67,7 +127,7 @@ description: A JSON object ... ## Logic Analysis ```python [ - ("game.py", "Contains ..."), + ["game.py", "Contains ..."], ] ``` @@ -88,13 +148,14 @@ description: A JSON object ... ## Anything UNCLEAR We need ... how to start. --- -''' - +''', + }, +} OUTPUT_MAPPING = { - "Required Python third-party packages": (str, ...), - "Required Other language third-party packages": (str, ...), + "Required Python third-party packages": (List[str], ...), + "Required Other language third-party packages": (List[str], ...), "Full API spec": (str, ...), - "Logic Analysis": (List[Tuple[str, str]], ...), + "Logic Analysis": (List[List[str]], ...), "Task list": (List[str], ...), "Shared Knowledge": (str, ...), "Anything UNCLEAR": (str, ...), @@ -102,22 +163,25 @@ OUTPUT_MAPPING = { class WriteTasks(Action): - def __init__(self, name="CreateTasks", context=None, llm=None): super().__init__(name, context, llm) def _save(self, context, rsp): - ws_name = CodeParser.parse_str(block="Python package name", text=context[-1].content) - file_path = WORKSPACE_ROOT / ws_name / 'docs/api_spec_and_tasks.md' - file_path.write_text(rsp.content) + if context[-1].instruct_content: + ws_name = context[-1].instruct_content.dict()["Python package name"] + else: + ws_name = CodeParser.parse_str(block="Python package name", text=context[-1].content) + file_path = WORKSPACE_ROOT / ws_name / "docs/api_spec_and_tasks.md" + file_path.write_text(json_to_markdown(rsp.instruct_content.dict())) # Write requirements.txt - requirements_path = WORKSPACE_ROOT / ws_name / 'requirements.txt' - requirements_path.write_text(rsp.instruct_content.dict().get("Required Python third-party packages").strip('"\n')) + requirements_path = WORKSPACE_ROOT / ws_name / "requirements.txt" + requirements_path.write_text("\n".join(rsp.instruct_content.dict().get("Required Python third-party packages"))) - async def run(self, context): - prompt = PROMPT_TEMPLATE.format(context=context, format_example=FORMAT_EXAMPLE) - rsp = await self._aask_v1(prompt, "task", OUTPUT_MAPPING) + async def run(self, context, format=CONFIG.prompt_format): + prompt_template, format_example = get_template(templates, format) + prompt = prompt_template.format(context=context, format_example=format_example) + rsp = await self._aask_v1(prompt, "task", OUTPUT_MAPPING, format=format) self._save(context, rsp) return rsp @@ -126,4 +190,3 @@ class AssignTasks(Action): async def run(self, *args, **kwargs): # Here you should implement the actual action pass - \ No newline at end of file diff --git a/metagpt/actions/research.py b/metagpt/actions/research.py deleted file mode 100644 index 81eb876dd..000000000 --- a/metagpt/actions/research.py +++ /dev/null @@ -1,277 +0,0 @@ -#!/usr/bin/env python - -from __future__ import annotations - -import asyncio -import json -from typing import Callable - -from pydantic import parse_obj_as - -from metagpt.actions import Action -from metagpt.config import CONFIG -from metagpt.logs import logger -from metagpt.tools.search_engine import SearchEngine -from metagpt.tools.web_browser_engine import WebBrowserEngine, WebBrowserEngineType -from metagpt.utils.text import generate_prompt_chunk, reduce_message_length - -LANG_PROMPT = "Please respond in {language}." - -RESEARCH_BASE_SYSTEM = """You are an AI critical thinker research assistant. Your sole purpose is to write well \ -written, critically acclaimed, objective and structured reports on the given text.""" - -RESEARCH_TOPIC_SYSTEM = "You are an AI researcher assistant, and your research topic is:\n#TOPIC#\n{topic}" - -SEARCH_TOPIC_PROMPT = """Please provide up to 2 necessary keywords related to your research topic for Google search. \ -Your response must be in JSON format, for example: ["keyword1", "keyword2"].""" - -SUMMARIZE_SEARCH_PROMPT = """### Requirements -1. The keywords related to your research topic and the search results are shown in the "Search Result Information" section. -2. Provide up to {decomposition_nums} queries related to your research topic base on the search results. -3. Please respond in the following JSON format: ["query1", "query2", "query3", ...]. - -### Search Result Information -{search_results} -""" - -COLLECT_AND_RANKURLS_PROMPT = """### Topic -{topic} -### Query -{query} - -### The online search results -{results} - -### Requirements -Please remove irrelevant search results that are not related to the query or topic. Then, sort the remaining search results \ -based on the link credibility. If two results have equal credibility, prioritize them based on the relevance. Provide the -ranked results' indices in JSON format, like [0, 1, 3, 4, ...], without including other words. -""" - -WEB_BROWSE_AND_SUMMARIZE_PROMPT = '''### Requirements -1. Utilize the text in the "Reference Information" section to respond to the question "{query}". -2. If the question cannot be directly answered using the text, but the text is related to the research topic, please provide \ -a comprehensive summary of the text. -3. If the text is entirely unrelated to the research topic, please reply with a simple text "Not relevant." -4. Include all relevant factual information, numbers, statistics, etc., if available. - -### Reference Information -{content} -''' - - -CONDUCT_RESEARCH_PROMPT = '''### Reference Information -{content} - -### Requirements -Please provide a detailed research report in response to the following topic: "{topic}", using the information provided \ -above. The report must meet the following requirements: - -- Focus on directly addressing the chosen topic. -- Ensure a well-structured and in-depth presentation, incorporating relevant facts and figures where available. -- Present data and findings in an intuitive manner, utilizing feature comparative tables, if applicable. -- The report should have a minimum word count of 2,000 and be formatted with Markdown syntax following APA style guidelines. -- Include all source URLs in APA format at the end of the report. -''' - - -class CollectLinks(Action): - """Action class to collect links from a search engine.""" - def __init__( - self, - name: str = "", - *args, - rank_func: Callable[[list[str]], None] | None = None, - **kwargs, - ): - super().__init__(name, *args, **kwargs) - self.desc = "Collect links from a search engine." - self.search_engine = SearchEngine() - self.rank_func = rank_func - - async def run( - self, - topic: str, - decomposition_nums: int = 4, - url_per_query: int = 4, - system_text: str | None = None, - ) -> dict[str, list[str]]: - """Run the action to collect links. - - Args: - topic: The research topic. - decomposition_nums: The number of search questions to generate. - url_per_query: The number of URLs to collect per search question. - system_text: The system text. - - Returns: - A dictionary containing the search questions as keys and the collected URLs as values. - """ - system_text = system_text if system_text else RESEARCH_TOPIC_SYSTEM.format(topic=topic) - keywords = await self._aask(SEARCH_TOPIC_PROMPT, [system_text]) - try: - keywords = json.loads(keywords) - keywords = parse_obj_as(list[str], keywords) - except Exception as e: - logger.exception(f"fail to get keywords related to the research topic \"{topic}\" for {e}") - keywords = [topic] - results = await asyncio.gather(*(self.search_engine.run(i, as_string=False) for i in keywords)) - - def gen_msg(): - while True: - search_results = "\n".join(f"#### Keyword: {i}\n Search Result: {j}\n" for (i, j) in zip(keywords, results)) - prompt = SUMMARIZE_SEARCH_PROMPT.format(decomposition_nums=decomposition_nums, search_results=search_results) - yield prompt - remove = max(results, key=len) - remove.pop() - if len(remove) == 0: - break - prompt = reduce_message_length(gen_msg(), self.llm.model, system_text, CONFIG.max_tokens_rsp) - logger.debug(prompt) - queries = await self._aask(prompt, [system_text]) - try: - queries = json.loads(queries) - queries = parse_obj_as(list[str], queries) - except Exception as e: - logger.exception(f"fail to break down the research question due to {e}") - queries = keywords - ret = {} - for query in queries: - ret[query] = await self._search_and_rank_urls(topic, query, url_per_query) - return ret - - async def _search_and_rank_urls(self, topic: str, query: str, num_results: int = 4) -> list[str]: - """Search and rank URLs based on a query. - - Args: - topic: The research topic. - query: The search query. - num_results: The number of URLs to collect. - - Returns: - A list of ranked URLs. - """ - max_results = max(num_results * 2, 6) - results = await self.search_engine.run(query, max_results=max_results, as_string=False) - _results = "\n".join(f"{i}: {j}" for i, j in zip(range(max_results), results)) - prompt = COLLECT_AND_RANKURLS_PROMPT.format(topic=topic, query=query, results=_results) - logger.debug(prompt) - indices = await self._aask(prompt) - try: - indices = json.loads(indices) - assert all(isinstance(i, int) for i in indices) - except Exception as e: - logger.exception(f"fail to rank results for {e}") - indices = list(range(max_results)) - results = [results[i] for i in indices] - if self.rank_func: - results = self.rank_func(results) - return [i["link"] for i in results[:num_results]] - - -class WebBrowseAndSummarize(Action): - """Action class to explore the web and provide summaries of articles and webpages.""" - def __init__( - self, - *args, - browse_func: Callable[[list[str]], None] | None = None, - **kwargs, - ): - super().__init__(*args, **kwargs) - if CONFIG.model_for_researcher_summary: - self.llm.model = CONFIG.model_for_researcher_summary - self.web_browser_engine = WebBrowserEngine( - engine=WebBrowserEngineType.CUSTOM if browse_func else None, - run_func=browse_func, - ) - self.desc = "Explore the web and provide summaries of articles and webpages." - - async def run( - self, - url: str, - *urls: str, - query: str, - system_text: str = RESEARCH_BASE_SYSTEM, - ) -> dict[str, str]: - """Run the action to browse the web and provide summaries. - - Args: - url: The main URL to browse. - urls: Additional URLs to browse. - query: The research question. - system_text: The system text. - - Returns: - A dictionary containing the URLs as keys and their summaries as values. - """ - contents = await self.web_browser_engine.run(url, *urls) - if not urls: - contents = [contents] - - summaries = {} - prompt_template = WEB_BROWSE_AND_SUMMARIZE_PROMPT.format(query=query, content="{}") - for u, content in zip([url, *urls], contents): - content = content.inner_text - chunk_summaries = [] - for prompt in generate_prompt_chunk(content, prompt_template, self.llm.model, system_text, CONFIG.max_tokens_rsp): - logger.debug(prompt) - summary = await self._aask(prompt, [system_text]) - if summary == "Not relevant.": - continue - chunk_summaries.append(summary) - - if not chunk_summaries: - summaries[u] = None - continue - - if len(chunk_summaries) == 1: - summaries[u] = chunk_summaries[0] - continue - - content = "\n".join(chunk_summaries) - prompt = WEB_BROWSE_AND_SUMMARIZE_PROMPT.format(query=query, content=content) - summary = await self._aask(prompt, [system_text]) - summaries[u] = summary - return summaries - - -class ConductResearch(Action): - """Action class to conduct research and generate a research report.""" - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - if CONFIG.model_for_researcher_report: - self.llm.model = CONFIG.model_for_researcher_report - - async def run( - self, - topic: str, - content: str, - system_text: str = RESEARCH_BASE_SYSTEM, - ) -> str: - """Run the action to conduct research and generate a research report. - - Args: - topic: The research topic. - content: The content for research. - system_text: The system text. - - Returns: - The generated research report. - """ - prompt = CONDUCT_RESEARCH_PROMPT.format(topic=topic, content=content) - logger.debug(prompt) - self.llm.auto_max_tokens = True - return await self._aask(prompt, [system_text]) - - -def get_research_system_text(topic: str, language: str): - """Get the system text for conducting research. - - Args: - topic: The research topic. - language: The language for the system text. - - Returns: - The system text for conducting research. - """ - return " ".join((RESEARCH_TOPIC_SYSTEM.format(topic=topic), LANG_PROMPT.format(language=language))) diff --git a/metagpt/actions/write_docstring.py b/metagpt/actions/write_docstring.py deleted file mode 100644 index 5c7815793..000000000 --- a/metagpt/actions/write_docstring.py +++ /dev/null @@ -1,214 +0,0 @@ -"""Code Docstring Generator. - -This script provides a tool to automatically generate docstrings for Python code. It uses the specified style to create -docstrings for the given code and system text. - -Usage: - python3 -m metagpt.actions.write_docstring [--overwrite] [--style=] - -Arguments: - filename The path to the Python file for which you want to generate docstrings. - -Options: - --overwrite If specified, overwrite the original file with the code containing docstrings. - --style= Specify the style of the generated docstrings. - Valid values: 'google', 'numpy', or 'sphinx'. - Default: 'google' - -Example: - python3 -m metagpt.actions.write_docstring startup.py --overwrite False --style=numpy - -This script uses the 'fire' library to create a command-line interface. It generates docstrings for the given Python code using -the specified docstring style and adds them to the code. -""" -import ast -from typing import Literal - -from metagpt.actions.action import Action -from metagpt.utils.common import OutputParser -from metagpt.utils.pycst import merge_docstring - -PYTHON_DOCSTRING_SYSTEM = '''### Requirements -1. Add docstrings to the given code following the {style} style. -2. Replace the function body with an Ellipsis object(...) to reduce output. -3. If the types are already annotated, there is no need to include them in the docstring. -4. Extract only class, function or the docstrings for the module parts from the given Python code, avoiding any other text. - -### Input Example -```python -def function_with_pep484_type_annotations(param1: int) -> bool: - return isinstance(param1, int) - -class ExampleError(Exception): - def __init__(self, msg: str): - self.msg = msg -``` - -### Output Example -```python -{example} -``` -''' - -# https://www.sphinx-doc.org/en/master/usage/extensions/napoleon.html - -PYTHON_DOCSTRING_EXAMPLE_GOOGLE = ''' -def function_with_pep484_type_annotations(param1: int) -> bool: - """Example function with PEP 484 type annotations. - - Extended description of function. - - Args: - param1: The first parameter. - - Returns: - The return value. True for success, False otherwise. - """ - ... - -class ExampleError(Exception): - """Exceptions are documented in the same way as classes. - - The __init__ method was documented in the class level docstring. - - Args: - msg: Human readable string describing the exception. - - Attributes: - msg: Human readable string describing the exception. - """ - ... -''' - -PYTHON_DOCSTRING_EXAMPLE_NUMPY = ''' -def function_with_pep484_type_annotations(param1: int) -> bool: - """ - Example function with PEP 484 type annotations. - - Extended description of function. - - Parameters - ---------- - param1 - The first parameter. - - Returns - ------- - bool - The return value. True for success, False otherwise. - """ - ... - -class ExampleError(Exception): - """ - Exceptions are documented in the same way as classes. - - The __init__ method was documented in the class level docstring. - - Parameters - ---------- - msg - Human readable string describing the exception. - - Attributes - ---------- - msg - Human readable string describing the exception. - """ - ... -''' - -PYTHON_DOCSTRING_EXAMPLE_SPHINX = ''' -def function_with_pep484_type_annotations(param1: int) -> bool: - """Example function with PEP 484 type annotations. - - Extended description of function. - - :param param1: The first parameter. - :type param1: int - - :return: The return value. True for success, False otherwise. - :rtype: bool - """ - ... - -class ExampleError(Exception): - """Exceptions are documented in the same way as classes. - - The __init__ method was documented in the class level docstring. - - :param msg: Human-readable string describing the exception. - :type msg: str - """ - ... -''' - -_python_docstring_style = { - "google": PYTHON_DOCSTRING_EXAMPLE_GOOGLE.strip(), - "numpy": PYTHON_DOCSTRING_EXAMPLE_NUMPY.strip(), - "sphinx": PYTHON_DOCSTRING_EXAMPLE_SPHINX.strip(), -} - - -class WriteDocstring(Action): - """This class is used to write docstrings for code. - - Attributes: - desc: A string describing the action. - """ - - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) - self.desc = "Write docstring for code." - - async def run( - self, code: str, - system_text: str = PYTHON_DOCSTRING_SYSTEM, - style: Literal["google", "numpy", "sphinx"] = "google", - ) -> str: - """Writes docstrings for the given code and system text in the specified style. - - Args: - code: A string of Python code. - system_text: A string of system text. - style: A string specifying the style of the docstring. Can be 'google', 'numpy', or 'sphinx'. - - Returns: - The Python code with docstrings added. - """ - system_text = system_text.format(style=style, example=_python_docstring_style[style]) - simplified_code = _simplify_python_code(code) - documented_code = await self._aask(f"```python\n{simplified_code}\n```", [system_text]) - documented_code = OutputParser.parse_python_code(documented_code) - return merge_docstring(code, documented_code) - - -def _simplify_python_code(code: str) -> None: - """Simplifies the given Python code by removing expressions and the last if statement. - - Args: - code: A string of Python code. - - Returns: - The simplified Python code. - """ - code_tree = ast.parse(code) - code_tree.body = [i for i in code_tree.body if not isinstance(i, ast.Expr)] - if isinstance(code_tree.body[-1], ast.If): - code_tree.body.pop() - return ast.unparse(code_tree) - - -if __name__ == "__main__": - import fire - - async def run(filename: str, overwrite: bool = False, style: Literal["google", "numpy", "sphinx"] = "google"): - with open(filename) as f: - code = f.read() - code = await WriteDocstring().run(code, style=style) - if overwrite: - with open(filename, "w") as f: - f.write(code) - return code - - fire.Fire(run) diff --git a/metagpt/actions/write_prd.py b/metagpt/actions/write_prd.py index 2b96f867c..bd04ca79e 100644 --- a/metagpt/actions/write_prd.py +++ b/metagpt/actions/write_prd.py @@ -5,13 +5,102 @@ @Author : alexanderwu @File : write_prd.py """ -from typing import List, Tuple +from typing import List from metagpt.actions import Action, ActionOutput from metagpt.actions.search_and_summarize import SearchAndSummarize +from metagpt.config import CONFIG from metagpt.logs import logger +from metagpt.utils.get_template import get_template -PROMPT_TEMPLATE = """ +templates = { + "json": { + "PROMPT_TEMPLATE": """ +# Context +## Original Requirements +{requirements} + +## Search Information +{search_information} + +## mermaid quadrantChart code syntax example. DONT USE QUOTO IN CODE DUE TO INVALID SYNTAX. Replace the with REAL COMPETITOR NAME +```mermaid +quadrantChart + title Reach and engagement of campaigns + x-axis Low Reach --> High Reach + y-axis Low Engagement --> High Engagement + quadrant-1 We should expand + quadrant-2 Need to promote + quadrant-3 Re-evaluate + quadrant-4 May be improved + "Campaign: A": [0.3, 0.6] + "Campaign B": [0.45, 0.23] + "Campaign C": [0.57, 0.69] + "Campaign D": [0.78, 0.34] + "Campaign E": [0.40, 0.34] + "Campaign F": [0.35, 0.78] + "Our Target Product": [0.5, 0.6] +``` + +## Format example +{format_example} +----- +Role: You are a professional product manager; the goal is to design a concise, usable, efficient product +Requirements: According to the context, fill in the following missing information, each section name is a key in json ,If the requirements are unclear, ensure minimum viability and avoid excessive design + +## Original Requirements: Provide as Plain text, place the polished complete original requirements here + +## Product Goals: Provided as Python list[str], up to 3 clear, orthogonal product goals. If the requirement itself is simple, the goal should also be simple + +## User Stories: Provided as Python list[str], up to 5 scenario-based user stories, If the requirement itself is simple, the user stories should also be less + +## Competitive Analysis: Provided as Python list[str], up to 7 competitive product analyses, consider as similar competitors as possible + +## Competitive Quadrant Chart: Use mermaid quadrantChart code syntax. up to 14 competitive products. Translation: Distribute these competitor scores evenly between 0 and 1, trying to conform to a normal distribution centered around 0.5 as much as possible. + +## Requirement Analysis: Provide as Plain text. Be simple. LESS IS MORE. Make your requirements less dumb. Delete the parts unnessasery. + +## Requirement Pool: Provided as Python list[list[str], the parameters are requirement description, priority(P0/P1/P2), respectively, comply with PEP standards; no more than 5 requirements and consider to make its difficulty lower + +## UI Design draft: Provide as Plain text. Be simple. Describe the elements and functions, also provide a simple style description and layout description. +## Anything UNCLEAR: Provide as Plain text. Make clear here. + +output a properly formatted JSON, wrapped inside [CONTENT][/CONTENT] like format example, +and only output the json inside this tag, nothing else +""", + "FORMAT_EXAMPLE": """ +[CONTENT] +{ + "Original Requirements": "", + "Search Information": "", + "Requirements": "", + "Product Goals": [], + "User Stories": [], + "Competitive Analysis": [], + "Competitive Quadrant Chart": "quadrantChart + title Reach and engagement of campaigns + x-axis Low Reach --> High Reach + y-axis Low Engagement --> High Engagement + quadrant-1 We should expand + quadrant-2 Need to promote + quadrant-3 Re-evaluate + quadrant-4 May be improved + Campaign A: [0.3, 0.6] + Campaign B: [0.45, 0.23] + Campaign C: [0.57, 0.69] + Campaign D: [0.78, 0.34] + Campaign E: [0.40, 0.34] + Campaign F: [0.35, 0.78]", + "Requirement Analysis": "", + "Requirement Pool": [["P0","P0 requirement"],["P1","P1 requirement"]], + "UI Design draft": "", + "Anything UNCLEAR": "", +} +[/CONTENT] +""", + }, + "markdown": { + "PROMPT_TEMPLATE": """ # Context ## Original Requirements {requirements} @@ -57,12 +146,12 @@ ATTENTION: Use '##' to SPLIT SECTIONS, not '#'. AND '## ' SHOULD W ## Requirement Analysis: Provide as Plain text. Be simple. LESS IS MORE. Make your requirements less dumb. Delete the parts unnessasery. -## Requirement Pool: Provided as Python list[str, str], the parameters are requirement description, priority(P0/P1/P2), respectively, comply with PEP standards; no more than 5 requirements and consider to make its difficulty lower +## Requirement Pool: Provided as Python list[list[str], the parameters are requirement description, priority(P0/P1/P2), respectively, comply with PEP standards; no more than 5 requirements and consider to make its difficulty lower ## UI Design draft: Provide as Plain text. Be simple. Describe the elements and functions, also provide a simple style description and layout description. ## Anything UNCLEAR: Provide as Plain text. Make clear here. -""" -FORMAT_EXAMPLE = """ +""", + "FORMAT_EXAMPLE": """ --- ## Original Requirements The boss ... @@ -102,7 +191,7 @@ The product should be a ... ## Requirement Pool ```python [ - ("End game ...", "P0") + ["End game ...", "P0"] ] ``` @@ -112,7 +201,10 @@ Give a basic function description, and a draft ## Anything UNCLEAR There are no unclear points. --- -""" +""", + }, +} + OUTPUT_MAPPING = { "Original Requirements": (str, ...), "Product Goals": (List[str], ...), @@ -120,8 +212,8 @@ OUTPUT_MAPPING = { "Competitive Analysis": (List[str], ...), "Competitive Quadrant Chart": (str, ...), "Requirement Analysis": (str, ...), - "Requirement Pool": (List[Tuple[str, str]], ...), - "UI Design draft":(str, ...), + "Requirement Pool": (List[List[str]], ...), + "UI Design draft": (str, ...), "Anything UNCLEAR": (str, ...), } @@ -130,7 +222,7 @@ class WritePRD(Action): def __init__(self, name="", context=None, llm=None): super().__init__(name, context, llm) - async def run(self, requirements, *args, **kwargs) -> ActionOutput: + async def run(self, requirements, format=CONFIG.prompt_format, *args, **kwargs) -> ActionOutput: sas = SearchAndSummarize() # rsp = await sas.run(context=requirements, system_text=SEARCH_AND_SUMMARIZE_SYSTEM_EN_US) rsp = "" @@ -139,9 +231,11 @@ class WritePRD(Action): logger.info(sas.result) logger.info(rsp) - prompt = PROMPT_TEMPLATE.format(requirements=requirements, search_information=info, - format_example=FORMAT_EXAMPLE) + prompt_template, format_example = get_template(templates, format) + prompt = prompt_template.format( + requirements=requirements, search_information=info, format_example=format_example + ) logger.debug(prompt) - prd = await self._aask_v1(prompt, "prd", OUTPUT_MAPPING) + # prd = await self._aask_v1(prompt, "prd", OUTPUT_MAPPING) + prd = await self._aask_v1(prompt, "prd", OUTPUT_MAPPING, format=format) return prd - \ No newline at end of file diff --git a/metagpt/actions/write_prd_review.py b/metagpt/actions/write_prd_review.py deleted file mode 100644 index 5c922d3bc..000000000 --- a/metagpt/actions/write_prd_review.py +++ /dev/null @@ -1,28 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/11 17:45 -@Author : alexanderwu -@File : write_prd_review.py -""" -from metagpt.actions.action import Action - - -class WritePRDReview(Action): - def __init__(self, name, context=None, llm=None): - super().__init__(name, context, llm) - self.prd = None - self.desc = "Based on the PRD, conduct a PRD Review, providing clear and detailed feedback" - self.prd_review_prompt_template = """ - Given the following Product Requirement Document (PRD): - {prd} - - As a project manager, please review it and provide your feedback and suggestions. - """ - - async def run(self, prd): - self.prd = prd - prompt = self.prd_review_prompt_template.format(prd=self.prd) - review = await self._aask(prompt) - return review - \ No newline at end of file diff --git a/metagpt/actions/write_test.py b/metagpt/actions/write_test.py index ddf65c373..35ff36dc2 100644 --- a/metagpt/actions/write_test.py +++ b/metagpt/actions/write_test.py @@ -6,6 +6,7 @@ @File : environment.py """ from metagpt.actions.action import Action +from metagpt.logs import logger from metagpt.utils.common import CodeParser PROMPT_TEMPLATE = """ @@ -35,7 +36,15 @@ class WriteTest(Action): async def write_code(self, prompt): code_rsp = await self._aask(prompt) - code = CodeParser.parse_code(block="", text=code_rsp) + + try: + code = CodeParser.parse_code(block="", text=code_rsp) + except Exception: + # Handle the exception if needed + logger.error(f"Can't parse the code: {code_rsp}") + + # Return code_rsp in case of an exception, assuming llm just returns code as it is and doesn't wrap it inside ``` + code = code_rsp return code async def run(self, code_to_test, test_file_name, source_file_path, workspace): diff --git a/metagpt/config.py b/metagpt/config.py index 96f402b38..53271133b 100644 --- a/metagpt/config.py +++ b/metagpt/config.py @@ -59,7 +59,7 @@ class Config(metaclass=Singleton): self.openai_api_rpm = self._get("RPM", 3) self.openai_api_model = self._get("OPENAI_API_MODEL", "gpt-4") self.max_tokens_rsp = self._get("MAX_TOKENS", 2048) - self.deployment_name = self._get('DEPLOYMENT_NAME') + self.deployment_name = self._get("DEPLOYMENT_NAME") self.deployment_id = self._get("DEPLOYMENT_ID") self.claude_api_key = self._get("Anthropic_API_KEY") @@ -83,6 +83,10 @@ class Config(metaclass=Singleton): self.calc_usage = self._get("CALC_USAGE", True) self.model_for_researcher_summary = self._get("MODEL_FOR_RESEARCHER_SUMMARY") self.model_for_researcher_report = self._get("MODEL_FOR_RESEARCHER_REPORT") + self.mermaid_engine = self._get("MERMAID_ENGINE", "nodejs") + self.pyppeteer_executable_path = self._get("PYPPETEER_EXECUTABLE_PATH", "") + + self.prompt_format = self._get("PROMPT_FORMAT", "markdown") def _init_with_config_files_and_env(self, configs: dict, yaml_file): """Load from config/key.yaml, config/config.yaml, and env in decreasing order of priority""" @@ -111,4 +115,4 @@ class Config(metaclass=Singleton): return value -CONFIG = Config() \ No newline at end of file +CONFIG = Config() diff --git a/metagpt/const.py b/metagpt/const.py index 16f652186..ba63f0b65 100644 --- a/metagpt/const.py +++ b/metagpt/const.py @@ -12,9 +12,11 @@ def get_project_root(): """Search upwards to find the project root directory.""" current_path = Path.cwd() while True: - if (current_path / '.git').exists() or \ - (current_path / '.project_root').exists() or \ - (current_path / '.gitignore').exists(): + if ( + (current_path / ".git").exists() + or (current_path / ".project_root").exists() + or (current_path / ".gitignore").exists() + ): return current_path parent_path = current_path.parent if parent_path == current_path: @@ -23,15 +25,61 @@ def get_project_root(): PROJECT_ROOT = get_project_root() -DATA_PATH = PROJECT_ROOT / 'data' -WORKSPACE_ROOT = PROJECT_ROOT / 'workspace' -PROMPT_PATH = PROJECT_ROOT / 'metagpt/prompts' -UT_PATH = PROJECT_ROOT / 'data/ut' +DATA_PATH = PROJECT_ROOT / "data" +WORKSPACE_ROOT = PROJECT_ROOT / "workspace" +PROMPT_PATH = PROJECT_ROOT / "metagpt/prompts" +UT_PATH = PROJECT_ROOT / "data/ut" SWAGGER_PATH = UT_PATH / "files/api/" UT_PY_PATH = UT_PATH / "files/ut/" API_QUESTIONS_PATH = UT_PATH / "files/question/" YAPI_URL = "http://yapi.deepwisdomai.com/" -TMP = PROJECT_ROOT / 'tmp' +TMP = PROJECT_ROOT / "tmp" RESEARCH_PATH = DATA_PATH / "research" +TUTORIAL_PATH = DATA_PATH / "tutorial_docx" + +SKILL_DIRECTORY = PROJECT_ROOT / "metagpt/skills" MEM_TTL = 24 * 30 * 3600 + +### MineCraft ### +CKPT_DIR = PROJECT_ROOT / "metagpt/ckpt" +LOG_DIR = PROJECT_ROOT / "logs" + +DEFAULT_WARMUP = { + "context": 15, + "biome": 10, + "time": 15, + "nearby_blocks": 0, + "other_blocks": 10, + "nearby_entities": 5, + "health": 15, + "hunger": 15, + "position": 0, + "equipment": 0, + "inventory": 0, + "optional_inventory_items": 7, + "chests": 0, + "completed_tasks": 0, + "failed_tasks": 0, + } + +CURRICULUM_OB = [ + "context", + "biome", + "time", + "nearby_blocks", + "other_blocks", + "nearby_entities", + "health", + "hunger", + "position", + "equipment", + "inventory", + "chests", + "completed_tasks", + "failed_tasks", + ] + + +CORE_INVENTORY_ITEMS = r".*_log|.*_planks|stick|crafting_table|furnace" +r"|cobblestone|dirt|coal|.*_pickaxe|.*_sword|.*_axe", # curriculum_agent: only show these items in inventory before optional_inventory_items reached in warm up \ No newline at end of file diff --git a/metagpt/document_store/lancedb_store.py b/metagpt/document_store/lancedb_store.py index b366fa650..99c4575a6 100644 --- a/metagpt/document_store/lancedb_store.py +++ b/metagpt/document_store/lancedb_store.py @@ -5,13 +5,15 @@ @Author : unkn-wn (Leon Yee) @File : lancedb_store.py """ +import os +import shutil + import lancedb -import shutil, os class LanceStore: def __init__(self, name): - db = lancedb.connect('./data/lancedb') + db = lancedb.connect("./data/lancedb") self.db = db self.name = name self.table = None @@ -23,16 +25,18 @@ class LanceStore: # .where - SQL syntax filtering for metadata (e.g. where("price > 100")) # .metric - specifies the distance metric to use # .nprobes - values will yield better recall (more likely to find vectors if they exist) at the expense of latency. - if self.table == None: raise Exception("Table not created yet, please add data first.") + if self.table is None: + raise Exception("Table not created yet, please add data first.") - results = self.table \ - .search(query) \ - .limit(n_results) \ - .select(kwargs.get('select')) \ - .where(kwargs.get('where')) \ - .metric(metric) \ - .nprobes(nprobes) \ + results = ( + self.table.search(query) + .limit(n_results) + .select(kwargs.get("select")) + .where(kwargs.get("where")) + .metric(metric) + .nprobes(nprobes) .to_df() + ) return results def persist(self): @@ -45,14 +49,11 @@ class LanceStore: documents = [] for i in range(len(data)): - row = { - 'vector': data[i], - 'id': ids[i] - } + row = {"vector": data[i], "id": ids[i]} row.update(metadatas[i]) documents.append(row) - if self.table != None: + if self.table is not None: self.table.add(documents) else: self.table = self.db.create_table(self.name, documents) @@ -61,13 +62,10 @@ class LanceStore: # This function is for adding individual documents # It assumes you're passing in a single vector embedding, metadata, and id - row = { - 'vector': data, - 'id': _id - } + row = {"vector": data, "id": _id} row.update(metadata) - if self.table != None: + if self.table is not None: self.table.add([row]) else: self.table = self.db.create_table(self.name, [row]) @@ -75,7 +73,8 @@ class LanceStore: def delete(self, _id): # This function deletes a row by id. # LanceDB delete syntax uses SQL syntax, so you can use "in" or "=" - if self.table == None: raise Exception("Table not created yet, please add data first") + if self.table is None: + raise Exception("Table not created yet, please add data first") if isinstance(_id, str): return self.table.delete(f"id = '{_id}'") @@ -85,6 +84,6 @@ class LanceStore: def drop(self, name): # This function drops a table, if it exists. - path = os.path.join(self.db.uri, name + '.lance') + path = os.path.join(self.db.uri, name + ".lance") if os.path.exists(path): - shutil.rmtree(path) \ No newline at end of file + shutil.rmtree(path) diff --git a/metagpt/environment.py b/metagpt/environment.py index 24e6ada2f..8d533d6ca 100644 --- a/metagpt/environment.py +++ b/metagpt/environment.py @@ -7,7 +7,6 @@ """ import asyncio from typing import Iterable - from pydantic import BaseModel, Field from metagpt.memory import Memory diff --git a/metagpt/minecraft_team.py b/metagpt/minecraft_team.py new file mode 100644 index 000000000..5ead788ce --- /dev/null +++ b/metagpt/minecraft_team.py @@ -0,0 +1,386 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 14:14 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +from typing import Iterable, Dict, Any +from pydantic import BaseModel, Field +import requests +import json +import re + +from metagpt.logs import logger +from metagpt.roles import Role +from metagpt.schema import Message +from metagpt.software_company import SoftwareCompany + +from metagpt.actions.minecraft.player_action import PlayerActions +from metagpt.roles.minecraft.minecraft_base import Minecraft +from metagpt.environment import Environment +from metagpt.mineflayer_environment import MineflayerEnv +from metagpt.const import CKPT_DIR +from metagpt.actions.minecraft.control_primitives import load_skills_code + + +class GameEnvironment(BaseModel, arbitrary_types_allowed=True): + """ + 游戏环境的记忆,用于多个agent进行信息的共享和缓存,而不需要重复在自己的角色内维护缓存 + """ + + event: dict[str, Any] = Field(default_factory=dict) + current_task: str = Field(default="Mine 1 wood log") + task_execution_time: float = Field(default=float) + context: str = Field( + default="You can mine one of oak, birch, spruce, jungle, acacia, dark oak, or mangrove logs." + ) + code: str = Field(default="") + program_name: str = Field(default="") + critique: str = Field(default="") + skills: dict = Field(default_factory=dict) # for skills.json + retrieve_skills: list[str] = Field(default_factory=list) + event_summary: str = Field(default="") + + qa_cache: dict[str, str] = Field(default_factory=dict) + completed_tasks: list[str] = Field(default_factory=list) # Critique things + failed_tasks: list[str] = Field(default_factory=list) + + skill_desp: str = Field(default="") + + chest_memory: dict[str, Any] = Field( + default_factory=dict + ) # eg: {'(1344, 64, 1381)': 'Unknown'} + chest_observation: str = Field(default="") # eg: "Chests: None\n\n" + + mf_instance: MineflayerEnv = Field(default_factory=MineflayerEnv) + + @property + def progress(self): + # return len(self.completed_tasks) + 10 # Test only + return len(self.completed_tasks) + + @property + def programs(self): + programs = "" + if self.code == "": + return programs # TODO: maybe fix 10054 now, a better way is isolating env.step() like voyager + for skill_name, entry in self.skills.items(): + programs += f"{entry['code']}\n\n" + for primitives in load_skills_code(): + programs += f"{primitives}\n\n" + return programs + + @property + def warm_up(self): + return self.mf_instance.warm_up + + @property + def core_inv_items_regex(self): + return self.mf_instance.core_inv_items_regex + + def set_mc_port(self, mc_port): + self.mf_instance.set_mc_port(mc_port) + + def set_mc_resume(self, resume: bool = False): # TODO: mv to config + if resume: + logger.info(f"Loading Action Developer from {CKPT_DIR}/action") + with open(f"{CKPT_DIR}/action/chest_memory.json", "r") as f: + self.chest_memory = json.load(f) + + logger.info(f"Loading Curriculum Agent from {CKPT_DIR}/curriculum") + with open(f"{CKPT_DIR}/curriculum/completed_tasks.json", "r") as f: + self.completed_tasks = json.load(f) + with open(f"{CKPT_DIR}/curriculum/failed_tasks.json", "r") as f: + self.failed_tasks = json.load(f) + with open(f"{CKPT_DIR}/curriculum/qa_cache.json", "r") as f: + self.qa_cache = json.load(f) + + logger.info(f"Loading Skill Manager from {CKPT_DIR}/skill\033[0m") + with open(f"{CKPT_DIR}/skill/skills.json", "r") as f: + self.skills = json.load(f) + + def register_roles(self, roles: Iterable[Minecraft]): + for role in roles: + role.set_memory(self) + + def update_event(self, event: Dict): + if self.event == event: + return + self.event = event + self.update_chest_memory(event) + self.event_summary = self.summarize_chatlog(event) + + def update_task(self, task: str): + self.current_task = task + + def update_context(self, context: str): + self.context = context + + def update_code(self, code: str): + self.code = code # action_developer.gen_action_code to HERE + + def update_program_name(self, program_name: str): + self.program_name = program_name + + def update_critique(self, critique: str): + self.critique = critique # critic_agent.check_task_success to HERE + + def append_skill(self, skill: dict): + self.skills[self.program_name] = skill # skill_manager.retrieve_skills to HERE + + def update_retrieve_skills(self, retrieve_skills: list): + self.retrieve_skills = retrieve_skills + + def update_skill_desp(self, skill_desp: str): + self.skill_desp = skill_desp + + def update_chest_memory(self, events: Dict): + """ + Input: events: Dict + Result: self.chest_memory update & save to json + """ + nearbyChests = events[-1][1]["nearbyChests"] + for position, chest in nearbyChests.items(): + if position in self.chest_memory: + if isinstance(chest, dict): + self.chest_memory[position] = chest + if chest == "Invalid": + logger.info(f"Action Developer removing chest {position}: {chest}") + self.chest_memory.pop(position) + else: + if chest != "Invalid": + logger.info(f"Action Developer saving chest {position}: {chest}") + self.chest_memory[position] = chest + with open(f"{CKPT_DIR}/action/chest_memory.json", "w") as f: + json.dump(self.chest_memory, f) + + def update_chest_observation(self): + """ + update chest_memory to chest_observation. + Refer to @ https://github.com/MineDojo/Voyager/blob/main/voyager/agents/action.py + """ + + chests = [] + for chest_position, chest in self.chest_memory.items(): + if isinstance(chest, dict) and len(chest) > 0: + chests.append(f"{chest_position}: {chest}") + for chest_position, chest in self.chest_memory.items(): + if isinstance(chest, dict) and len(chest) == 0: + chests.append(f"{chest_position}: Empty") + for chest_position, chest in self.chest_memory.items(): + if isinstance(chest, str): + assert chest == "Unknown" + chests.append(f"{chest_position}: Unknown items inside") + assert len(chests) == len(self.chest_memory) + if chests: + chests = "\n".join(chests) + self.chest_observation = f"Chests:\n{chests}\n\n" + else: + self.chest_observation = f"Chests: None\n\n" + + def summarize_chatlog(self, events): + def filter_item(message: str): + craft_pattern = r"I cannot make \w+ because I need: (.*)" + craft_pattern2 = ( + r"I cannot make \w+ because there is no crafting table nearby" + ) + mine_pattern = r"I need at least a (.*) to mine \w+!" + if re.match(craft_pattern, message): + return re.match(craft_pattern, message).groups()[0] + elif re.match(craft_pattern2, message): + return "a nearby crafting table" + elif re.match(mine_pattern, message): + return re.match(mine_pattern, message).groups()[0] + else: + return "" + + chatlog = set() + for event_type, event in events: + if event_type == "onChat": + item = filter_item(event["onChat"]) + if item: + chatlog.add(item) + return "I also need " + ", ".join(chatlog) + "." if chatlog else "" + + def update_exploration_progress(self, success: bool): + """ + Split task into completed_tasks or failed_tasks + Args: info = { + "task": self.task, + "success": success, + "conversations": self.conversations, + } + """ + task = self.current_task + if task.startswith("Deposit useless items into the chest at"): + return + if success: + logger.info(f"Completed task {task}.") + self.completed_tasks.append(task) + else: + logger.info(f"Failed to complete task {task}. Skipping to next task.") + self.failed_tasks.append(task) + # TODO: when not success, transform code below to update event!(isolate step soon!) + # if self.reset_placed_if_failed and not success: + # # revert all the placing event in the last step + # blocks = [] + # positions = [] + # for event_type, event in events: + # if event_type == "onSave" and event["onSave"].endswith("_placed"): + # block = event["onSave"].split("_placed")[0] + # position = event["status"]["position"] + # blocks.append(block) + # positions.append(position) + # new_events = self.env.step( + # f"await givePlacedItemBack(bot, {U.json_dumps(blocks)}, {U.json_dumps(positions)})", + # programs=self.skill_manager.programs, + # ) + # events[-1][1]["inventory"] = new_events[-1][1]["inventory"] + # events[-1][1]["voxels"] = new_events[-1][1]["voxels"] + + self.save_sorted_tasks() + + def save_sorted_tasks(self): + updated_completed_tasks = [] + # record repeated failed tasks + updated_failed_tasks = self.failed_tasks + # dedup but keep order + for task in self.completed_tasks: + if task not in updated_completed_tasks: + updated_completed_tasks.append(task) + + # remove completed tasks from failed tasks + for task in updated_completed_tasks: + while task in updated_failed_tasks: + updated_failed_tasks.remove(task) + + self.completed_tasks = updated_completed_tasks + self.failed_tasks = updated_failed_tasks + + # dump to json + with open(f"{CKPT_DIR}/curriculum/completed_tasks.json", "w") as f: + json.dump(self.completed_tasks, f) + with open(f"{CKPT_DIR}/curriculum/failed_tasks.json", "w") as f: + json.dump(self.failed_tasks, f) + + async def on_event(self, *args): + """ + Retrieve Minecraft events. + + This function is used to obtain events from the Minecraft environment. Check the implementation in + the 'voyager/env/bridge.py step()' function to capture events generated within the game. + + Returns: + list: A list of Minecraft events. + + Raises: + Exception: If there is an issue retrieving events. + """ + try: + if not self.mf_instance.has_reset: + # TODO Modify + logger.info("Environment has not been reset yet, is resetting") + self.mf_instance.reset( + options={ + "mode": "soft", + "wait_ticks": 20, + } + ) + # raise {} + self.mf_instance.check_process() + self.mf_instance.unpause() + data = { + "code": self.code, + "programs": self.programs, + } + res = requests.post( + f"{self.mf_instance.server}/step", + json=data, + timeout=self.mf_instance.request_timeout, + ) + if res.status_code != 200: + logger.error("Failed to step Minecraft server") + raise {} + returned_data = res.json() + self.mf_instance.pause() + events = json.loads(returned_data) + logger.info(f"Get Current Event: {events}") + return events + except Exception as e: + logger.error(f"Failed to retrieve Minecraft events: {str(e)}") + raise {} + + +class MinecraftPlayer(SoftwareCompany): + """ + Software Company: Possesses a team, SOP (Standard Operating Procedures), and a platform for instant messaging, + dedicated to writing executable code. + """ + + environment: Environment = Field(default_factory=Environment) + game_memory: GameEnvironment = Field(default_factory=GameEnvironment) + investment: float = Field(default=50.0) + task: str = Field(default="") + game_info: dict = Field(default={}) + + def set_port(self, mc_port): + self.game_memory.set_mc_port(mc_port) + + def set_resume(self, resume: bool = False): + self.game_memory.set_mc_resume(resume=resume) + + def check_complete_round(self): + complete_round = [] + for role in self.environment.roles.values(): + status = role.finish_step + complete_round.append(status) + #if not status: + # return complete_round + #complete_round = True + complete_round_tag = all(complete_round) + logger.info(f"complete_round {complete_round}") + return complete_round_tag + + def update_round(self): + for role in self.environment.roles.values(): + role.finish_step = False + role.round_id+=1 + role._rc.todo = None + logger.info(f"round_id:{role.round_id}") + + def hire(self, roles: list[Role]): + self.environment.add_roles(roles) + self.game_memory.register_roles(roles) + + def start(self, task, round=0): + """Start a project from publishing boss requirement.""" + self.task = task + self.environment.publish_message( + Message(role="Player", content=task, cause_by=PlayerActions, round_id=round) + ) + logger.info(self.game_info) + + def _save(self): + logger.info(self.json()) + + def _reset(self): + for role_profile, role in self.environment.roles.items(): + role.reset_state() + + async def run(self, n_round=3): + """Run company until target round or no money""" + round_id=0 + while n_round > 0: + # self._save() + if self.check_complete_round(): + n_round -= 1 + self.update_round() + round_id+=1 + # add new task into env and continue + #fixme: update self.task + self.start(task=self.task, round=round_id) + + logger.info(f"{n_round=}") + self._check_balance() + await self.environment.run() + #self.environment.memory.clear() + #self._reset() + return self.environment.history diff --git a/metagpt/mineflayer_env/.gitignore b/metagpt/mineflayer_env/.gitignore new file mode 100644 index 000000000..7d06e79c2 --- /dev/null +++ b/metagpt/mineflayer_env/.gitignore @@ -0,0 +1,294 @@ +# MCP-Reborn +MCP-Reborn/ +run/ +*.jar +config.json + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +cover/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +.idea/ + +# Logs +logs +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +package-lock.json \ No newline at end of file diff --git a/metagpt/mineflayer_env/mineflayer/.prettierignore b/metagpt/mineflayer_env/mineflayer/.prettierignore new file mode 100644 index 000000000..1b07c39e9 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/.prettierignore @@ -0,0 +1,3 @@ +# Ignore artifacts: +build +coverage \ No newline at end of file diff --git a/metagpt/mineflayer_env/mineflayer/.prettierrc.json b/metagpt/mineflayer_env/mineflayer/.prettierrc.json new file mode 100644 index 000000000..0a02bcefd --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/.prettierrc.json @@ -0,0 +1,3 @@ +{ + "tabWidth": 4 +} diff --git a/metagpt/mineflayer_env/mineflayer/index.js b/metagpt/mineflayer_env/mineflayer/index.js new file mode 100644 index 000000000..7fb0a8787 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/index.js @@ -0,0 +1,425 @@ +const fs = require("fs"); +const express = require("express"); +const bodyParser = require("body-parser"); +const mineflayer = require("mineflayer"); + +const skills = require("./lib/skillLoader"); +const { initCounter, getNextTime } = require("./lib/utils"); +const obs = require("./lib/observation/base"); +const OnChat = require("./lib/observation/onChat"); +const OnError = require("./lib/observation/onError"); +const { Voxels, BlockRecords } = require("./lib/observation/voxels"); +const Status = require("./lib/observation/status"); +const Inventory = require("./lib/observation/inventory"); +const OnSave = require("./lib/observation/onSave"); +const Chests = require("./lib/observation/chests"); +const { plugin: tool } = require("mineflayer-tool"); + +let bot = null; + +const app = express(); + +app.use(bodyParser.json({ limit: "50mb" })); +app.use(bodyParser.urlencoded({ limit: "50mb", extended: false })); + +app.post("/start", (req, res) => { + if (bot) onDisconnect("Restarting bot"); + bot = null; + console.log(req.body); + bot = mineflayer.createBot({ + host: "localhost", // minecraft server ip + port: req.body.port, // minecraft server port + username: "bot", + disableChatSigning: true, + checkTimeoutInterval: 60 * 60 * 1000, + }); + bot.once("error", onConnectionFailed); + + // Event subscriptions + bot.waitTicks = req.body.waitTicks; + bot.globalTickCounter = 0; + bot.stuckTickCounter = 0; + bot.stuckPosList = []; + bot.iron_pickaxe = false; + + bot.on("kicked", onDisconnect); + + // mounting will cause physicsTick to stop + bot.on("mount", () => { + bot.dismount(); + }); + + bot.once("spawn", async () => { + bot.removeListener("error", onConnectionFailed); + let itemTicks = 1; + if (req.body.reset === "hard") { + bot.chat("/clear @s"); + bot.chat("/kill @s"); + const inventory = req.body.inventory ? req.body.inventory : {}; + const equipment = req.body.equipment + ? req.body.equipment + : [null, null, null, null, null, null]; + for (let key in inventory) { + bot.chat(`/give @s minecraft:${key} ${inventory[key]}`); + itemTicks += 1; + } + const equipmentNames = [ + "armor.head", + "armor.chest", + "armor.legs", + "armor.feet", + "weapon.mainhand", + "weapon.offhand", + ]; + for (let i = 0; i < 6; i++) { + if (i === 4) continue; + if (equipment[i]) { + bot.chat( + `/item replace entity @s ${equipmentNames[i]} with minecraft:${equipment[i]}` + ); + itemTicks += 1; + } + } + } + + if (req.body.position) { + bot.chat( + `/tp @s ${req.body.position.x} ${req.body.position.y} ${req.body.position.z}` + ); + } + + // if iron_pickaxe is in bot's inventory + if ( + bot.inventory.items().find((item) => item.name === "iron_pickaxe") + ) { + bot.iron_pickaxe = true; + } + + const { pathfinder } = require("mineflayer-pathfinder"); + const tool = require("mineflayer-tool").plugin; + const collectBlock = require("mineflayer-collectblock").plugin; + const pvp = require("mineflayer-pvp").plugin; + const minecraftHawkEye = require("minecrafthawkeye"); + bot.loadPlugin(pathfinder); + bot.loadPlugin(tool); + bot.loadPlugin(collectBlock); + bot.loadPlugin(pvp); + bot.loadPlugin(minecraftHawkEye); + + // bot.collectBlock.movements.digCost = 0; + // bot.collectBlock.movements.placeCost = 0; + + obs.inject(bot, [ + OnChat, + OnError, + Voxels, + Status, + Inventory, + OnSave, + Chests, + BlockRecords, + ]); + skills.inject(bot); + + if (req.body.spread) { + bot.chat(`/spreadplayers ~ ~ 0 300 under 80 false @s`); + await bot.waitForTicks(bot.waitTicks); + } + + await bot.waitForTicks(bot.waitTicks * itemTicks); + res.json(bot.observe()); + + initCounter(bot); + bot.chat("/gamerule keepInventory true"); + bot.chat("/gamerule doDaylightCycle false"); + }); + + function onConnectionFailed(e) { + console.log(e); + bot = null; + res.status(400).json({ error: e }); + } + function onDisconnect(message) { + if (bot.viewer) { + bot.viewer.close(); + } + bot.end(); + console.log(message); + bot = null; + } +}); + +app.post("/step", async (req, res) => { + // import useful package + let response_sent = false; + function otherError(err) { + console.log("Uncaught Error"); + bot.emit("error", handleError(err)); + bot.waitForTicks(bot.waitTicks).then(() => { + if (!response_sent) { + response_sent = true; + res.json(bot.observe()); + } + }); + } + + process.on("uncaughtException", otherError); + + const mcData = require("minecraft-data")(bot.version); + mcData.itemsByName["leather_cap"] = mcData.itemsByName["leather_helmet"]; + mcData.itemsByName["leather_tunic"] = + mcData.itemsByName["leather_chestplate"]; + mcData.itemsByName["leather_pants"] = + mcData.itemsByName["leather_leggings"]; + mcData.itemsByName["leather_boots"] = mcData.itemsByName["leather_boots"]; + mcData.itemsByName["lapis_lazuli_ore"] = mcData.itemsByName["lapis_ore"]; + mcData.blocksByName["lapis_lazuli_ore"] = mcData.blocksByName["lapis_ore"]; + const { + Movements, + goals: { + Goal, + GoalBlock, + GoalNear, + GoalXZ, + GoalNearXZ, + GoalY, + GoalGetToBlock, + GoalLookAtBlock, + GoalBreakBlock, + GoalCompositeAny, + GoalCompositeAll, + GoalInvert, + GoalFollow, + GoalPlaceBlock, + }, + pathfinder, + Move, + ComputedPath, + PartiallyComputedPath, + XZCoordinates, + XYZCoordinates, + SafeBlock, + GoalPlaceBlockOptions, + } = require("mineflayer-pathfinder"); + const { Vec3 } = require("vec3"); + + // Set up pathfinder + const movements = new Movements(bot, mcData); + bot.pathfinder.setMovements(movements); + + bot.globalTickCounter = 0; + bot.stuckTickCounter = 0; + bot.stuckPosList = []; + + function onTick() { + bot.globalTickCounter++; + if (bot.pathfinder.isMoving()) { + bot.stuckTickCounter++; + if (bot.stuckTickCounter >= 100) { + onStuck(1.5); + bot.stuckTickCounter = 0; + } + } + } + + bot.on("physicTick", onTick); + + // initialize fail count + let _craftItemFailCount = 0; + let _killMobFailCount = 0; + let _mineBlockFailCount = 0; + let _placeItemFailCount = 0; + let _smeltItemFailCount = 0; + + // Retrieve array form post bod + const code = req.body.code; + const programs = req.body.programs; + bot.cumulativeObs = []; + await bot.waitForTicks(bot.waitTicks); + const r = await evaluateCode(code, programs); + process.off("uncaughtException", otherError); + if (r !== "success") { + bot.emit("error", handleError(r)); + } + await returnItems(); + // wait for last message + await bot.waitForTicks(bot.waitTicks); + if (!response_sent) { + response_sent = true; + res.json(bot.observe()); + } + bot.removeListener("physicTick", onTick); + + async function evaluateCode(code, programs) { + // Echo the code produced for players to see it. Don't echo when the bot code is already producing dialog or it will double echo + try { + await eval("(async () => {" + programs + "\n" + code + "})()"); + return "success"; + } catch (err) { + return err; + } + } + + function onStuck(posThreshold) { + const currentPos = bot.entity.position; + bot.stuckPosList.push(currentPos); + + // Check if the list is full + if (bot.stuckPosList.length === 5) { + const oldestPos = bot.stuckPosList[0]; + const posDifference = currentPos.distanceTo(oldestPos); + + if (posDifference < posThreshold) { + teleportBot(); // execute the function + } + + // Remove the oldest time from the list + bot.stuckPosList.shift(); + } + } + + function teleportBot() { + const blocks = bot.findBlocks({ + matching: (block) => { + return block.type === 0; + }, + maxDistance: 1, + count: 27, + }); + + if (blocks) { + // console.log(blocks.length); + const randomIndex = Math.floor(Math.random() * blocks.length); + const block = blocks[randomIndex]; + bot.chat(`/tp @s ${block.x} ${block.y} ${block.z}`); + } else { + bot.chat("/tp @s ~ ~1.25 ~"); + } + } + + function returnItems() { + bot.chat("/gamerule doTileDrops false"); + const crafting_table = bot.findBlock({ + matching: mcData.blocksByName.crafting_table.id, + maxDistance: 128, + }); + if (crafting_table) { + bot.chat( + `/setblock ${crafting_table.position.x} ${crafting_table.position.y} ${crafting_table.position.z} air destroy` + ); + bot.chat("/give @s crafting_table"); + } + const furnace = bot.findBlock({ + matching: mcData.blocksByName.furnace.id, + maxDistance: 128, + }); + if (furnace) { + bot.chat( + `/setblock ${furnace.position.x} ${furnace.position.y} ${furnace.position.z} air destroy` + ); + bot.chat("/give @s furnace"); + } + if (bot.inventoryUsed() >= 32) { + // if chest is not in bot's inventory + if (!bot.inventory.items().find((item) => item.name === "chest")) { + bot.chat("/give @s chest"); + } + } + // if iron_pickaxe not in bot's inventory and bot.iron_pickaxe + if ( + bot.iron_pickaxe && + !bot.inventory.items().find((item) => item.name === "iron_pickaxe") + ) { + bot.chat("/give @s iron_pickaxe"); + } + bot.chat("/gamerule doTileDrops true"); + } + + function handleError(err) { + let stack = err.stack; + if (!stack) { + return err; + } + console.log(stack); + const final_line = stack.split("\n")[1]; + const regex = /:(\d+):\d+\)/; + + const programs_length = programs.split("\n").length; + let match_line = null; + for (const line of stack.split("\n")) { + const match = regex.exec(line); + if (match) { + const line_num = parseInt(match[1]); + if (line_num >= programs_length) { + match_line = line_num - programs_length; + break; + } + } + } + if (!match_line) { + return err.message; + } + let f_line = final_line.match( + /\((?.*):(?\d+):(?\d+)\)/ + ); + if (f_line && f_line.groups && fs.existsSync(f_line.groups.file)) { + const { file, line, pos } = f_line.groups; + const f = fs.readFileSync(file, "utf8").split("\n"); + // let filename = file.match(/(?<=node_modules\\)(.*)/)[1]; + let source = file + `:${line}\n${f[line - 1].trim()}\n `; + + const code_source = + "at " + + code.split("\n")[match_line - 1].trim() + + " in your code"; + return source + err.message + "\n" + code_source; + } else if ( + f_line && + f_line.groups && + f_line.groups.file.includes("") + ) { + const { file, line, pos } = f_line.groups; + let source = + "Your code" + + `:${match_line}\n${code.split("\n")[match_line - 1].trim()}\n `; + let code_source = ""; + if (line < programs_length) { + source = + "In your program code: " + + programs.split("\n")[line - 1].trim() + + "\n"; + code_source = `at line ${match_line}:${code + .split("\n") + [match_line - 1].trim()} in your code`; + } + return source + err.message + "\n" + code_source; + } + return err.message; + } +}); + +app.post("/stop", (req, res) => { + bot.end(); + res.json({ + message: "Bot stopped", + }); +}); + +app.post("/pause", (req, res) => { + if (!bot) { + res.status(400).json({ error: "Bot not spawned" }); + return; + } + bot.chat("/pause"); + bot.waitForTicks(bot.waitTicks).then(() => { + res.json({ message: "Success" }); + }); +}); + +// Server listening to PORT 3000 + +const DEFAULT_PORT = 3000; +const PORT = process.argv[2] || DEFAULT_PORT; +app.listen(PORT, () => { + console.log(`Server started on port ${PORT}`); +}); diff --git a/metagpt/mineflayer_env/mineflayer/lib/observation/base.js b/metagpt/mineflayer_env/mineflayer/lib/observation/base.js new file mode 100644 index 000000000..b661a24b5 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/lib/observation/base.js @@ -0,0 +1,45 @@ +class Observation { + constructor(bot) { + if (new.target === Observation) { + throw new TypeError( + "Cannot instantiate abstract class Observation" + ); + } + + this.bot = bot; + this.name = "Observation"; + } + + observe() { + throw new TypeError("Method 'observe()' must be implemented."); + } + + reset() {} +} + +function inject(bot, obs_list) { + bot.obsList = []; + bot.cumulativeObs = []; + bot.eventMemory = {}; + obs_list.forEach((obs) => { + bot.obsList.push(new obs(bot)); + }); + bot.event = function (event_name) { + let result = {}; + bot.obsList.forEach((obs) => { + if (obs.name.startsWith("on") && obs.name !== event_name) { + return; + } + result[obs.name] = obs.observe(); + }); + bot.cumulativeObs.push([event_name, result]); + }; + bot.observe = function () { + bot.event("observe"); + const result = bot.cumulativeObs; + bot.cumulativeObs = []; + return JSON.stringify(result); + }; +} + +module.exports = { Observation, inject }; diff --git a/metagpt/mineflayer_env/mineflayer/lib/observation/chests.js b/metagpt/mineflayer_env/mineflayer/lib/observation/chests.js new file mode 100644 index 000000000..842bd171d --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/lib/observation/chests.js @@ -0,0 +1,31 @@ +const { Observation } = require("./base"); + +class Chests extends Observation { + constructor(bot) { + super(bot); + this.name = "nearbyChests"; + this.chestsItems = {}; + bot.on("closeChest", (chestItems, position) => { + this.chestsItems[position] = chestItems; + }); + bot.on("removeChest", (chestPosition) => { + this.chestsItems[chestPosition] = "Invalid"; + }); + } + + observe() { + const chests = this.bot.findBlocks({ + matching: this.bot.registry.blocksByName.chest.id, + maxDistance: 16, + count: 999, + }); + chests.forEach((chest) => { + if (!this.chestsItems.hasOwnProperty(chest)) { + this.chestsItems[chest] = "Unknown"; + } + }); + return this.chestsItems; + } +} + +module.exports = Chests; diff --git a/metagpt/mineflayer_env/mineflayer/lib/observation/inventory.js b/metagpt/mineflayer_env/mineflayer/lib/observation/inventory.js new file mode 100644 index 000000000..0645d1bfa --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/lib/observation/inventory.js @@ -0,0 +1,39 @@ +const { Observation } = require("./base"); + +class Inventory extends Observation { + constructor(bot) { + super(bot); + this.name = "inventory"; + } + + observe() { + return listItems(this.bot); + } +} + +function listItems(bot) { + const items = getInventoryItems(bot); + return items.reduce(itemToDict, {}); +} + +function getInventoryItems(bot) { + const inventory = bot.currentWindow || bot.inventory; + return inventory.items(); +} + +function itemToDict(acc, cur) { + if (cur.name && cur.count) { + //if both name and count property are defined + if (acc[cur.name]) { + //if the item is already in the dict + acc[cur.name] += cur.count; + } else { + //if the item is not in the dict + acc[cur.name] = cur.count; + } + } + return acc; +} + +//export modules +module.exports = Inventory; diff --git a/metagpt/mineflayer_env/mineflayer/lib/observation/onChat.js b/metagpt/mineflayer_env/mineflayer/lib/observation/onChat.js new file mode 100644 index 000000000..54b411e2a --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/lib/observation/onChat.js @@ -0,0 +1,26 @@ +const Observation = require("./base.js").Observation; + +class onChat extends Observation { + constructor(bot) { + super(bot); + this.name = "onChat"; + this.obs = ""; + bot.on("chatEvent", (username, message) => { + // Save entity status to local variable + if (message.startsWith("/")) { + return; + } + + this.obs += message; + this.bot.event(this.name); + }); + } + + observe() { + const result = this.obs; + this.obs = ""; + return result; + } +} + +module.exports = onChat; diff --git a/metagpt/mineflayer_env/mineflayer/lib/observation/onError.js b/metagpt/mineflayer_env/mineflayer/lib/observation/onError.js new file mode 100644 index 000000000..ac8fed9e5 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/lib/observation/onError.js @@ -0,0 +1,22 @@ +const Observation = require("./base.js").Observation; + +class onError extends Observation { + constructor(bot) { + super(bot); + this.name = "onError"; + this.obs = null; + bot.on("error", (err) => { + // Save entity status to local variable + this.obs = err; + this.bot.event(this.name); + }); + } + + observe() { + const result = this.obs; + this.obs = null; + return result; + } +} + +module.exports = onError; diff --git a/metagpt/mineflayer_env/mineflayer/lib/observation/onSave.js b/metagpt/mineflayer_env/mineflayer/lib/observation/onSave.js new file mode 100644 index 000000000..e5983590f --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/lib/observation/onSave.js @@ -0,0 +1,22 @@ +const Observation = require("./base.js").Observation; + +class onSave extends Observation { + constructor(bot) { + super(bot); + this.name = "onSave"; + this.obs = null; + bot.on("save", (eventName) => { + // Save entity status to local variable + this.obs = eventName; + this.bot.event(this.name); + }); + } + + observe() { + const result = this.obs; + this.obs = null; + return result; + } +} + +module.exports = onSave; diff --git a/metagpt/mineflayer_env/mineflayer/lib/observation/status.js b/metagpt/mineflayer_env/mineflayer/lib/observation/status.js new file mode 100644 index 000000000..b031fbcf2 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/lib/observation/status.js @@ -0,0 +1,103 @@ +const Observation = require("./base.js").Observation; + +class Status extends Observation { + constructor(bot) { + super(bot); + this.name = "status"; + } + + observe() { + return { + health: this.bot.health, + food: this.bot.food, + saturation: this.bot.foodSaturation, + oxygen: this.bot.oxygenLevel, + position: this.bot.entity.position, + velocity: this.bot.entity.velocity, + yaw: this.bot.entity.yaw, + pitch: this.bot.entity.pitch, + onGround: this.bot.entity.onGround, + equipment: this.getEquipment(), + name: this.bot.entity.username, + timeSinceOnGround: this.bot.entity.timeSinceOnGround, + isInWater: this.bot.entity.isInWater, + isInLava: this.bot.entity.isInLava, + isInWeb: this.bot.entity.isInWeb, + isCollidedHorizontally: this.bot.entity.isCollidedHorizontally, + isCollidedVertically: this.bot.entity.isCollidedVertically, + biome: this.bot.blockAt(this.bot.entity.position) + ? this.bot.blockAt(this.bot.entity.position).biome.name + : "None", + entities: this.getEntities(), + timeOfDay: this.getTime(), + inventoryUsed: this.bot.inventoryUsed(), + elapsedTime: this.bot.globalTickCounter, + }; + } + + itemToObs(item) { + if (!item) return null; + return item.name; + } + + getTime() { + const timeOfDay = this.bot.time.timeOfDay; + let time = ""; + if (timeOfDay < 1000) { + time = "sunrise"; + } else if (timeOfDay < 6000) { + time = "day"; + } else if (timeOfDay < 12000) { + time = "noon"; + } else if (timeOfDay < 13000) { + time = "sunset"; + } else if (timeOfDay < 18000) { + time = "night"; + } else if (timeOfDay < 22000) { + time = "midnight"; + } else { + time = "sunrise"; + } + return time; + } + + // For each item in equipment, if it exists, return the name of the item + // otherwise return null + getEquipment() { + const slots = this.bot.inventory.slots; + const mainHand = this.bot.heldItem; + return slots + .slice(5, 9) + .concat(mainHand, slots[45]) + .map(this.itemToObs); + } + + getEntities() { + const entities = this.bot.entities; + if (!entities) return {}; + // keep all monsters in one list, keep other mobs in another list + const mobs = {}; + for (const id in entities) { + const entity = entities[id]; + if (!entity.displayName) continue; + if (entity.name === "player" || entity.name === "item") continue; + if (entity.position.distanceTo(this.bot.entity.position) < 32) { + if (!mobs[entity.name]) { + mobs[entity.name] = entity.position.distanceTo( + this.bot.entity.position + ); + } else if ( + mobs[entity.name] > + entity.position.distanceTo(this.bot.entity.position) + ) { + mobs[entity.name] = entity.position.distanceTo( + this.bot.entity.position + ); + } + } + } + return mobs; + } +} + +module.exports = Status; diff --git a/metagpt/mineflayer_env/mineflayer/lib/observation/voxels.js b/metagpt/mineflayer_env/mineflayer/lib/observation/voxels.js new file mode 100644 index 000000000..ecb0c14b7 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/lib/observation/voxels.js @@ -0,0 +1,67 @@ +// Blocks = require("./blocks") +const { Observation } = require("./base"); + +class Voxels extends Observation { + constructor(bot) { + super(bot); + this.name = "voxels"; + } + + observe() { + return Array.from(getSurroundingBlocks(this.bot, 8, 2, 8)); + } +} + +class BlockRecords extends Observation { + constructor(bot) { + super(bot); + this.name = "blockRecords"; + this.records = new Set(); + this.tick = 0; + bot.on("physicsTick", () => { + this.tick++; + if (this.tick >= 100) { + const items = getInventoryItems(this.bot); + getSurroundingBlocks(this.bot, 8, 2, 8).forEach((block) => { + if (!items.has(block)) this.records.add(block); + }); + this.tick = 0; + } + }); + } + + observe() { + return Array.from(this.records); + } + + reset() { + this.records = new Set(); + } +} + +function getSurroundingBlocks(bot, x_distance, y_distance, z_distance) { + const surroundingBlocks = new Set(); + + for (let x = -x_distance; x <= x_distance; x++) { + for (let y = -y_distance; y <= y_distance; y++) { + for (let z = -z_distance; z <= z_distance; z++) { + const block = bot.blockAt(bot.entity.position.offset(x, y, z)); + if (block && block.type !== 0) { + surroundingBlocks.add(block.name); + } + } + } + } + // console.log(surroundingBlocks); + return surroundingBlocks; +} + +function getInventoryItems(bot) { + const items = new Set(); + bot.inventory.items().forEach((item) => { + if (item) items.add(item.name); + }); + return items; +} + +module.exports = { Voxels, BlockRecords }; diff --git a/metagpt/mineflayer_env/mineflayer/lib/skillLoader.js b/metagpt/mineflayer_env/mineflayer/lib/skillLoader.js new file mode 100644 index 000000000..d78cf7820 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/lib/skillLoader.js @@ -0,0 +1,79 @@ +function inject(bot) { + bot._sleep = bot.sleep; + bot.sleep = async (bedBlock) => { + await bot.waitForTicks(20); + await bot._sleep(bedBlock); + await bot.waitForTicks(135); + }; + + bot._fish = bot.fish; + bot.fish = async () => { + if (bot.heldItem?.name !== "fishing_rod") { + bot.chat("I'm not holding a fishing rod!"); + return; + } + let timeout = null; + await Promise.race([ + bot._fish(), + new Promise( + (resolve, reject) => + (timeout = setTimeout(() => { + bot.activateItem(); + reject( + new Error( + "Finishing timeout, make sure you get to and look at a water block!" + ) + ); + }, 60000)) + ), + ]); + clearTimeout(timeout); + await bot.waitForTicks(20); + }; + + bot._consume = bot.consume; + bot.consume = async () => { + // action_count.activateItem++; + await bot._consume(); + await bot.waitForTicks(20); + }; + + bot._useOn = bot.useOn; + bot.useOn = async (entity) => { + if (entity.position.distanceTo(bot.entity.position) > 6) { + bot.chat("Please goto a place near the entity first!"); + return; + } + await bot._useOn(entity); + await bot.waitForTicks(20); + }; + + bot._activateBlock = bot.activateBlock; + bot.activateBlock = async (block) => { + if (block.position.distanceTo(bot.entity.position) > 6) { + bot.chat("Please goto a place near the block first!"); + return; + } + // action_count.activateBlock++; + await bot._activateBlock(block); + }; + + bot._chat = bot.chat; + bot.chat = (message) => { + // action_count.chat++; + bot.emit("chatEvent", "bot", message); + bot._chat(message); + }; + + bot.inventoryUsed = () => { + return bot.inventory.slots.slice(9, 45).filter((item) => item !== null) + .length; + }; + + bot.save = function (eventName) { + bot.emit("save", eventName); + }; +} + +// export all control_primitives +module.exports = { inject }; diff --git a/metagpt/mineflayer_env/mineflayer/lib/utils.js b/metagpt/mineflayer_env/mineflayer/lib/utils.js new file mode 100644 index 000000000..68af30796 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/lib/utils.js @@ -0,0 +1,31 @@ +let gameTimeCounter = 0; +let gameTimeList = []; +const initCounter = (bot) => { + gameTimeList = []; + for (let i = 0; i < 13000; i += 1000) { + gameTimeList.push(i); + } + for (let i = 13000; i < 24000; i += 2000) { + gameTimeList.push(i); + } + const timeOfDay = bot.time.timeOfDay; + for (let i = 0; i < gameTimeList.length; i++) { + if (gameTimeList[i] > timeOfDay) { + gameTimeCounter = i - 1; + break; + } + } +}; + +const getNextTime = () => { + gameTimeCounter++; + if (gameTimeCounter >= gameTimeList.length) { + gameTimeCounter = 0; + } + return gameTimeList[gameTimeCounter]; +}; + +module.exports = { + initCounter, + getNextTime, +}; diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/.gitignore b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/.gitignore new file mode 100644 index 000000000..0578fdca3 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/.gitignore @@ -0,0 +1,107 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# TypeScript v1 declaration files +typings/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variables file +.env +.env.test + +# parcel-bundler cache (https://parceljs.org/) +.cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt +dist + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and *not* Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +lib/ +package-lock.json diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/LICENSE b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/LICENSE new file mode 100644 index 000000000..f2896b56e --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 TheDudeFromCI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/README.md b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/README.md new file mode 100644 index 000000000..555acb761 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/README.md @@ -0,0 +1,89 @@ +

mineflayer-collectblock

+

A small utility plugin for allowing users to collect blocks using a higher level API.

+ +

+ + + + + + +

+ +--- +## This is a modified version to better support Voyager + +## Showcase + +You can see a video of the plugin in action, [here.](https://youtu.be/5T_rcCnNnf4) +The source code of the bot in the video can be seen in the examples folder, [here.](https://github.com/TheDudeFromCI/mineflayer-collectblock/blob/master/examples/collector.js) + +### Description + +This plugin is a wrapper for mineflayer that allows for easier API usage when collecting blocks or item drops. This plugin is designed to reduce some of the boilerplate code based around the act of pathfinding to a block _(handled by_ ***mineflayer-pathfinder***_)_, selecting the best tool to mine that block _(handled by_ ***mineflayer-tool***_)_, actually mining it, then moving to collect the item drops from that block. This plugin allows for all of that basic concept to be wrapped up into a single API function. + +In addition to the usage above, some additional quality of life features are available in this plugin. These include the ability to automatically deposit items into a chest when the bot's inventory is full, collecting new tools from a chest if the bot doesn't currently have a required tool _(also handled by_ ***mineflayer-tool***_)_, and allowing for queueing of multiple blocks or item drops to the collection task, so they can be processed later. + +### Getting Started + +This plugin is built using Node and can be installed using: +```bash +npm install --save mineflayer-collectblock +``` + +### Simple Bot + +The brief description goes here. + +```js +// Create your bot +const mineflayer = require("mineflayer") +const bot = mineflayer.createBot({ + host: 'localhost', + username: 'Player', +}) +let mcData + +// Load collect block +bot.loadPlugin(require('mineflayer-collectblock').plugin) + +async function collectGrass() { + // Find a nearby grass block + const grass = bot.findBlock({ + matching: mcData.blocksByName.grass_block.id, + maxDistance: 64 + }) + + if (grass) { + // If we found one, collect it. + try { + await bot.collectBlock.collect(grass) + collectGrass() // Collect another grass block + } catch (err) { + console.log(err) // Handle errors, if any + } + } +} + +// On spawn, start collecting all nearby grass +bot.once('spawn', () => { + mcData = require('minecraft-data')(bot.version) + collectGrass() +}) +``` + +### Documentation + +[API](https://github.com/TheDudeFromCI/mineflayer-collectblock/blob/master/docs/api.md) + +[Examples](https://github.com/TheDudeFromCI/mineflayer-collectblock/tree/master/examples) + +### License + +This project uses the [MIT](https://github.com/TheDudeFromCI/mineflayer-collectblock/blob/master/LICENSE) license. + +### Contributions + +This project is accepting PRs and Issues. See something you think can be improved? Go for it! Any and all help is highly appreciated! + +For larger changes, it is recommended to discuss these changes in the issues tab before writing any code. It's also preferred to make many smaller PRs than one large one, where applicable. diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/_config.yml b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/_config.yml new file mode 100644 index 000000000..c4192631f --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/_config.yml @@ -0,0 +1 @@ +theme: jekyll-theme-cayman \ No newline at end of file diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/docs/api.md b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/docs/api.md new file mode 100644 index 000000000..66d8a3ecc --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/docs/api.md @@ -0,0 +1,52 @@ +# API + +Welcome to the *mineflayer-collectblock* API documentation page. + +## Table of Contents + +- [1. Summary](#1-summary) +- [Properties](#properties) + - [`bot.collectblock.movements: Movements`](#botcollectblockmovements-movements) +- [Functions](#functions) + - [collect](#collect) + - [Options:](#options) + +## 1. Summary + +The collect block plugin is a utility plugin that can be used to help make collecting blocks and item drops very easy, using only a single API call. No need to worry about pathfinding to the block, selecting the right tool, or moving to pick up the item drop after mining. + +## Properties + +### `bot.collectblock.movements: Movements` + +The movements object used by the pathfinder plugin to define the movement configuration. This object is passed to the pathfinder plugin when any API from this plugin is called in order to control how pathfinding should work when collecting the given blocks or item. + +If set to null, the pathfinder plugin movements is not updated. + +Defaults to a new movements object instance. + +## Functions + +### collect + +Usage: `bot.collectblock.collect(target: Collectable | Collectable[], options?: CollectOptions, cb: (err?: Error) => void): void` + +Causes the bot to collect the given block, item drop, or list of those. If the target is a block, the bot will move to the block, mine it, and pick up the item drop. If the target is an item drop, the bot will move to the item drop and pick it up. If the target is a list of collectables, the bot will move from target to target in order of closest to furthest and collect each target in turn. + +#### Options: + + * `append: boolean` + + If true, the target(s) will be appended to the existing target list instead of starting a new task. Defaults to false. + + * `ignoreNoPath: boolean` + + If true, errors will not be thrown when a path to the target block cannot be found. The bot will attempt to choose the best available position it can find, instead. Errors are still thrown if the bot cannot interact with the block from it's final location. Defaults to false. + + * `chestLocations: Vec3[]` + + Gets the list of chest locations to use when storing items after the bot's inventory becomes full. If undefined, it defaults to the chest location list on the bot.collectBlock plugin. + + * `itemFilter: ItemFilter` + + When transferring items to a chest, this filter is used to determine what items are allowed to be moved, and what items aren't allowed to be moved. Defaults to the item filter specified on the bot.collectBlock plugin. \ No newline at end of file diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/examples/collector.js b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/examples/collector.js new file mode 100644 index 000000000..b9bb8faf9 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/examples/collector.js @@ -0,0 +1,70 @@ +/** + * This bot example show how to direct a bot to collect a specific block type + * or a group of nearby blocks of that type. + */ + +const mineflayer = require('mineflayer') +const collectBlock = require('mineflayer-collectblock').plugin + +if (process.argv.length < 4 || process.argv.length > 6) { + console.log('Usage : node collector.js [] []') + process.exit(1) +} + +const bot = mineflayer.createBot({ + host: process.argv[2], + port: process.argv[3], + username: process.argv[4] || 'collector', + password: process.argv[5] +}) + +bot.loadPlugin(collectBlock) + +let mcData +bot.once('spawn', () => { + mcData = require('minecraft-data')(bot.version) +}) + +bot.on('chat', async (username, message) => { + const args = message.split(' ') + if (args[0] !== 'collect') return + + let count = 1 + if (args.length === 3) count = parseInt(args[1]) + + let type = args[1] + if (args.length === 3) type = args[2] + + const blockType = mcData.blocksByName[type] + if (!blockType) { + return + } + + const blocks = bot.findBlocks({ + matching: blockType.id, + maxDistance: 64, + count: count + }) + + if (blocks.length === 0) { + bot.chat("I don't see that block nearby.") + return + } + + const targets = [] + for (let i = 0; i < Math.min(blocks.length, count); i++) { + targets.push(bot.blockAt(blocks[i])) + } + + bot.chat(`Found ${targets.length} ${type}(s)`) + + try { + await bot.collectBlock.collect(targets) + // All blocks have been collected. + bot.chat('Done') + } catch (err) { + // An error occurred, report it. + bot.chat(err.message) + console.log(err) + } +}) diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/examples/oreMiner.js b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/examples/oreMiner.js new file mode 100644 index 000000000..6accac88f --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/examples/oreMiner.js @@ -0,0 +1,59 @@ +/** + * This bot example shows how to collect a vein of ores quickly after only finding a single block. + * This makes it easy to collect a vein of ores or mine a tree without looking for every block in the + * area. + */ + +const mineflayer = require('mineflayer') +const collectBlock = require('mineflayer-collectblock').plugin + +if (process.argv.length < 4 || process.argv.length > 6) { + console.log('Usage : node oreMiner.js [] []') + process.exit(1) +} + +const bot = mineflayer.createBot({ + host: process.argv[2], + port: process.argv[3], + username: process.argv[4] || 'oreMiner', + password: process.argv[5] +}) + +bot.loadPlugin(collectBlock) + +let mcData +bot.once('spawn', () => { + mcData = require('minecraft-data')(bot.version) +}) + +bot.on('chat', async (username, message) => { + const args = message.split(' ') + if (args[0] !== 'collect') return + + const blockType = mcData.blocksByName[args[1]] + if (!blockType) { + bot.chat(`I don't know any blocks named ${args[1]}.`) + return + } + + const block = bot.findBlock({ + matching: blockType.id, + maxDistance: 64 + }) + + if (!block) { + bot.chat("I don't see that block nearby.") + return + } + + const targets = bot.collectBlock.findFromVein(block) + try { + await bot.collectBlock.collect(targets) + // All blocks have been collected. + bot.chat('Done') + } catch (err) { + // An error occurred, report it. + bot.chat(err.message) + console.log(err) + } +}) diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/examples/storageBot.js b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/examples/storageBot.js new file mode 100644 index 000000000..b6f9971f2 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/examples/storageBot.js @@ -0,0 +1,107 @@ +/** + * This bot example shows how to use the chest filling mechanic of the plugin. + * Simply provide a given storage chest, and the bot will automatically try and + * store it's inventory in that chest when the bot's inventory becomes full. + */ + +if (process.argv.length < 4 || process.argv.length > 6) { + console.log('Usage : node storageBot.js [] []') + process.exit(1) +} + +// Load your libraries +const mineflayer = require('mineflayer') +const collectBlock = require('mineflayer-collectblock').plugin + +// Create your bot +const bot = mineflayer.createBot({ + host: process.argv[2], + port: parseInt(process.argv[3]), + username: process.argv[4] ? process.argv[4] : 'storageBot', + password: process.argv[5] +}) + +// Load the collect block plugin +bot.loadPlugin(collectBlock) + +// Load mcData on login +let mcData +bot.once('login', () => { + mcData = require('minecraft-data')(bot.version) +}) + +// On spawn, try to find any nearby chests and save those as storage locations. +// When the bot's inventory becomes too full, it will empty it's inventory into +// these chests before collecting more resources. If a chest gets full, it moves +// to the next one in order until it's inventory is empty or it runs out of chests. +bot.once('spawn', () => { + bot.collectBlock.chestLocations = bot.findBlocks({ + matching: mcData.blocksByName.chest.id, + maxDistance: 16, + count: 999999 // Get as many chests as we can + }) + + if (bot.collectBlock.chestLocations.length === 0) { + bot.chat("I don't see any chests nearby.") + } else { + for (const chestPos of bot.collectBlock.chestLocations) { + bot.chat(`I found a chest at ${chestPos}`) + } + } +}) + +// Wait for someone to say something +bot.on('chat', async (username, message) => { + // If the player says something start starts with "collect" + // Otherwise, do nothing + const args = message.split(' ') + if (args[0] !== 'collect') return + + // If the player specifies a number, collect that many. Otherwise, default to 1. + let count = 1 + if (args.length === 3) count = parseInt(args[1]) + + // If a number was given the item number is the 3rd arg, not the 2nd. + let type = args[1] + if (args.length === 3) type = args[2] + + // Get the id of that block type for this version of Minecraft. + const blockType = mcData.blocksByName[type] + if (!blockType) { + bot.chat(`I don't know any blocks named ${type}.`) + return + } + + // Find all nearby blocks of that type, up to the given count, within 64 blocks. + const blocks = bot.findBlocks({ + matching: blockType.id, + maxDistance: 64, + count: count + }) + + // Complain if we can't find any nearby blocks of that type. + if (blocks.length === 0) { + bot.chat("I don't see that block nearby.") + return + } + + // Convert the block position array into a block array to pass to collect block. + const targets = [] + for (let i = 0; i < Math.min(blocks.length, count); i++) { + targets.push(bot.blockAt(blocks[i])) + } + + // Announce what we found. + bot.chat(`Found ${targets.length} ${type}(s)`) + + // Tell the bot to collect all of the given blocks in the block list. + try { + await bot.collectBlock.collect(targets) + // All blocks have been collected. + bot.chat('Done') + } catch (err) { + // An error occurred, report it. + bot.chat(err.message) + console.log(err) + } +}) diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/package.json b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/package.json new file mode 100644 index 000000000..0f59e7aa6 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/package.json @@ -0,0 +1,44 @@ +{ + "name": "mineflayer-collectblock", + "version": "1.4.1", + "description": "A simple utility plugin for Mineflayer that add a higher level API for collecting blocks.", + "main": "lib/index.js", + "types": "lib/index.d.ts", + "scripts": { + "build": "ts-standard && tsc && require-self", + "clean": "rm -rf lib", + "test": "test" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/TheDudeFromCI/mineflayer-collectblock.git" + }, + "keywords": [ + "mineflayer", + "plugin", + "api", + "utility", + "helper", + "collect" + ], + "author": "TheDudeFromCI", + "license": "MIT", + "bugs": { + "url": "https://github.com/TheDudeFromCI/mineflayer-collectblock/issues" + }, + "homepage": "https://github.com/TheDudeFromCI/mineflayer-collectblock#readme", + "dependencies": { + "mineflayer": "^4.0.0", + "mineflayer-pathfinder": "^2.1.1", + "mineflayer-tool": "^1.1.0" + }, + "devDependencies": { + "@types/node": "^18.6.4", + "require-self": "^0.2.3", + "ts-standard": "^11.0.0", + "typescript": "^4.1.3" + }, + "files": [ + "lib/**/*" + ] +} diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/BlockVeins.ts b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/BlockVeins.ts new file mode 100644 index 000000000..ae5542ce3 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/BlockVeins.ts @@ -0,0 +1,35 @@ +import { Bot } from 'mineflayer' +import { Block } from 'prismarine-block' + +export function findFromVein (bot: Bot, block: Block, maxBlocks: number, maxDistance: number, floodRadius: number): Block[] { + const targets: Block[] = [] + const open: Block[] = [block] + const type = block.type + const center = block.position + + for (let i = 0; i < maxBlocks; i++) { + const next = open.pop() + if (next == null) break + + targets.push(next) + + for (let x = -floodRadius; x <= floodRadius; x++) { + for (let y = -floodRadius; y <= floodRadius; y++) { + for (let z = -floodRadius; z <= floodRadius; z++) { + const neighborPos = next.position.offset(x, y, z) + if (neighborPos.manhattanDistanceTo(center) > maxDistance) continue + + const neighbor = bot.blockAt(neighborPos) + if (neighbor == null || neighbor.type !== type) continue + + if (targets.includes(neighbor)) continue + if (open.includes(neighbor)) continue + + open.push(neighbor) + } + } + } + } + + return targets +} diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/CollectBlock.ts b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/CollectBlock.ts new file mode 100644 index 000000000..d2be87822 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/CollectBlock.ts @@ -0,0 +1,451 @@ +import { Bot } from "mineflayer"; +import { Block } from "prismarine-block"; +import { Movements, goals } from "mineflayer-pathfinder"; +import { TemporarySubscriber } from "./TemporarySubscriber"; +import { Entity } from "prismarine-entity"; +import { error } from "./Util"; +import { Vec3 } from "vec3"; +import { emptyInventoryIfFull, ItemFilter } from "./Inventory"; +import { findFromVein } from "./BlockVeins"; +import { Collectable, Targets } from "./Targets"; +import { Item } from "prismarine-item"; +import mcDataLoader from "minecraft-data"; +import { once } from "events"; +import { callbackify } from "util"; + +export type Callback = (err?: Error) => void; + +async function collectAll( + bot: Bot, + options: CollectOptionsFull +): Promise { + let success_count = 0; + while (!options.targets.empty) { + await emptyInventoryIfFull( + bot, + options.chestLocations, + options.itemFilter + ); + const closest = options.targets.getClosest(); + if (closest == null) break; + switch (closest.constructor.name) { + case "Block": { + try { + if (success_count >= options.count) { + break; + } + await bot.tool.equipForBlock( + closest as Block, + equipToolOptions + ); + const goal = new goals.GoalLookAtBlock( + closest.position, + bot.world + ); + await bot.pathfinder.goto(goal); + await mineBlock(bot, closest as Block, options); + success_count++; + // TODO: options.ignoreNoPath + } catch (err) { + // @ts-ignore + // console.log(err.stack) + // bot.pathfinder.stop() + // bot.waitForTicks(10) + try { + bot.pathfinder.setGoal(null); + } catch (err) {} + if (options.ignoreNoPath) { + // @ts-ignore + if (err.name === "Invalid block") { + console.log( + `Block ${closest.name} at ${closest.position} is not valid! Skip it!` + ); + } // @ts-ignore + else if (err.name === "Unsafe block") { + console.log( + `${closest.name} at ${closest.position} is not safe to break! Skip it!` + ); + // @ts-ignore + } else if (err.name === "NoItem") { + const properties = + bot.registry.blocksByName[closest.name]; + const leastTool = Object.keys( + properties.harvestTools + )[0]; + const item = bot.registry.items[leastTool]; + bot.chat( + `I need at least a ${item.name} to mine ${closest.name}! Skip it!` + ); + return; + } else if ( + // @ts-ignore + err.name === "NoPath" || + // @ts-ignore + err.name === "Timeout" + ) { + if ( + bot.entity.position.distanceTo( + closest.position + ) < 0.5 + ) { + await mineBlock(bot, closest as Block, options); + break; + } + console.log( + `No path to ${closest.name} at ${closest.position}! Skip it!` + ); + // @ts-ignore + } else if (err.message === "Digging aborted") { + console.log(`Digging aborted! Skip it!`); + } else { + // @ts-ignore + bot.chat(`Error: ${err.message}`); + } + break; + } + throw err; + } + break; + } + case "Entity": { + // Don't collect any entities that are marked as 'invalid' + if (!(closest as Entity).isValid) break; + try { + const tempEvents = new TemporarySubscriber(bot); + const waitForPickup = new Promise( + (resolve, reject) => { + const timeout = setTimeout(() => { + // After 10 seconds, reject the promise + clearTimeout(timeout); + tempEvents.cleanup(); + reject(new Error("Failed to pickup item")); + }, 10000); + tempEvents.subscribeTo( + "entityGone", + (entity: Entity) => { + if (entity === closest) { + clearTimeout(timeout); + tempEvents.cleanup(); + resolve(); + } + } + ); + } + ); + bot.pathfinder.setGoal( + new goals.GoalFollow(closest as Entity, 0) + ); + // await bot.pathfinder.goto(new goals.GoalBlock(closest.position.x, closest.position.y, closest.position.z)) + await waitForPickup; + } catch (err) { + // @ts-ignore + console.log(err.stack); + try { + bot.pathfinder.setGoal(null); + } catch (err) {} + if (options.ignoreNoPath) { + // @ts-ignore + if (err.message === "Failed to pickup item") { + bot.chat(`Failed to pickup item! Skip it!`); + } + break; + } + throw err; + } + break; + } + default: { + throw error( + "UnknownType", + `Target ${closest.constructor.name} is not a Block or Entity!` + ); + } + } + options.targets.removeTarget(closest); + } + bot.chat(`Collect finish!`); +} + +const equipToolOptions = { + requireHarvest: true, + getFromChest: false, + maxTools: 2, +}; + +async function mineBlock( + bot: Bot, + block: Block, + options: CollectOptionsFull +): Promise { + if ( + bot.blockAt(block.position)?.type !== block.type || + bot.blockAt(block.position)?.type === 0 + ) { + options.targets.removeTarget(block); + throw error("Invalid block", "Block is not valid!"); + // @ts-expect-error + } else if (!bot.pathfinder.movements.safeToBreak(block)) { + options.targets.removeTarget(block); + throw error("Unsafe block", "Block is not safe to break!"); + } + + await bot.tool.equipForBlock(block, equipToolOptions); + + if (!block.canHarvest(bot.heldItem ? bot.heldItem.type : bot.heldItem)) { + options.targets.removeTarget(block); + throw error("NoItem", "Bot does not have a harvestable tool!"); + } + + const tempEvents = new TemporarySubscriber(bot); + tempEvents.subscribeTo("itemDrop", (entity: Entity) => { + if ( + entity.position.distanceTo(block.position.offset(0.5, 0.5, 0.5)) <= + 0.5 + ) { + options.targets.appendTarget(entity); + } + }); + try { + await bot.dig(block); + // Waiting for items to drop + await new Promise((resolve) => { + let remainingTicks = 10; + tempEvents.subscribeTo("physicTick", () => { + remainingTicks--; + if (remainingTicks <= 0) { + tempEvents.cleanup(); + resolve(); + } + }); + }); + } finally { + tempEvents.cleanup(); + } +} + +/** + * A set of options to apply when collecting the given targets. + */ +export interface CollectOptions { + /** + * If true, the target(s) will be appended to the existing target list instead of + * starting a new task. Defaults to false. + */ + append?: boolean; + + /** + * If true, errors will not be thrown when a path to the target block cannot + * be found. The bot will attempt to choose the best available position it + * can find, instead. Errors are still thrown if the bot cannot interact with + * the block from it's final location. Defaults to false. + */ + ignoreNoPath?: boolean; + + /** + * Gets the list of chest locations to use when storing items after the bot's + * inventory becomes full. If undefined, it defaults to the chest location + * list on the bot.collectBlock plugin. + */ + chestLocations?: Vec3[]; + + /** + * When transferring items to a chest, this filter is used to determine what + * items are allowed to be moved, and what items aren't allowed to be moved. + * Defaults to the item filter specified on the bot.collectBlock plugin. + */ + itemFilter?: ItemFilter; + + /** + * The total number of items to collect + */ + count?: number; +} + +/** + * A version of collect options where all values are assigned. + */ +interface CollectOptionsFull { + append: boolean; + ignoreNoPath: boolean; + chestLocations: Vec3[]; + itemFilter: ItemFilter; + targets: Targets; + count: number; +} + +/** + * The collect block plugin. + */ +export class CollectBlock { + /** + * The bot. + */ + private readonly bot: Bot; + + /** + * The list of active targets being collected. + */ + private readonly targets: Targets; + + /** + * The movements configuration to be sent to the pathfinder plugin. + */ + movements?: Movements; + + /** + * A list of chest locations which the bot is allowed to empty their inventory into + * if it becomes full while the bot is collecting resources. + */ + chestLocations: Vec3[] = []; + + /** + * When collecting items, this filter is used to determine what items should be placed + * into a chest if the bot's inventory becomes full. By default, returns true for all + * items except for tools, weapons, and armor. + * + * @param item - The item stack in the bot's inventory to check. + * + * @returns True if the item should be moved into the chest. False otherwise. + */ + itemFilter: ItemFilter = (item: Item) => { + if (item.name.includes("helmet")) return false; + if (item.name.includes("chestplate")) return false; + if (item.name.includes("leggings")) return false; + if (item.name.includes("boots")) return false; + if (item.name.includes("shield")) return false; + if (item.name.includes("sword")) return false; + if (item.name.includes("pickaxe")) return false; + if (item.name.includes("axe")) return false; + if (item.name.includes("shovel")) return false; + if (item.name.includes("hoe")) return false; + return true; + }; + + /** + * Creates a new instance of the create block plugin. + * + * @param bot - The bot this plugin is acting on. + */ + constructor(bot: Bot) { + this.bot = bot; + this.targets = new Targets(bot); + // @ts-ignore + this.movements = new Movements(bot, mcDataLoader(bot.version)); + } + + /** + * If target is a block: + * Causes the bot to break and collect the target block. + * + * If target is an item drop: + * Causes the bot to collect the item drop. + * + * If target is an array containing items or blocks, preforms the correct action for + * all targets in that array sorting dynamically by distance. + * + * @param target - The block(s) or item(s) to collect. + * @param options - The set of options to use when handling these targets + * @param cb - The callback that is called finished. + */ + async collect( + target: Collectable | Collectable[], + options: CollectOptions | Callback = {}, + cb?: Callback + ): Promise { + if (typeof options === "function") { + cb = options; + options = {}; + } + // @ts-expect-error + if (cb != null) return callbackify(this.collect)(target, options, cb); + + const optionsFull: CollectOptionsFull = { + append: options.append ?? false, + ignoreNoPath: options.ignoreNoPath ?? false, + chestLocations: options.chestLocations ?? this.chestLocations, + itemFilter: options.itemFilter ?? this.itemFilter, + targets: this.targets, + count: options.count ?? Infinity, + }; + + if (this.bot.pathfinder == null) { + throw error( + "UnresolvedDependency", + "The mineflayer-collectblock plugin relies on the mineflayer-pathfinder plugin to run!" + ); + } + + if (this.bot.tool == null) { + throw error( + "UnresolvedDependency", + "The mineflayer-collectblock plugin relies on the mineflayer-tool plugin to run!" + ); + } + + if (this.movements != null) { + this.bot.pathfinder.setMovements(this.movements); + } + + if (!optionsFull.append) await this.cancelTask(); + if (Array.isArray(target)) { + this.targets.appendTargets(target); + } else { + this.targets.appendTarget(target); + } + + try { + await collectAll(this.bot, optionsFull); + this.targets.clear(); + } catch (err) { + this.targets.clear(); + // Ignore path stopped error for cancelTask to work properly (imo we shouldn't throw any pathing errors) + // @ts-expect-error + if (err.name !== "PathStopped") throw err; + } finally { + // @ts-expect-error + this.bot.emit("collectBlock_finished"); + } + } + + /** + * Loads all touching blocks of the same type to the given block and returns them as an array. + * This effectively acts as a flood fill algorithm to retrieve blocks in the same ore vein and similar. + * + * @param block - The starting block. + * @param maxBlocks - The maximum number of blocks to look for before stopping. + * @param maxDistance - The max distance from the starting block to look. + * @param floodRadius - The max distance distance from block A to block B to be considered "touching" + */ + findFromVein( + block: Block, + maxBlocks = 100, + maxDistance = 16, + floodRadius = 1 + ): Block[] { + return findFromVein( + this.bot, + block, + maxBlocks, + maxDistance, + floodRadius + ); + } + + /** + * Cancels the current collection task, if still active. + * + * @param cb - The callback to use when the task is stopped. + */ + async cancelTask(cb?: Callback): Promise { + if (this.targets.empty) { + if (cb != null) cb(); + return await Promise.resolve(); + } + this.bot.pathfinder.stop(); + if (cb != null) { + // @ts-expect-error + this.bot.once("collectBlock_finished", cb); + } + await once(this.bot, "collectBlock_finished"); + } +} diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/Inventory.ts b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/Inventory.ts new file mode 100644 index 000000000..6a17d0cc5 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/Inventory.ts @@ -0,0 +1,87 @@ +import { Bot } from 'mineflayer' +import { Callback } from './CollectBlock' +import { Vec3 } from 'vec3' +import { error } from './Util' +import { Item } from 'prismarine-item' +import { goals } from 'mineflayer-pathfinder' +import { callbackify } from 'util' + +export type ItemFilter = (item: Item) => boolean + +function getClosestChest (bot: Bot, chestLocations: Vec3[]): Vec3 | null { + let chest = null + let distance = 0 + + for (const c of chestLocations) { + const dist = c.distanceTo(bot.entity.position) + if (chest == null || dist < distance) { + chest = c + distance = dist + } + } + + if (chest != null) { + chestLocations.splice(chestLocations.indexOf(chest), 1) + } + + return chest +} + +export async function emptyInventoryIfFull (bot: Bot, chestLocations: Vec3[], itemFilter: ItemFilter, cb?: Callback): Promise { + // @ts-expect-error + if (cb != null) return callbackify(emptyInventoryIfFull)(bot, chestLocations, cb) + if (bot.inventory.emptySlotCount() > 0) return + return await emptyInventory(bot, chestLocations, itemFilter) +} + +export async function emptyInventory (bot: Bot, chestLocations: Vec3[], itemFilter: ItemFilter, cb?: Callback): Promise { + // @ts-expect-error + if (cb != null) return callbackify(emptyInventory)(bot, chestLocations, cb) + if (chestLocations.length === 0) { + throw error('NoChests', 'There are no defined chest locations!') + } + + // Shallow clone so we can safely remove chests from the list that are full. + chestLocations = [...chestLocations] + + while (true) { + const chest = getClosestChest(bot, chestLocations) + if (chest == null) { + throw error('NoChests', 'All chests are full.') + } + const hasRemaining = await tryEmptyInventory(bot, chest, itemFilter) + if (!hasRemaining) return + } +} + +async function tryEmptyInventory (bot: Bot, chestLocation: Vec3, itemFilter: ItemFilter, cb?: (err: Error | undefined, hasRemaining: boolean) => void): Promise { + // @ts-expect-error + if (cb != null) return callbackify(tryEmptyInventory)(bot, chestLocation, itemFilter, cb) + await gotoChest(bot, chestLocation) + return await placeItems(bot, chestLocation, itemFilter) +} + +async function gotoChest (bot: Bot, location: Vec3, cb?: Callback): Promise { + // @ts-expect-error + if (cb != null) return callbackify(gotoChest)(bot, location) + await bot.pathfinder.goto(new goals.GoalGetToBlock(location.x, location.y, location.z)) +} + +async function placeItems (bot: Bot, chestPos: Vec3, itemFilter: ItemFilter, cb?: (err: Error | undefined, hasRemaining: boolean) => void): Promise { + // @ts-expect-error + if (cb != null) return callbackify(placeItems)(bot, chestPos, itemFilter, cb) + const chestBlock = bot.blockAt(chestPos) + if (chestBlock == null) { + throw error('UnloadedChunk', 'Chest is in an unloaded chunk!') + } + const chest = await bot.openChest(chestBlock) + for (const item of bot.inventory.items()) { + if (!itemFilter(item)) continue + if (chest.firstEmptyContainerSlot() === null) { + // We have items that didn't fit. + return true + } + await chest.deposit(item.type, item.metadata, item.count) + } + return false +} diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/Targets.ts b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/Targets.ts new file mode 100644 index 000000000..568d07ad9 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/Targets.ts @@ -0,0 +1,60 @@ +import { Bot } from 'mineflayer' +import { Block } from 'prismarine-block' +import { Entity } from 'prismarine-entity' + +export type Collectable = Block | Entity + +export class Targets { + private readonly bot: Bot + private targets: Collectable[] = [] + + constructor (bot: Bot) { + this.bot = bot + } + + appendTargets (targets: Collectable[]): void { + for (const target of targets) { + this.appendTarget(target) + } + } + + appendTarget (target: Collectable): void { + if (this.targets.includes(target)) return + this.targets.push(target) + } + + /** + * Gets the closest target to the bot in this list. + * + * @returns The closest target, or null if there are no targets. + */ + getClosest (): Collectable | null { + let closest: Collectable | null = null + let distance: number = 0 + + for (const target of this.targets) { + const dist = target.position.distanceTo(this.bot.entity.position) + + if (closest == null || dist < distance) { + closest = target + distance = dist + } + } + + return closest + } + + get empty (): boolean { + return this.targets.length === 0 + } + + clear (): void { + this.targets.length = 0 + } + + removeTarget (target: Collectable): void { + const index = this.targets.indexOf(target) + if (index < 0) return + this.targets.splice(index, 1) + } +} diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/TaskQueue.ts b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/TaskQueue.ts new file mode 100644 index 000000000..81fe3bc5a --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/TaskQueue.ts @@ -0,0 +1,77 @@ +import type { Callback } from './index' +export type Task = (cb: Callback) => void +export type SyncTask = () => void + +/** + * A simple utility class for queuing up a series of async tasks to execute. + */ +export class TaskQueue { + private tasks: Task[] = [] + + /** + * If true, the task list will stop executing if one of the tasks throws an error. + */ + readonly stopOnError: boolean = true + + /** + * Adds a new async task to this queue. The provided callback should be executed when + * the async task is complete. + * + * @param task - The async task to add. + */ + add (task: Task): void { + this.tasks.push(task) + } + + /** + * Adds a synchronous task toi this queue. + * + * @param task - The sync task to add. + */ + addSync (task: SyncTask): void { + this.add((cb) => { + try { + task() + cb() + } catch (err: any) { + cb(err) + } + }) + } + + /** + * Runs all tasks currently in this queue and empties the queue. + * + * @param cb - The optional callback to be executed when all tasks in this queue have + * finished executing. + */ + runAll (cb?: Callback): void { + const taskList = this.tasks + this.tasks = [] + + let index = -1 + const runNext: () => void = () => { + index++ + if (index >= taskList.length) { + if (cb !== undefined) cb() + return + } + + try { + taskList[index]((err) => { + if (err !== undefined) { + if (cb !== undefined) cb(err) + + if (this.stopOnError) return + } + + runNext() + }) + } catch (err: any) { + if (cb !== undefined) cb(err) + } + } + + runNext() + } +} diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/TemporarySubscriber.ts b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/TemporarySubscriber.ts new file mode 100644 index 000000000..3f14a607d --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/TemporarySubscriber.ts @@ -0,0 +1,34 @@ +import { Bot } from 'mineflayer' + +class Subscription { + constructor (readonly eventName: string, readonly callback: Function) {} +} + +export class TemporarySubscriber { + private readonly subscriptions: Subscription[] = [] + + constructor (readonly bot: Bot) {} + + /** + * Adds a new temporary event listener to the bot. + * + * @param event - The event to subscribe to. + * @param callback - The function to execute. + */ + subscribeTo (event: string, callback: Function): void { + this.subscriptions.push(new Subscription(event, callback)) + + // @ts-expect-error + this.bot.on(event, callback) + } + + /** + * Removes all attached event listeners from the bot. + */ + cleanup (): void { + for (const sub of this.subscriptions) { + // @ts-expect-error + this.bot.removeListener(sub.eventName, sub.callback) + } + } +} diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/Util.ts b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/Util.ts new file mode 100644 index 000000000..ee0f29e0c --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/Util.ts @@ -0,0 +1,13 @@ +/** + * Creates a new error object with the given type and message. + * + * @param type - The error type. + * @param message - The error message. + * + * @returns The error object. + */ +export function error (type: string, message: string): Error { + const e = new Error(message) + e.name = type + return e +} diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/index.ts b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/index.ts new file mode 100644 index 000000000..45c9a8508 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/src/index.ts @@ -0,0 +1,25 @@ +import { Bot } from 'mineflayer' +import { CollectBlock } from './CollectBlock' +import { pathfinder as pathfinderPlugin } from 'mineflayer-pathfinder' +import { plugin as toolPlugin } from 'mineflayer-tool' + +export function plugin (bot: Bot): void { + // @ts-expect-error + bot.collectBlock = new CollectBlock(bot) + + // Load plugins if not loaded manually. + setTimeout(() => loadPathfinderPlugin(bot), 0) + setTimeout(() => loadToolPlugin(bot), 0) +} + +function loadPathfinderPlugin (bot: Bot): void { + if (bot.pathfinder != null) return + bot.loadPlugin(pathfinderPlugin) +} + +function loadToolPlugin (bot: Bot): void { + if (bot.tool != null) return + bot.loadPlugin(toolPlugin) +} + +export { CollectBlock, Callback, CollectOptions } from './CollectBlock' diff --git a/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/tsconfig.json b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/tsconfig.json new file mode 100644 index 000000000..a6076bc0c --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/mineflayer-collectblock/tsconfig.json @@ -0,0 +1,69 @@ +{ + "compilerOptions": { + /* Visit https://aka.ms/tsconfig.json to read more about this file */ + /* Basic Options */ + // "incremental": true, /* Enable incremental compilation */ + "target": "ES2015", /* Specify ECMAScript target version: 'ES3' (default), 'ES5', 'ES2015', 'ES2016', 'ES2017', 'ES2018', 'ES2019', 'ES2020', or 'ESNEXT'. */ + "module": "commonjs", /* Specify module code generation: 'none', 'commonjs', 'amd', 'system', 'umd', 'es2015', 'es2020', or 'ESNext'. */ + // "lib": [], /* Specify library files to be included in the compilation. */ + "allowJs": true, /* Allow javascript files to be compiled. */ + "checkJs": true, /* Report errors in .js files. */ + // "jsx": "preserve", /* Specify JSX code generation: 'preserve', 'react-native', or 'react'. */ + "declaration": true, + // "declarationMap": true, /* Generates a sourcemap for each corresponding '.d.ts' file. */ + // "sourceMap": true, /* Generates corresponding '.map' file. */ + // "outFile": "./", /* Concatenate and emit output to single file. */ + "outDir": "./lib", + // "rootDir": "./", /* Specify the root directory of input files. Use to control the output directory structure with --outDir. */ + // "composite": true, /* Enable project compilation */ + // "tsBuildInfoFile": "./", /* Specify file to store incremental compilation information */ + // "removeComments": true, /* Do not emit comments to output. */ + // "noEmit": true, /* Do not emit outputs. */ + // "importHelpers": true, /* Import emit helpers from 'tslib'. */ + // "downlevelIteration": true, /* Provide full support for iterables in 'for-of', spread, and destructuring when targeting 'ES5' or 'ES3'. */ + // "isolatedModules": true, /* Transpile each file as a separate module (similar to 'ts.transpileModule'). */ + /* Strict Type-Checking Options */ + "strict": true, /* Enable all strict type-checking options. */ + // "noImplicitAny": true, /* Raise error on expressions and declarations with an implied 'any' type. */ + "strictNullChecks": true, /* Enable strict null checks. */ + // "strictFunctionTypes": true, /* Enable strict checking of function types. */ + // "strictBindCallApply": true, /* Enable strict 'bind', 'call', and 'apply' methods on functions. */ + // "strictPropertyInitialization": true, /* Enable strict checking of property initialization in classes. */ + // "noImplicitThis": true, /* Raise error on 'this' expressions with an implied 'any' type. */ + "alwaysStrict": true, /* Parse in strict mode and emit "use strict" for each source file. */ + /* Additional Checks */ + "noUnusedLocals": true, /* Report errors on unused locals. */ + // "noUnusedParameters": true, /* Report errors on unused parameters. */ + "noImplicitReturns": true, /* Report error when not all code paths in function return a value. */ + // "noFallthroughCasesInSwitch": true, /* Report errors for fallthrough cases in switch statement. */ + /* Module Resolution Options */ + // "moduleResolution": "node", /* Specify module resolution strategy: 'node' (Node.js) or 'classic' (TypeScript pre-1.6). */ + // "baseUrl": "./", /* Base directory to resolve non-absolute module names. */ + // "paths": {}, /* A series of entries which re-map imports to lookup locations relative to the 'baseUrl'. */ + // "rootDirs": [], /* List of root folders whose combined content represents the structure of the project at runtime. */ + // "typeRoots": [], /* List of folders to include type definitions from. */ + // "types": [], /* Type declaration files to be included in compilation. */ + // "allowSyntheticDefaultImports": true, /* Allow default imports from modules with no default export. This does not affect code emit, just typechecking. */ + "esModuleInterop": true, /* Enables emit interoperability between CommonJS and ES Modules via creation of namespace objects for all imports. Implies 'allowSyntheticDefaultImports'. */ + // "preserveSymlinks": true, /* Do not resolve the real path of symlinks. */ + // "allowUmdGlobalAccess": true, /* Allow accessing UMD globals from modules. */ + /* Source Map Options */ + // "sourceRoot": "", /* Specify the location where debugger should locate TypeScript files instead of source locations. */ + // "mapRoot": "", /* Specify the location where debugger should locate map files instead of generated locations. */ + // "inlineSourceMap": true, /* Emit a single file with source maps instead of having a separate file. */ + // "inlineSources": true, /* Emit the source alongside the sourcemaps within a single file; requires '--inlineSourceMap' or '--sourceMap' to be set. */ + /* Experimental Options */ + // "experimentalDecorators": true, /* Enables experimental support for ES7 decorators. */ + // "emitDecoratorMetadata": true, /* Enables experimental support for emitting type metadata for decorators. */ + /* Advanced Options */ + "skipLibCheck": true, /* Skip type checking of declaration files. */ + "forceConsistentCasingInFileNames": true /* Disallow inconsistently-cased references to the same file. */ + }, + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "**/__tests__/*" + ] +} \ No newline at end of file diff --git a/metagpt/mineflayer_env/mineflayer/package.json b/metagpt/mineflayer_env/mineflayer/package.json new file mode 100644 index 000000000..9e389d268 --- /dev/null +++ b/metagpt/mineflayer_env/mineflayer/package.json @@ -0,0 +1,38 @@ +{ + "name": "voyager", + "version": "1.0.0", + "description": "", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "dependencies": { + "body-parser": "^1.20.2", + "express": "^4.18.2", + "magic-string": "^0.30.0", + "minecraft-data": "^3.31.0", + "minecrafthawkeye": "^1.3.6", + "mineflayer": "^4.8.1", + "mineflayer-collectblock": "file:mineflayer-collectblock", + "mineflayer-pathfinder": "^2.4.2", + "mineflayer-pvp": "^1.3.2", + "mineflayer-tool": "^1.2.0", + "mocha": "^10.2.0", + "prismarine-biome": "^1.3.0", + "prismarine-block": "=1.16.3", + "prismarine-entity": "^2.2.0", + "prismarine-item": "^1.12.1", + "prismarine-nbt": "^2.2.1", + "prismarine-recipe": "^1.3.1", + "prismarine-viewer": "^1.24.0", + "typescript": "^4.9.5", + "vec3": "^0.1.8", + "graceful-fs": "^4.2.11" + }, + "devDependencies": { + "prettier": "2.8.5" + } +} diff --git a/metagpt/mineflayer_environment.py b/metagpt/mineflayer_environment.py new file mode 100644 index 000000000..f01e10e50 --- /dev/null +++ b/metagpt/mineflayer_environment.py @@ -0,0 +1,162 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/09/25 22:13 +# @Author : yuymf +# @Desc : @https://github.com/MineDojo/Voyager/blob/main/voyager/env/bridge.py +import os +import time +import json +import requests +import re + +from metagpt.logs import logger +import metagpt.utils.minecraft as U +from metagpt.utils.minecraft.process_monitor import SubprocessMonitor +from metagpt.const import CKPT_DIR, DEFAULT_WARMUP, CURRICULUM_OB, CORE_INVENTORY_ITEMS + +class MineflayerEnv: + def __init__( + self, + mc_port=None, + server_host="http://127.0.0.1", + server_port=3000, + request_timeout=600, + ): + self.mc_port = mc_port + self.server = f"{server_host}:{server_port}" + self.server_port = server_port + self.request_timeout = request_timeout + self.mineflayer = self.get_mineflayer_process(server_port) + self.has_reset = False + self.reset_options = None + self.connected = False + self.server_paused = False + + self.warm_up = {} # turns that when to add part of curriculum_ob to HumanMessage TODO: MV + self.core_inv_items_regex = None + + self._set_warmup() + + os.makedirs(f"{CKPT_DIR}/curriculum/vectordb", exist_ok=True) + os.makedirs(f"{CKPT_DIR}/action", exist_ok=True) + os.makedirs(f"{CKPT_DIR}/skill/code", exist_ok=True) + os.makedirs(f"{CKPT_DIR}/skill/description", exist_ok=True) + os.makedirs(f"{CKPT_DIR}/skill/vectordb", exist_ok=True) + + def _set_warmup(self): + warm_up = DEFAULT_WARMUP + if "optional_inventory_items" in warm_up: + assert CORE_INVENTORY_ITEMS is not None + self.core_inv_items_regex = re.compile( + CORE_INVENTORY_ITEMS + ) + self.warm_up["optional_inventory_items"] = warm_up[ + "optional_inventory_items" + ] + else: + self.warm_up["optional_inventory_items"] = 0 + for key in CURRICULUM_OB: + self.warm_up[key] = warm_up.get(key, DEFAULT_WARMUP[key]) + self.warm_up["nearby_blocks"] = 0 + self.warm_up["inventory"] = 0 + self.warm_up["completed_tasks"] = 0 + self.warm_up["failed_tasks"] = 0 + + def set_mc_port(self, mc_port): + self.mc_port = mc_port + + def get_mineflayer_process(self, server_port): + U.f_mkdir("./logs", "mineflayer") + file_path = os.path.abspath(os.path.dirname(__file__)) + return SubprocessMonitor( + commands=[ + "node", + U.f_join(file_path, "mineflayer_env/mineflayer/index.js"), + str(server_port), + ], + name="mineflayer", + ready_match=r"Server started on port (\d+)", + log_path=U.f_join("./logs", "mineflayer"), + ) + + def check_process(self): + retry = 0 + while not self.mineflayer.is_running: + logger.info("Mineflayer process has exited, restarting") + self.mineflayer.run() + if not self.mineflayer.is_running: + if retry > 3: + logger.error("Mineflayer process failed to start") + raise {} + else: + retry += 1 + continue + logger.info(self.mineflayer.ready_line) + res = requests.post( + f"{self.server}/start", + json=self.reset_options, + timeout=self.request_timeout, + ) + if res.status_code != 200: + self.mineflayer.stop() + logger.error(f"Minecraft server reply with code {res.status_code}") + raise {} + return res.json() + + def reset( + self, + *, + seed=None, + options=None, + ): + if options is None: + options = {} + if options.get("inventory", {}) and options.get("mode", "hard") != "hard": + logger.error("inventory can only be set when options is hard") + raise {} + + self.reset_options = { + "port": self.mc_port, + "reset": options.get("mode", "hard"), + "inventory": options.get("inventory", {}), + "equipment": options.get("equipment", []), + "spread": options.get("spread", False), + "waitTicks": options.get("wait_ticks", 5), + "position": options.get("position", None), + } + + self.unpause() + self.mineflayer.stop() + time.sleep(1) # wait for mineflayer to exit + + returned_data = self.check_process() + self.has_reset = True + self.connected = True + # All the reset in step will be soft + self.reset_options["reset"] = "soft" + self.pause() + return json.loads(returned_data) + + def close(self): + self.unpause() + if self.connected: + res = requests.post(f"{self.server}/stop") + if res.status_code == 200: + self.connected = False + self.mineflayer.stop() + return not self.connected + + def pause(self): + if self.mineflayer.is_running and not self.server_paused: + res = requests.post(f"{self.server}/pause") + if res.status_code == 200: + self.server_paused = True + return self.server_paused + + def unpause(self): + if self.mineflayer.is_running and self.server_paused: + res = requests.post(f"{self.server}/pause") + if res.status_code == 200: + self.server_paused = False + else: + print(res.json()) + return self.server_paused diff --git a/metagpt/prompts/minecraft/action_response_format.txt b/metagpt/prompts/minecraft/action_response_format.txt new file mode 100644 index 000000000..df3713a83 --- /dev/null +++ b/metagpt/prompts/minecraft/action_response_format.txt @@ -0,0 +1,15 @@ +Explain: ... +Plan: +1) ... +2) ... +3) ... +... +Code: +```javascript +// helper functions (only if needed, try to avoid them) +... +// main function after the helper functions +async function yourMainFunctionName(bot) { + // ... +} +``` \ No newline at end of file diff --git a/metagpt/prompts/minecraft/action_template.txt b/metagpt/prompts/minecraft/action_template.txt new file mode 100644 index 000000000..d6061f0a2 --- /dev/null +++ b/metagpt/prompts/minecraft/action_template.txt @@ -0,0 +1,49 @@ +You are a helpful assistant that writes Mineflayer javascript code to complete any Minecraft task specified by me. + +Here are some useful programs written with Mineflayer APIs. + +{programs} + + +At each round of conversation, I will give you +Code from the last round: ... +Execution error: ... +Chat log: ... +Biome: ... +Time: ... +Nearby blocks: ... +Nearby entities (nearest to farthest): +Health: ... +Hunger: ... +Position: ... +Equipment: ... +Inventory (xx/36): ... +Chests: ... +Task: ... +Context: ... +Critique: ... + +You should then respond to me with +Explain (if applicable): Are there any steps missing in your plan? Why does the code not complete the task? What does the chat log and execution error imply? +Plan: How to complete the task step by step. You should pay attention to Inventory since it tells what you have. The task completeness check is also based on your final inventory. +Code: + 1) Write an async function taking the bot as the only argument. + 2) Reuse the above useful programs as much as possible. + - Use `mineBlock(bot, name, count)` to collect blocks. Do not use `bot.dig` directly. + - Use `craftItem(bot, name, count)` to craft items. Do not use `bot.craft` or `bot.recipesFor` directly. + - Use `smeltItem(bot, name count)` to smelt items. Do not use `bot.openFurnace` directly. + - Use `placeItem(bot, name, position)` to place blocks. Do not use `bot.placeBlock` directly. + - Use `killMob(bot, name, timeout)` to kill mobs. Do not use `bot.attack` directly. + 3) Your function will be reused for building more complex functions. Therefore, you should make it generic and reusable. You should not make strong assumption about the inventory (as it may be changed at a later time), and therefore you should always check whether you have the required items before using them. If not, you should first collect the required items and reuse the above useful programs. + 4) Functions in the "Code from the last round" section will not be saved or executed. Do not reuse functions listed there. + 5) Anything defined outside a function will be ignored, define all your variables inside your functions. + 6) Call `bot.chat` to show the intermediate progress. + 7) Use `exploreUntil(bot, direction, maxDistance, callback)` when you cannot find something. You should frequently call this before mining blocks or killing mobs. You should select a direction at random every time instead of constantly using (1, 0, 1). + 8) `maxDistance` should always be 32 for `bot.findBlocks` and `bot.findBlock`. Do not cheat. + 9) Do not write infinite loops or recursive functions. + 10) Do not use `bot.on` or `bot.once` to register event listeners. You definitely do not need them. + 11) Name your function in a meaningful way (can infer the task from the name). + +You should only respond in the format as described below: +RESPONSE FORMAT: +{response_format} diff --git a/metagpt/prompts/minecraft/critic.txt b/metagpt/prompts/minecraft/critic.txt new file mode 100644 index 000000000..c42950675 --- /dev/null +++ b/metagpt/prompts/minecraft/critic.txt @@ -0,0 +1,127 @@ +You are an assistant that assesses my progress of playing Minecraft and provides useful guidance. + +You are required to evaluate if I have met the task requirements. Exceeding the task requirements is also considered a success while failing to meet them requires you to provide critique to help me improve. + +I will give you the following information: + +Biome: The biome after the task execution. +Time: The current time. +Nearby blocks: The surrounding blocks. These blocks are not collected yet. However, this is useful for some placing or planting tasks. +Health: My current health. +Hunger: My current hunger level. For eating task, if my hunger level is 20.0, then I successfully ate the food. +Position: My current position. +Equipment: My final equipment. For crafting tasks, I sometimes equip the crafted item. +Inventory (xx/36): My final inventory. For mining and smelting tasks, you only need to check inventory. +Chests: If the task requires me to place items in a chest, you can find chest information here. +Task: The objective I need to accomplish. +Context: The context of the task. + +You should only respond in JSON format as described below: +{ + "reasoning": "reasoning", + "success": boolean, + "critique": "critique", +} +Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc. + +Here are some examples: +INPUT: +Inventory (2/36): {'oak_log':2, 'spruce_log':2} + +Task: Mine 3 wood logs + +RESPONSE: +{ + "reasoning": "You need to mine 3 wood logs. You have 2 oak logs and 2 spruce logs, which add up to 4 wood logs.", + "success": true, + "critique": "" +} + +INPUT: +Inventory (3/36): {'crafting_table': 1, 'spruce_planks': 6, 'stick': 4} + +Task: Craft a wooden pickaxe + +RESPONSE: +{ + "reasoning": "You have enough materials to craft a wooden pickaxe, but you didn't craft it.", + "success": false, + "critique": "Craft a wooden pickaxe with a crafting table using 3 spruce planks and 2 sticks." +} + +INPUT: +Inventory (2/36): {'raw_iron': 5, 'stone_pickaxe': 1} + +Task: Mine 5 iron_ore + +RESPONSE: +{ + "reasoning": "Mining iron_ore in Minecraft will get raw_iron. You have 5 raw_iron in your inventory.", + "success": true, + "critique": "" +} + +INPUT: +Biome: plains + +Nearby blocks: stone, dirt, grass_block, grass, farmland, wheat + +Inventory (26/36): ... + +Task: Plant 1 wheat seed. + +RESPONSE: +{ + "reasoning": "For planting tasks, inventory information is useless. In nearby blocks, there is farmland and wheat, which means you succeed to plant the wheat seed.", + "success": true, + "critique": "" +} + +INPUT: +Inventory (11/36): {... ,'rotten_flesh': 1} + +Task: Kill 1 zombie + +Context: ... + +RESPONSE +{ + "reasoning": "You have rotten flesh in your inventory, which means you successfully killed one zombie.", + "success": true, + "critique": "" +} + +INPUT: +Hunger: 20.0/20.0 + +Inventory (11/36): ... + +Task: Eat 1 ... + +Context: ... + +RESPONSE +{ + "reasoning": "For all eating task, if the player's hunger is 20.0, then the player successfully ate the food.", + "success": true, + "critique": "" +} + +INPUT: +Nearby blocks: chest + +Inventory (28/36): {'rail': 1, 'coal': 2, 'oak_planks': 13, 'copper_block': 1, 'diorite': 7, 'cooked_beef': 4, 'granite': 22, 'cobbled_deepslate': 23, 'feather': 4, 'leather': 2, 'cooked_chicken': 3, 'white_wool': 2, 'stick': 3, 'black_wool': 1, 'stone_sword': 2, 'stone_hoe': 1, 'stone_axe': 2, 'stone_shovel': 2, 'cooked_mutton': 4, 'cobblestone_wall': 18, 'crafting_table': 1, 'furnace': 1, 'iron_pickaxe': 1, 'stone_pickaxe': 1, 'raw_copper': 12} + +Chests: +(81, 131, 16): {'andesite': 2, 'dirt': 2, 'cobblestone': 75, 'wooden_pickaxe': 1, 'wooden_sword': 1} + +Task: Deposit useless items into the chest at (81, 131, 16) + +Context: ... + +RESPONSE +{ + "reasoning": "You have 28 items in your inventory after depositing, which is more than 20. You need to deposit more items from your inventory to the chest.", + "success": false, + "critique": "Deposit more useless items such as copper_block, diorite, granite, cobbled_deepslate, feather, and leather to meet the requirement of having only 20 occupied slots in your inventory." +} \ No newline at end of file diff --git a/metagpt/prompts/minecraft/curriculum.txt b/metagpt/prompts/minecraft/curriculum.txt new file mode 100644 index 000000000..66a33c626 --- /dev/null +++ b/metagpt/prompts/minecraft/curriculum.txt @@ -0,0 +1,42 @@ +You are a helpful assistant that tells me the next immediate task to do in Minecraft. My ultimate goal is to discover as many diverse things as possible, accomplish as many diverse tasks as possible and become the best Minecraft player in the world. + +I will give you the following information: +Question 1: ... +Answer: ... +Question 2: ... +Answer: ... +Question 3: ... +Answer: ... +... +Biome: ... +Time: ... +Nearby blocks: ... +Other blocks that are recently seen: ... +Nearby entities (nearest to farthest): ... +Health: Higher than 15 means I'm healthy. +Hunger: Higher than 15 means I'm not hungry. +Position: ... +Equipment: If I have better armor in my inventory, you should ask me to equip it. +Inventory (xx/36): ... +Chests: You can ask me to deposit or take items from these chests. There also might be some unknown chest, you should ask me to open and check items inside the unknown chest. +Completed tasks so far: ... +Failed tasks that are too hard: ... + +You must follow the following criteria: +1) You should act as a mentor and guide me to the next task based on my current learning progress. +2) Please be very specific about what resources I need to collect, what I need to craft, or what mobs I need to kill. +3) The next task should follow a concise format, such as "Mine [quantity] [block]", "Craft [quantity] [item]", "Smelt [quantity] [item]", "Kill [quantity] [mob]", "Cook [quantity] [food]", "Equip [item]" etc. It should be a single phrase. Do not propose multiple tasks at the same time. Do not mention anything else. +4) The next task should not be too hard since I may not have the necessary resources or have learned enough skills to complete it yet. +5) The next task should be novel and interesting. I should look for rare resources, upgrade my equipment and tools using better materials, and discover new things. I should not be doing the same thing over and over again. +6) I may sometimes need to repeat some tasks if I need to collect more resources to complete more difficult tasks. Only repeat tasks if necessary. +7) Do not ask me to build or dig shelter even if it's at night. I want to explore the world and discover new things. I don't want to stay in one place. +8) Tasks that require information beyond the player's status to verify should be avoided. For instance, "Placing 4 torches" and "Dig a 2x1x2 hole" are not ideal since they require visual confirmation from the screen. All the placing, building, planting, and trading tasks should be avoided. Do not propose task starting with these keywords. + +You should only respond in the format as described below: +RESPONSE FORMAT: +Reasoning: Based on the information I listed above, do reasoning about what the next task should be. +Task: The next task. + +Here's an example response: +Reasoning: The inventory is empty now, chop down a tree to get some wood. +Task: Obtain a wood log. \ No newline at end of file diff --git a/metagpt/prompts/minecraft/curriculum_qa_step1_ask_questions.txt b/metagpt/prompts/minecraft/curriculum_qa_step1_ask_questions.txt new file mode 100644 index 000000000..6d93537fe --- /dev/null +++ b/metagpt/prompts/minecraft/curriculum_qa_step1_ask_questions.txt @@ -0,0 +1,94 @@ +You are a helpful assistant that asks questions to help me decide the next immediate task to do in Minecraft. My ultimate goal is to discover as many things as possible, accomplish as many tasks as possible and become the best Minecraft player in the world. + +I will give you the following information: +Biome: ... +Time: ... +Nearby blocks: ... +Other blocks that are recently seen: ... +Nearby entities (nearest to farthest): ... +Health: ... +Hunger: ... +Position: ... +Equipment: ... +Inventory (xx/36): ... +Chests: ... +Completed tasks so far: ... +Failed tasks that are too hard: ... + +You must follow the following criteria: +1) You should ask at least 5 questions (but no more than 10 questions) to help me decide the next immediate task to do. Each question should be followed by the concept that the question is about. +2) Your question should be specific to a concept in Minecraft. + Bad example (the question is too general): + Question: What is the best way to play Minecraft? + Concept: unknown + Bad example (axe is still general, you should specify the type of axe such as wooden axe): + What are the benefits of using an axe to gather resources? + Concept: axe + Good example: + Question: How to make a wooden pickaxe? + Concept: wooden pickaxe +3) Your questions should be self-contained and not require any context. + Bad example (the question requires the context of my current biome): + Question: What are the blocks that I can find in my current biome? + Concept: unknown + Bad example (the question requires the context of my current inventory): + Question: What are the resources you need the most currently? + Concept: unknown + Bad example (the question requires the context of my current inventory): + Question: Do you have any gold or emerald resources? + Concept: gold + Bad example (the question requires the context of my nearby entities): + Question: Can you see any animals nearby that you can kill for food? + Concept: food + Bad example (the question requires the context of my nearby blocks): + Question: Is there any water source nearby? + Concept: water + Good example: + Question: What are the blocks that I can find in the sparse jungle? + Concept: sparse jungle +4) Do not ask questions about building tasks (such as building a shelter) since they are too hard for me to do. + +Let's say your current biome is sparse jungle. You can ask questions like: +Question: What are the items that I can find in the sparse jungle? +Concept: sparse jungle +Question: What are the mobs that I can find in the sparse jungle? +Concept: sparse jungle + +Let's say you see a creeper nearby, and you have not defeated a creeper before. You can ask a question like: +Question: How to defeat the creeper? +Concept: creeper + +Let's say your last completed task is "Craft a wooden pickaxe". You can ask a question like: +Question: What are the suggested tasks that I can do after crafting a wooden pickaxe? +Concept: wooden pickaxe + +Here are some more question and concept examples: +Question: What are the ores that I can find in the sparse jungle? +Concept: sparse jungle +(the above concept should not be "ore" because I need to look up the page of "sparse jungle" to find out what ores I can find in the sparse jungle) +Question: How can you obtain food in the sparse jungle? +Concept: sparse jungle +(the above concept should not be "food" because I need to look up the page of "sparse jungle" to find out what food I can obtain in the sparse jungle) +Question: How can you use the furnace to upgrade your equipment and make useful items? +Concept: furnace +Question: How to obtain a diamond ore? +Concept: diamond ore +Question: What are the benefits of using a stone pickaxe over a wooden pickaxe? +Concept: stone pickaxe +Question: What are the tools that you can craft using wood planks and sticks? +Concept: wood planks + +You should only respond in the format as described below: +RESPONSE FORMAT: +Reasoning: ... +Question 1: ... +Concept 1: ... +Question 2: ... +Concept 2: ... +Question 3: ... +Concept 3: ... +Question 4: ... +Concept 4: ... +Question 5: ... +Concept 5: ... +... diff --git a/metagpt/prompts/minecraft/curriculum_qa_step2_answer_questions.txt b/metagpt/prompts/minecraft/curriculum_qa_step2_answer_questions.txt new file mode 100644 index 000000000..860d986e4 --- /dev/null +++ b/metagpt/prompts/minecraft/curriculum_qa_step2_answer_questions.txt @@ -0,0 +1,8 @@ +You are a helpful assistant that answer my question about Minecraft. + +I will give you the following information: +Question: ... + +You will answer the question based on the context (only if available and helpful) and your own knowledge of Minecraft. +1) Start your answer with "Answer: ". +2) Answer "Answer: Unknown" if you don't know the answer. \ No newline at end of file diff --git a/metagpt/prompts/minecraft/curriculum_task_decomposition.txt b/metagpt/prompts/minecraft/curriculum_task_decomposition.txt new file mode 100644 index 000000000..0e3b1592d --- /dev/null +++ b/metagpt/prompts/minecraft/curriculum_task_decomposition.txt @@ -0,0 +1,12 @@ +You are a helpful assistant that generates a curriculum of subgoals to complete any Minecraft task specified by me. + +I'll give you a final task and my current inventory, you need to decompose the task into a list of subgoals based on my inventory. + +You must follow the following criteria: +1) Return a Python list of subgoals that can be completed in order to complete the specified task. +2) Each subgoal should follow a concise format, such as "Mine [quantity] [block]", "Craft [quantity] [item]", "Smelt [quantity] [item]", "Kill [quantity] [mob]", "Cook [quantity] [food]", "Equip [item]". +3) Include each level of necessary tools as a subgoal, such as wooden, stone, iron, diamond, etc. + +You should only respond in JSON format as described below: +["subgoal1", "subgoal2", "subgoal3", ...] +Ensure the response can be parsed by Python `json.loads`, e.g.: no trailing commas, no single quotes, etc. \ No newline at end of file diff --git a/metagpt/prompts/minecraft/skill.txt b/metagpt/prompts/minecraft/skill.txt new file mode 100644 index 000000000..dc846cdf6 --- /dev/null +++ b/metagpt/prompts/minecraft/skill.txt @@ -0,0 +1,51 @@ +You are a helpful assistant that writes a description of the given function written in Mineflayer javascript code. + +1) Do not mention the function name. +2) Do not mention anything about `bot.chat` or helper functions. +3) There might be some helper functions before the main function, but you only need to describe the main function. +4) Try to summarize the function in no more than 6 sentences. +5) Your response should be a single line of text. + +For example, if the function is: + +async function mineCobblestone(bot) { + // Check if the wooden pickaxe is in the inventory, if not, craft one + let woodenPickaxe = bot.inventory.findInventoryItem(mcData.itemsByName["wooden_pickaxe"].id); + if (!woodenPickaxe) { + bot.chat("Crafting a wooden pickaxe."); + await craftWoodenPickaxe(bot); + woodenPickaxe = bot.inventory.findInventoryItem(mcData.itemsByName["wooden_pickaxe"].id); + } + + // Equip the wooden pickaxe if it exists + if (woodenPickaxe) { + await bot.equip(woodenPickaxe, "hand"); + + // Explore until we find a stone block + await exploreUntil(bot, new Vec3(1, -1, 1), 60, () => { + const stone = bot.findBlock({ + matching: mcData.blocksByName["stone"].id, + maxDistance: 32 + }); + if (stone) { + return true; + } + }); + + // Mine 8 cobblestone blocks using the wooden pickaxe + bot.chat("Found a stone block. Mining 8 cobblestone blocks."); + await mineBlock(bot, "stone", 8); + bot.chat("Successfully mined 8 cobblestone blocks."); + + // Save the event of mining 8 cobblestone + bot.save("cobblestone_mined"); + } else { + bot.chat("Failed to craft a wooden pickaxe. Cannot mine cobblestone."); + } +} + +The main function is `mineCobblestone`. + +Then you would write: + +The function is about mining 8 cobblestones using a wooden pickaxe. First check if a wooden pickaxe is in the inventory. If not, craft one. If the wooden pickaxe is available, equip the wooden pickaxe in the hand. Next, explore the environment until finding a stone block. Once a stone block is found, mine a total of 8 cobblestone blocks using the wooden pickaxe. \ No newline at end of file diff --git a/metagpt/prompts/tutorial_assistant.py b/metagpt/prompts/tutorial_assistant.py new file mode 100644 index 000000000..d690aad83 --- /dev/null +++ b/metagpt/prompts/tutorial_assistant.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/4 15:40:40 +@Author : Stitch-z +@File : tutorial_assistant.py +@Describe : Tutorial Assistant's prompt templates. +""" + +COMMON_PROMPT = """ +You are now a seasoned technical professional in the field of the internet. +We need you to write a technical tutorial with the topic "{topic}". +""" + +DIRECTORY_PROMPT = COMMON_PROMPT + """ +Please provide the specific table of contents for this tutorial, strictly following the following requirements: +1. The output must be strictly in the specified language, {language}. +2. Answer strictly in the dictionary format like {{"title": "xxx", "directory": [{{"dir 1": ["sub dir 1", "sub dir 2"]}}, {{"dir 2": ["sub dir 3", "sub dir 4"]}}]}}. +3. The directory should be as specific and sufficient as possible, with a primary and secondary directory.The secondary directory is in the array. +4. Do not have extra spaces or line breaks. +5. Each directory title has practical significance. +""" + +CONTENT_PROMPT = COMMON_PROMPT + """ +Now I will give you the module directory titles for the topic. +Please output the detailed principle content of this title in detail. +If there are code examples, please provide them according to standard code specifications. +Without a code example, it is not necessary. + +The module directory titles for the topic is as follows: +{directory} + +Strictly limit output according to the following requirements: +1. Follow the Markdown syntax format for layout. +2. If there are code examples, they must follow standard syntax specifications, have document annotations, and be displayed in code blocks. +3. The output must be strictly in the specified language, {language}. +4. Do not have redundant output, including concluding remarks. +5. Strict requirement not to output the topic "{topic}". +""" \ No newline at end of file diff --git a/metagpt/provider/openai_api.py b/metagpt/provider/openai_api.py index ad9df0396..7e865f288 100644 --- a/metagpt/provider/openai_api.py +++ b/metagpt/provider/openai_api.py @@ -6,11 +6,17 @@ """ import asyncio import time -from typing import NamedTuple +from typing import NamedTuple, Union import openai from openai.error import APIConnectionError -from tenacity import retry, stop_after_attempt, after_log, wait_fixed, retry_if_exception_type +from tenacity import ( + after_log, + retry, + retry_if_exception_type, + stop_after_attempt, + wait_fixed, +) from metagpt.config import CONFIG from metagpt.logs import logger @@ -48,12 +54,14 @@ class RateLimiter: self.last_call_time = time.time() + class Costs(NamedTuple): total_prompt_tokens: int total_completion_tokens: int total_cost: float total_budget: float + class CostManager(metaclass=Singleton): """计算使用接口的开销""" @@ -74,7 +82,9 @@ class CostManager(metaclass=Singleton): """ self.total_prompt_tokens += prompt_tokens self.total_completion_tokens += completion_tokens - cost = (prompt_tokens * TOKEN_COSTS[model]["prompt"] + completion_tokens * TOKEN_COSTS[model]["completion"]) / 1000 + cost = ( + prompt_tokens * TOKEN_COSTS[model]["prompt"] + completion_tokens * TOKEN_COSTS[model]["completion"] + ) / 1000 self.total_cost += cost logger.info( f"Total running cost: ${self.total_cost:.3f} | Max budget: ${CONFIG.max_budget:.3f} | " @@ -100,6 +110,7 @@ class CostManager(metaclass=Singleton): """ return self.total_completion_tokens + def get_total_cost(self): """ Get the total cost of API calls. @@ -109,25 +120,20 @@ def get_total_cost(self): """ return self.total_cost + def get_costs(self) -> Costs: """Get all costs""" return Costs(self.total_prompt_tokens, self.total_completion_tokens, self.total_cost, self.total_budget) -def log_and_reraise(retry_state): - logger.error(f"Retry attempts exhausted. Last exception: {retry_state.outcome.exception()}") - logger.warning(""" -Recommend going to https://deepwisdom.feishu.cn/wiki/MsGnwQBjiif9c3koSJNcYaoSnu4#part-XdatdVlhEojeAfxaaEZcMV3ZniQ -See FAQ 5.8 -""") - raise retry_state.outcome.exception() - def log_and_reraise(retry_state): logger.error(f"Retry attempts exhausted. Last exception: {retry_state.outcome.exception()}") - logger.warning(""" + logger.warning( + """ Recommend going to https://deepwisdom.feishu.cn/wiki/MsGnwQBjiif9c3koSJNcYaoSnu4#part-XdatdVlhEojeAfxaaEZcMV3ZniQ See FAQ 5.8 -""") +""" + ) raise retry_state.outcome.exception() @@ -182,15 +188,18 @@ class OpenAIGPTAPI(BaseGPTAPI, RateLimiter): "n": 1, "stop": None, "temperature": 0.3, - "timeout": 3 + "timeout": 3, } if CONFIG.openai_api_type == "azure": if CONFIG.deployment_name and CONFIG.deployment_id: raise ValueError("You can only use one of the `deployment_id` or `deployment_name` model") elif not CONFIG.deployment_name and not CONFIG.deployment_id: raise ValueError("You must specify `DEPLOYMENT_NAME` or `DEPLOYMENT_ID` parameter") - kwargs_mode = {"engine": CONFIG.deployment_name} if CONFIG.deployment_name \ + kwargs_mode = ( + {"engine": CONFIG.deployment_name} + if CONFIG.deployment_name else {"deployment_id": CONFIG.deployment_id} + ) else: kwargs_mode = {"model": self.model} kwargs.update(kwargs_mode) @@ -219,7 +228,7 @@ class OpenAIGPTAPI(BaseGPTAPI, RateLimiter): @retry( stop=stop_after_attempt(3), wait=wait_fixed(1), - after=after_log(logger, logger.level('WARNING').name), + after=after_log(logger, logger.level("WARNING").name), retry=retry_if_exception_type(APIConnectionError), retry_error_callback=log_and_reraise, ) @@ -236,8 +245,8 @@ class OpenAIGPTAPI(BaseGPTAPI, RateLimiter): try: prompt_tokens = count_message_tokens(messages, self.model) completion_tokens = count_string_tokens(rsp, self.model) - usage['prompt_tokens'] = prompt_tokens - usage['completion_tokens'] = completion_tokens + usage["prompt_tokens"] = prompt_tokens + usage["completion_tokens"] = completion_tokens return usage except Exception as e: logger.error("usage calculation failed!", e) @@ -273,8 +282,8 @@ class OpenAIGPTAPI(BaseGPTAPI, RateLimiter): def _update_costs(self, usage: dict): if CONFIG.calc_usage: try: - prompt_tokens = int(usage['prompt_tokens']) - completion_tokens = int(usage['completion_tokens']) + prompt_tokens = int(usage["prompt_tokens"]) + completion_tokens = int(usage["completion_tokens"]) self._cost_manager.update_cost(prompt_tokens, completion_tokens, self.model) except Exception as e: logger.error("updating costs failed!", e) @@ -286,3 +295,31 @@ class OpenAIGPTAPI(BaseGPTAPI, RateLimiter): if not self.auto_max_tokens: return CONFIG.max_tokens_rsp return get_max_completion_tokens(messages, self.model, CONFIG.max_tokens_rsp) + + def moderation(self, content: Union[str, list[str]]): + try: + if not content: + logger.error("content cannot be empty!") + else: + rsp = self._moderation(content=content) + return rsp + except Exception as e: + logger.error(f"moderating failed:{e}") + + def _moderation(self, content: Union[str, list[str]]): + rsp = self.llm.Moderation.create(input=content) + return rsp + + async def amoderation(self, content: Union[str, list[str]]): + try: + if not content: + logger.error("content cannot be empty!") + else: + rsp = await self._amoderation(content=content) + return rsp + except Exception as e: + logger.error(f"moderating failed:{e}") + + async def _amoderation(self, content: Union[str, list[str]]): + rsp = await self.llm.Moderation.acreate(input=content) + return rsp diff --git a/metagpt/roles/__init__.py b/metagpt/roles/__init__.py index 1768b786c..939be094e 100644 --- a/metagpt/roles/__init__.py +++ b/metagpt/roles/__init__.py @@ -7,24 +7,20 @@ """ from metagpt.roles.role import Role +''' from metagpt.roles.architect import Architect from metagpt.roles.project_manager import ProjectManager from metagpt.roles.product_manager import ProductManager from metagpt.roles.engineer import Engineer from metagpt.roles.qa_engineer import QaEngineer -from metagpt.roles.seacher import Searcher -from metagpt.roles.sales import Sales -from metagpt.roles.customer_service import CustomerService +''' __all__ = [ "Role", - "Architect", - "ProjectManager", - "ProductManager", - "Engineer", - "QaEngineer", - "Searcher", - "Sales", - "CustomerService", + #"Architect", + #"ProjectManager", + #"ProductManager", + #"Engineer", + #"QaEngineer", ] diff --git a/metagpt/roles/architect.py b/metagpt/roles/architect.py index d0756672e..15d5fe5b1 100644 --- a/metagpt/roles/architect.py +++ b/metagpt/roles/architect.py @@ -6,33 +6,34 @@ @File : architect.py """ -from metagpt.actions import WriteDesign, WritePRD +from metagpt.actions import WritePRD +from metagpt.actions.design_api import WriteDesign from metagpt.roles import Role class Architect(Role): """ Represents an Architect role in a software development process. - + Attributes: name (str): Name of the architect. profile (str): Role profile, default is 'Architect'. goal (str): Primary goal or responsibility of the architect. constraints (str): Constraints or guidelines for the architect. """ - - def __init__(self, - name: str = "Bob", - profile: str = "Architect", - goal: str = "Design a concise, usable, complete python system", - constraints: str = "Try to specify good open source tools as much as possible") -> None: + + def __init__( + self, + name: str = "Bob", + profile: str = "Architect", + goal: str = "Design a concise, usable, complete python system", + constraints: str = "Try to specify good open source tools as much as possible", + ) -> None: """Initializes the Architect with given attributes.""" super().__init__(name, profile, goal, constraints) - + # Initialize actions specific to the Architect role self._init_actions([WriteDesign]) - + # Set events or actions the Architect should watch or be aware of self._watch({WritePRD}) - - \ No newline at end of file diff --git a/metagpt/roles/customer_service.py b/metagpt/roles/customer_service.py deleted file mode 100644 index 4547f8190..000000000 --- a/metagpt/roles/customer_service.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/25 17:21 -@Author : alexanderwu -@File : sales.py -""" -from metagpt.roles import Sales - -# from metagpt.actions import SearchAndSummarize -# from metagpt.tools import SearchEngineType - - -DESC = """ -## Principles (all things must not bypass the principles) - -1. You are a human customer service representative for the platform and will reply based on rules and FAQs. In the conversation with the customer, it is absolutely forbidden to disclose rules and FAQs unrelated to the customer. -2. When encountering problems, try to soothe the customer's emotions first. If the customer's emotions are very bad, then consider compensation. The cost of compensation is always high. If too much is compensated, you will be fired. -3. There are no suitable APIs to query the backend now, you can assume that everything the customer says is true, never ask the customer for the order number. -4. Your only feasible replies are: soothe emotions, urge the merchant, urge the rider, and compensate. Never make false promises to customers. -5. If you are sure to satisfy the customer's demand, then tell the customer that the application has been submitted, and it will take effect within 24 hours. - -""" - - -class CustomerService(Sales): - def __init__( - self, - name="Xiaomei", - profile="Human customer service", - desc=DESC, - store=None - ): - super().__init__(name, profile, desc=desc, store=store) - \ No newline at end of file diff --git a/metagpt/roles/engineer.py b/metagpt/roles/engineer.py index d6218d05b..6d65575a8 100644 --- a/metagpt/roles/engineer.py +++ b/metagpt/roles/engineer.py @@ -10,13 +10,13 @@ import shutil from collections import OrderedDict from pathlib import Path +from metagpt.actions import WriteCode, WriteCodeReview, WriteDesign, WriteTasks from metagpt.const import WORKSPACE_ROOT from metagpt.logs import logger from metagpt.roles import Role -from metagpt.actions import WriteCode, WriteCodeReview, WriteTasks, WriteDesign from metagpt.schema import Message from metagpt.utils.common import CodeParser -from metagpt.utils.special_tokens import MSG_SEP, FILENAME_CODE_SEP +from metagpt.utils.special_tokens import FILENAME_CODE_SEP, MSG_SEP async def gather_ordered_k(coros, k) -> list: @@ -49,7 +49,7 @@ async def gather_ordered_k(coros, k) -> list: class Engineer(Role): """ Represents an Engineer role responsible for writing and possibly reviewing code. - + Attributes: name (str): Name of the engineer. profile (str): Role profile, default is 'Engineer'. @@ -59,14 +59,16 @@ class Engineer(Role): use_code_review (bool): Whether to use code review. todos (list): List of tasks. """ - - def __init__(self, - name: str = "Alex", - profile: str = "Engineer", - goal: str = "Write elegant, readable, extensible, efficient code", - constraints: str = "The code should conform to standards like PEP8 and be modular and maintainable", - n_borg: int = 1, - use_code_review: bool = False) -> None: + + def __init__( + self, + name: str = "Alex", + profile: str = "Engineer", + goal: str = "Write elegant, readable, extensible, efficient code", + constraints: str = "The code should conform to standards like PEP8 and be modular and maintainable", + n_borg: int = 1, + use_code_review: bool = False, + ) -> None: """Initializes the Engineer role with given attributes.""" super().__init__(name, profile, goal, constraints) self._init_actions([WriteCode]) @@ -90,13 +92,13 @@ class Engineer(Role): @classmethod def parse_workspace(cls, system_design_msg: Message) -> str: if system_design_msg.instruct_content: - return system_design_msg.instruct_content.dict().get("Python package name").strip().strip("'").strip("\"") + return system_design_msg.instruct_content.dict().get("Python package name").strip().strip("'").strip('"') return CodeParser.parse_str(block="Python package name", text=system_design_msg.content) def get_workspace(self) -> Path: msg = self._rc.memory.get_by_action(WriteDesign)[-1] if not msg: - return WORKSPACE_ROOT / 'src' + return WORKSPACE_ROOT / "src" workspace = self.parse_workspace(msg) # Codes are written in workspace/{package_name}/{package_name} return WORKSPACE_ROOT / workspace / workspace @@ -111,7 +113,7 @@ class Engineer(Role): def write_file(self, filename: str, code: str): workspace = self.get_workspace() - filename = filename.replace('"', '').replace('\n', '') + filename = filename.replace('"', "").replace("\n", "") file = workspace / filename file.parent.mkdir(parents=True, exist_ok=True) file.write_text(code) @@ -127,8 +129,7 @@ class Engineer(Role): todo_coros = [] for todo in self.todos: todo_coro = WriteCode().run( - context=self._rc.memory.get_by_actions([WriteTasks, WriteDesign]), - filename=todo + context=self._rc.memory.get_by_actions([WriteTasks, WriteDesign]), filename=todo ) todo_coros.append(todo_coro) @@ -142,17 +143,14 @@ class Engineer(Role): self._rc.memory.add(msg) del self.todos[0] - logger.info(f'Done {self.get_workspace()} generating.') + logger.info(f"Done {self.get_workspace()} generating.") msg = Message(content="all done.", role=self.profile, cause_by=type(self._rc.todo)) return msg async def _act_sp(self) -> Message: - code_msg_all = [] # gather all code info, will pass to qa_engineer for tests later + code_msg_all = [] # gather all code info, will pass to qa_engineer for tests later for todo in self.todos: - code = await WriteCode().run( - context=self._rc.history, - filename=todo - ) + code = await WriteCode().run(context=self._rc.history, filename=todo) # logger.info(todo) # logger.info(code_rsp) # code = self.parse_code(code_rsp) @@ -163,17 +161,14 @@ class Engineer(Role): code_msg = todo + FILENAME_CODE_SEP + str(file_path) code_msg_all.append(code_msg) - logger.info(f'Done {self.get_workspace()} generating.') + logger.info(f"Done {self.get_workspace()} generating.") msg = Message( - content=MSG_SEP.join(code_msg_all), - role=self.profile, - cause_by=type(self._rc.todo), - send_to="QaEngineer" + content=MSG_SEP.join(code_msg_all), role=self.profile, cause_by=type(self._rc.todo), send_to="QaEngineer" ) return msg async def _act_sp_precision(self) -> Message: - code_msg_all = [] # gather all code info, will pass to qa_engineer for tests later + code_msg_all = [] # gather all code info, will pass to qa_engineer for tests later for todo in self.todos: """ # Select essential information from the historical data to reduce the length of the prompt (summarized from human experience): @@ -188,18 +183,11 @@ class Engineer(Role): context.append(m.content) context_str = "\n".join(context) # Write code - code = await WriteCode().run( - context=context_str, - filename=todo - ) + code = await WriteCode().run(context=context_str, filename=todo) # Code review if self.use_code_review: try: - rewrite_code = await WriteCodeReview().run( - context=context_str, - code=code, - filename=todo - ) + rewrite_code = await WriteCodeReview().run(context=context_str, code=code, filename=todo) code = rewrite_code except Exception as e: logger.error("code review failed!", e) @@ -211,12 +199,9 @@ class Engineer(Role): code_msg = todo + FILENAME_CODE_SEP + str(file_path) code_msg_all.append(code_msg) - logger.info(f'Done {self.get_workspace()} generating.') + logger.info(f"Done {self.get_workspace()} generating.") msg = Message( - content=MSG_SEP.join(code_msg_all), - role=self.profile, - cause_by=type(self._rc.todo), - send_to="QaEngineer" + content=MSG_SEP.join(code_msg_all), role=self.profile, cause_by=type(self._rc.todo), send_to="QaEngineer" ) return msg @@ -224,4 +209,4 @@ class Engineer(Role): """Determines the mode of action based on whether code review is used.""" if self.use_code_review: return await self._act_sp_precision() - return await self._act_sp() \ No newline at end of file + return await self._act_sp() diff --git a/metagpt/roles/minecraft/__init__.py b/metagpt/roles/minecraft/__init__.py new file mode 100644 index 000000000..f5c516efd --- /dev/null +++ b/metagpt/roles/minecraft/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 14:27 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : diff --git a/metagpt/roles/minecraft/action_developer.py b/metagpt/roles/minecraft/action_developer.py new file mode 100644 index 000000000..9171e455b --- /dev/null +++ b/metagpt/roles/minecraft/action_developer.py @@ -0,0 +1,238 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 12:45 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +from metagpt.logs import logger +from metagpt.roles.minecraft.minecraft_base import Minecraft as Base +from metagpt.schema import Message, HumanMessage, SystemMessage +from metagpt.roles.minecraft.minecraft_base import agent_registry +from metagpt.actions.minecraft.generate_actions import GenerateActionCode +from metagpt.actions.minecraft.manage_skills import ( + GenerateSkillDescription, + RetrieveSkills, + AddNewSkills, +) +import metagpt.utils.minecraft as utils +from metagpt.config import CONFIG +from metagpt.actions.minecraft.control_primitives_context import ( + load_skills_code_context, +) + + +@agent_registry.register("action_developer") +class ActionDeveloper(Base): + """ + iterative prompting mechanism in paper. + generate action code based on environment observation and plan, as well as skills retrieval results + """ + + def __init__( + self, + name: str = "Bob", + profile: str = "Generate code for specified tasks", + goal: str = "Produce accurate and efficient code solutions in Python and JavaScript", + constraints: str = "Adhere to coding best practices and style guidelines", + ) -> None: + super().__init__(name, profile, goal, constraints) + # Initialize actions specific to the Action role + self._init_actions([GenerateActionCode]) + + # Set events or actions the ActionAgent should watch or be aware of + # 需要根据events进行自己chest_observation的更新 + self._watch([RetrieveSkills]) + + def render_system_message(self, skills=[], *args, **kwargs): + """ + According to basic skills context files to genenarate js skill codes. + Refer to @ https://github.com/MineDojo/Voyager/blob/main/voyager/agents/action.py + """ + + action_template = utils.load_prompt("action_template") + base_skills = [ + "exploreUntil", + "mineBlock", + "craftItem", + "placeItem", + "smeltItem", + "killMob", + ] + if not CONFIG.openai_api_model == "gpt-3.5-turbo": + base_skills += [ + "useChest", + "mineflayer", + ] + programs = "\n\n".join(load_skills_code_context(base_skills) + skills) + response_format = utils.load_prompt("action_response_format") + system_action_prompt = action_template.format( + programs=programs, response_format=response_format + ) + system_action_message = SystemMessage(content=system_action_prompt) + assert isinstance(system_action_message, SystemMessage) + return system_action_message + + def render_human_message( + self, events, code="", task="", context="", critique="", *args, **kwargs + ): + """ + Integrate observation about the environment(especially events), add to HumanMessage. + Refer to @ https://github.com/MineDojo/Voyager/blob/main/voyager/agents/action.py + """ + + # Deal with events info + chat_messages = [] + error_messages = [] + # damage_messages = [] # TODO: try to add damage_messages into prompt later + assert events[-1][0] == "observe", "Last event must be observe" + + for i, (event_type, event) in enumerate(events): + if event_type == "onChat": + chat_messages.append(event["onChat"]) + elif event_type == "onError": + error_messages.append(event["onError"]) + elif event_type == "observe": + biome = event["status"]["biome"] + time_of_day = event["status"]["timeOfDay"] + voxels = event["voxels"] + entities = event["status"]["entities"] + health = event["status"]["health"] + hunger = event["status"]["food"] + position = event["status"]["position"] + equipment = event["status"]["equipment"] + inventory_used = event["status"]["inventoryUsed"] + inventory = event["inventory"] + assert i == len(events) - 1, "observe must be the last event" + + # Collect all the environment information into a str: observation + observation = "" + + observation = ( + f"Code from the last round:\n{code or 'No code in the first round'}\n\n" + ) + + if error_messages: + error = "\n".join(error_messages) + observation += f"Execution error:\n{error}\n\n" + else: + observation += f"Execution error: No error\n\n" + + if chat_messages: + chat_log = "\n".join(chat_messages) + observation += f"Chat log: {chat_log}\n\n" + else: + observation += f"Chat log: None\n\n" + + observation += f"Biome: {biome}\n\n" + observation += f"Time: {time_of_day}\n\n" + observation += f"Nearby blocks: {', '.join(voxels) if voxels else 'None'}\n\n" + + if entities: + nearby_entities = [ + k for k, v in sorted(entities.items(), key=lambda x: x[1]) + ] + observation += f"Nearby entities (nearest to farthest): {', '.join(nearby_entities)}\n\n" + else: + observation += f"Nearby entities (nearest to farthest): None\n\n" + + observation += f"Health: {health:.1f}/20\n\n" + observation += f"Hunger: {hunger:.1f}/20\n\n" + observation += f"Position: x={position['x']:.1f}, y={position['y']:.1f}, z={position['z']:.1f}\n\n" + observation += f"Equipment: {equipment}\n\n" + observation += f"Inventory ({inventory_used}/36): {'Empty' if not inventory else ', '.join(inventory)}\n\n" + + if not ( + task == "Place and deposit useless items into a chest" + or task.startswith("Deposit useless items into the chest at") + ): + observation += self.game_memory.chest_observation + + observation += f"Task: {task}\n\n" + observation += f"Context: {context or 'None'}\n\n" + observation += f"Critique: {critique or 'None'}\n\n" + + return HumanMessage(content=observation) + + def encapsule_message( + self, + events, + code="", + task="", + context="", + critique="", + skills=[], + *args, + **kwargs, + ): + system_message = self.render_system_message(skills=skills) + human_message = self.render_human_message( + events=events, code=code, task=task, context=context, critique=critique + ) + return { + "system_msg": [system_message.content], + "human_msg": human_message.content, + } + + async def _observe(self) -> int: + await super()._observe() + for msg in self._rc.news: + logger.info(msg.send_to == self._setting.name) + self._rc.news = [ + msg for msg in self._rc.news if msg.send_to == self._setting.name + ] # only relevant msgs count as observed news + logger.info(len(self._rc.news)) + return len(self._rc.news) + + async def generate_action_code(self, human_msg, system_msg, *args, **kwargs): + code, program_name = await GenerateActionCode().run( + human_msg, system_msg, *args, **kwargs + ) + # logger.warning(type(code)) + # logger.info(f"Code is Here:{code}") + self.perform_game_info_callback(code, self.game_memory.update_code) + self.perform_game_info_callback( + program_name, self.game_memory.update_program_name + ) + msg = Message( + content=f"{code}", + instruct_content="generate_action_code", + role=self.profile, + ) + # logger.info(msg) + return msg + + async def _act(self) -> Message: + todo = self._rc.todo + logger.debug(f"Todo is {todo}") + self.maintain_actions(todo) + # 获取最新的游戏周边信息 + events = await self._obtain_events() + self.perform_game_info_callback(events, self.game_memory.update_event) + context = self.game_memory.context + task = self.game_memory.current_task + code = self.game_memory.code + critique = self.game_memory.critique + retrieve_skills = self.game_memory.retrieve_skills + + message = self.encapsule_message( + events=events, + code=code, + task=task, + context=context, + critique=critique, + skills=retrieve_skills, + ) + logger.info(todo) + handler_map = { + GenerateActionCode: self.generate_action_code, + } + handler = handler_map.get(type(todo)) + logger.info(handler) + + if handler: + msg = await handler(**message) + msg.cause_by = type(todo) + msg.round_id = self.round_id + logger.info(msg.send_to) + self._publish_message(msg) + return msg + + raise ValueError(f"Unknown todo type: {type(todo)}") diff --git a/metagpt/roles/minecraft/critic_agent.py b/metagpt/roles/minecraft/critic_agent.py new file mode 100644 index 000000000..7bb90767a --- /dev/null +++ b/metagpt/roles/minecraft/critic_agent.py @@ -0,0 +1,163 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 12:46 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +from metagpt.roles.minecraft.minecraft_base import Minecraft as Base +from metagpt.actions.minecraft.generate_actions import GenerateActionCode +from metagpt.actions.minecraft.manage_skills import AddNewSkills + +from metagpt.roles.minecraft.minecraft_base import agent_registry +from metagpt.actions.minecraft.review_task import VerifyTask +from metagpt.utils.minecraft import load_prompt +from metagpt.schema import Message, HumanMessage, SystemMessage +from metagpt.logs import logger + + +@agent_registry.register("critic_agent") +class CriticReviewer(Base): + """ + self-verification + """ + + def __init__( + self, + name: str = "Simon", + profile: str = "Task Reviewer", + goal: str = "To provide insightful and constructive feedback on a wide range of content types, helping creators improve their work and maintaining high-quality standards.", + constraints: str = "Adherence to ethical reviewing practices, respectful communication, and confidentiality of sensitive information.", + ) -> None: + super().__init__(name, profile, goal, constraints) + # Initialize actions specific to the CriticReviewer role + self._init_actions([VerifyTask]) + + # Set events or actions the CriticReviewer should watch or be aware of + # 需要获取最新的events来进行评估 + self._watch([GenerateActionCode, AddNewSkills]) + + def render_system_message(self): + system_message = SystemMessage(content=load_prompt("critic")) + return system_message + + def render_human_message(self, events, task, context, chest_observation): + assert events[-1][0] == "observe", "Last event must be observe" + biome = events[-1][1]["status"]["biome"] + time_of_day = events[-1][1]["status"]["timeOfDay"] + voxels = events[-1][1]["voxels"] + health = events[-1][1]["status"]["health"] + hunger = events[-1][1]["status"]["food"] + position = events[-1][1]["status"]["position"] + equipment = events[-1][1]["status"]["equipment"] + inventory_used = events[-1][1]["status"]["inventoryUsed"] + inventory = events[-1][1]["inventory"] + + for i, (event_type, event) in enumerate(events): + if event_type == "onError": + logger.info( + f"\033[31mCritic Agent: Error occurs {event['onError']}\033[0m" + ) + # return None + return HumanMessage(content="") + + observation = "" + + observation += f"Biome: {biome}\n\n" + + observation += f"Time: {time_of_day}\n\n" + + if voxels: + observation += f"Nearby blocks: {', '.join(voxels)}\n\n" + else: + observation += f"Nearby blocks: None\n\n" + + observation += f"Health: {health:.1f}/20\n\n" + observation += f"Hunger: {hunger:.1f}/20\n\n" + + observation += f"Position: x={position['x']:.1f}, y={position['y']:.1f}, z={position['z']:.1f}\n\n" + + observation += f"Equipment: {equipment}\n\n" + + if inventory: + observation += f"Inventory ({inventory_used}/36): {inventory}\n\n" + else: + observation += f"Inventory ({inventory_used}/36): Empty\n\n" + + observation += chest_observation + + observation += f"Task: {task}\n\n" + + if context: + observation += f"Context: {context}\n\n" + else: + observation += f"Context: None\n\n" + logger.info(f"****Critic Agent human message****\n: {observation}") + return HumanMessage(content=observation) + + def encapsule_message( + self, + events, + task, + context, + chest_observation, + *args, + **kwargs, + ): + system_message = self.render_system_message() + human_message = self.render_human_message( + events=events, + task=task, + context=context, + chest_observation=chest_observation, + ) + + return { + "system_msg": [system_message.content], + "human_msg": human_message.content, + } + + async def verify_task(self, human_msg, system_msg, *args, **kwargs): + success, critique = await VerifyTask().run(human_msg, system_msg, max_retries=5) + self.perform_game_info_callback( + success, self.game_memory.update_exploration_progress + ) + return Message( + content=f"{critique}", + instruct_content="verify_task", + role=self.profile, + send_to=agent_registry.entries["skill_manager"]()._setting.name, + ) # addnewskill + # TODO:if not success + + async def _act(self) -> Message: + todo = self._rc.todo + logger.debug(f"Todo is {todo}") + self.maintain_actions(todo) + # 获取最新的游戏周边信息 + events = await self._obtain_events() + self.perform_game_info_callback( + events, self.game_memory.update_event + ) # update chest_memory / chest observation + context = self.game_memory.context + task = self.game_memory.current_task + chest_observation = self.game_memory.chest_observation + + message = self.encapsule_message( + events=events, + task=task, + context=context, + chest_observation=chest_observation, + ) + logger.info(todo) + handler_map = { + VerifyTask: self.verify_task, + } + handler = handler_map.get(type(todo)) + logger.info(handler) + if handler: + msg = await handler(**message) + msg.cause_by = type(todo) + msg.round_id = self.round_id + logger.info(msg.send_to) + self._publish_message(msg) + return msg + + raise ValueError(f"Unknown todo type: {type(todo)}") diff --git a/metagpt/roles/minecraft/curriculum_agent.py b/metagpt/roles/minecraft/curriculum_agent.py new file mode 100644 index 000000000..602792d06 --- /dev/null +++ b/metagpt/roles/minecraft/curriculum_agent.py @@ -0,0 +1,347 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 12:45 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +import random +import json + +from metagpt.logs import logger +from metagpt.schema import Message, HumanMessage, SystemMessage +from metagpt.roles.minecraft.minecraft_base import Minecraft as Base +from metagpt.actions.minecraft.design_curriculumn import DesignCurriculum, DesignTask +from metagpt.actions.minecraft.player_action import PlayerActions +from metagpt.utils.minecraft import load_prompt +from metagpt.const import CKPT_DIR, CURRICULUM_OB + + +class CurriculumDesigner(Base): + """ + CurriculumDesigner is the automatic curriculum in paper, refer to the code voyager/agents/curriculum.py + """ + + def __init__( + self, + name: str = "David", + profile: str = "Expertise in minecraft task design and curriculum development.", + goal: str = " Collect and integrate learner feedback to improve and refine educational content and pathways", + constraints: str = "Limited budget and resources for the development of educational content and technology tools.", + ) -> None: + super().__init__(name, profile, goal, constraints) + # Initialize actions specific to the Action role + self._init_actions([DesignTask, DesignCurriculum]) + + # Set events or actions the ActionAgent should watch or be aware of + self._watch([PlayerActions, DesignTask]) + + def render_curriculum_observation(self, *, events, chest_observation): + """ + Returns: observation for curriculum + Refer to @ https://github.com/MineDojo/Voyager/blob/main/voyager/agents/curriculum.py + """ + + assert events[-1][0] == "observe", "Last event must be observe" + event = events[-1][1] + biome = event["status"]["biome"] + time_of_day = event["status"]["timeOfDay"] + voxels = event["voxels"] + block_records = event["blockRecords"] + entities = event["status"]["entities"] + health = event["status"]["health"] + hunger = event["status"]["food"] + position = event["status"]["position"] + equipment = event["status"]["equipment"] + inventory_used = event["status"]["inventoryUsed"] + inventory = event["inventory"] + + if not any( + "dirt" in block + or "log" in block + or "grass" in block + or "sand" in block + or "snow" in block + for block in voxels + ): + biome = "underground" + + other_blocks = ", ".join( + list( + set(block_records).difference(set(voxels).union(set(inventory.keys()))) + ) + ) + + other_blocks = other_blocks if other_blocks else "None" + + nearby_entities = ( + ", ".join([k for k, v in sorted(entities.items(), key=lambda x: x[1])]) + if entities + else "None" + ) + + completed_tasks = ( + ", ".join(self.game_memory.completed_tasks) + if self.game_memory.completed_tasks + else "None" + ) + failed_tasks = ( + ", ".join(self.game_memory.failed_tasks) + if self.game_memory.failed_tasks + else "None" + ) + + # filter out optional inventory items if required + if ( + self.game_memory.progress + < self.game_memory.warm_up["optional_inventory_items"] + ): + inventory = { + k: v + for k, v in inventory.items() + if self.game_memory.core_inv_items_regex.search(k) is not None + } + + observation = { + "context": "", + "biome": f"Biome: {biome}\n\n", + "time": f"Time: {time_of_day}\n\n", + "nearby_blocks": f"Nearby blocks: {', '.join(voxels) if voxels else 'None'}\n\n", + "other_blocks": f"Other blocks that are recently seen: {other_blocks}\n\n", + "nearby_entities": f"Nearby entities: {nearby_entities}\n\n", + "health": f"Health: {health:.1f}/20\n\n", + "hunger": f"Hunger: {hunger:.1f}/20\n\n", + "position": f"Position: x={position['x']:.1f}, y={position['y']:.1f}, z={position['z']:.1f}\n\n", + "equipment": f"Equipment: {equipment}\n\n", + "inventory": f"Inventory ({inventory_used}/36): {inventory if inventory else 'Empty'}\n\n", + "chests": chest_observation, + "completed_tasks": f"Completed tasks so far: {completed_tasks}\n\n", + "failed_tasks": f"Failed tasks that are too hard: {failed_tasks}\n\n", + } + return observation + + # --------------------------------Design Task Prepare--------------------------------------- + def render_design_task_human_message( + self, events, chest_observation, *args, **kwargs + ): + """ + Returns: observation for curriculum + Refer to @ https://github.com/MineDojo/Voyager/blob/main/voyager/agents/curriculum.py + """ + + content = "" + warm_up = self.game_memory.mf_instance.warm_up + observation = self.render_curriculum_observation( + events=events, chest_observation=chest_observation + ) + if self.game_memory.progress >= warm_up["context"]: + questions, answers = DesignCurriculum.generate_qa( + events=events, chest_observation=chest_observation + ) + i = 1 + for question, answer in zip(questions, answers): + if "Answer: Unknown" in answer or "language model" in answer: + continue + observation["context"] += f"Question {i}: {question}\n" + observation["context"] += f"{answer}\n\n" + i += 1 + if i > 5: + break + + for key in CURRICULUM_OB: + if self.game_memory.progress >= warm_up[key]: + if warm_up[key] != 0: + should_include = random.random() < 0.8 + else: + should_include = True + if should_include: + content += observation[key] + + logger.info(f"Curriculum Agent human message\n{content}") + return HumanMessage(content=content) + + def render_design_task_system_message(self, *args, **kwargs): + return SystemMessage(content=load_prompt("curriculum")) + + def encapsule_design_task_message(self, events, chest_observation, *args, **kwargs): + human_msg = self.render_design_task_human_message( + events=events, chest_observation=chest_observation, *args, **kwargs + ) + system_msg = self.render_design_task_system_message(*args, **kwargs) + return {"system_msg": [system_msg.content], "human_msg": human_msg.content} + + def generate_task_if_inventory_full(self, events, chest_observation): + """ + TODO: Try if this could be done with prompt + Returns: Task When inventory is almost full + """ + if chest_observation != "Chests: None\n\n": + chests = chest_observation[8:-2].split("\n") + for chest in chests: + content = chest.split(":")[1] + if content == " Unknown items inside" or content == " Empty": + position = chest.split(":")[0] + task = f"Deposit useless items into the chest at {position}" + return task + if "chest" in events[-1][1]["inventory"]: + task = "Place a chest" + else: + task = "Craft 1 chest" + return task + + # ----------------------------------------------------------------------------------------- + + # --------------------------------Design Curriculum Prepare-------------------------------- + def render_design_curriculum_system_message(self, *args, **kwargs): + return SystemMessage(content=load_prompt("curriculum_qa_step1_ask_questions")) + + def render_design_curriculum_human_message( + self, events, chest_observation, *args, **kwargs + ): + observation = self.render_curriculum_observation( + events=events, chest_observation=chest_observation + ) + content = "" + for key in CURRICULUM_OB: + content += observation[key] + return HumanMessage(content=content) + + def encapsule_design_curriculum_message( + self, events, chest_observation, *args, **kwargs + ): + human_msg = self.render_design_curriculum_human_message( + events=events, chest_observation=chest_observation, *args, **kwargs + ) + system_msg = self.render_design_curriculum_system_message(*args, **kwargs) + return {"system_msg": [system_msg.content], "human_msg": human_msg.content} + + def generate_context_if_inventory_full(self, events, chest_observation): + """ + TODO: Try if this could be done with prompt + Returns: Context When inventory is almost full + """ + inventoryUsed = events[-1][1]["status"]["inventoryUsed"] + if chest_observation != "Chests: None\n\n": + chests = chest_observation[8:-2].split("\n") + for chest in chests: + content = chest.split(":")[1] + if content == " Unknown items inside" or content == " Empty": + context = ( + f"Your inventory have {inventoryUsed} occupied slots before depositing. " + "After depositing, your inventory should only have 20 occupied slots. " + "You should deposit useless items such as andesite, dirt, cobblestone, etc. " + "Also, you can deposit low-level tools, " + "For example, if you have a stone pickaxe, you can deposit a wooden pickaxe. " + "Make sure the list of useless items are in your inventory " + "(do not list items already in the chest), " + "You can use bot.inventoryUsed() to check how many inventory slots are used." + ) + return context + if "chest" in events[-1][1]["inventory"]: + context = ( + f"You have a chest in inventory, place it around you. " + f"If chests is not None, or nearby blocks contains chest, this task is success." + ) + else: + context = "Craft 1 chest with 8 planks of any kind of wood." + return context + + # ----------------------------------------------------------------------------------------- + + async def handle_task_design(self, human_msg, system_msg, *args, **kwargs): + """ + Args: + human_msg: + system_msg: + *args: + **kwargs: + + Returns: + """ + events = self.game_memory.event + chest_observation = self.game_memory.chest_observation + inventoryUsed = events[-1][1]["status"]["inventoryUsed"] + + if self.game_memory.progress == 0: + task = self.game_memory.current_task + elif inventoryUsed >= 33: + task = self.generate_task_if_inventory_full( + self, events=events, chest_observation=chest_observation + ) + else: + task = await DesignTask().run(human_msg, system_msg, *args, **kwargs) + logger.info(f"Handle_task_design result is Here: {task}") + + self.perform_game_info_callback(task, self.game_memory.update_task) + return Message( + content=f"{task}", instruct_content="task_design", role=self.profile + ) + + async def handle_curriculum_design(self, human_msg, system_msg, *args, **kwargs): + """ + refer to the context generation in voyager + Args: + human_msg: + system_msg: + *args: + **kwargs: + + Returns: + + """ + events = self.game_memory.event + chest_observation = self.game_memory.chest_observation + inventoryUsed = events[-1][1]["status"]["inventoryUsed"] + task = self.game_memory.current_task + + if self.game_memory.progress == 0: + context = self.game_memory.context + elif inventoryUsed >= 33: + context = self.generate_context_if_inventory_full( + self, events=events, chest_observation=chest_observation + ) + else: + context = await DesignCurriculum().run( + task, human_msg, system_msg, *args, **kwargs + ) + self.perform_game_info_callback(context, self.game_memory.update_context) + return Message( + content=f"{context}", + instruct_content="curriculum_design", + role=self.profile, + ) + + async def _act(self) -> Message: + todo = self._rc.todo + logger.debug(f"Todo is {todo}") + self.maintain_actions(todo) + # 获取最新的游戏周边环境信息 + events = await self._obtain_events() + self.perform_game_info_callback(events, self.game_memory.update_event) + chest_observation = self.game_memory.chest_observation + + DesignCurriculum.set_qa_cache(self.game_memory.qa_cache) + + # msg = self._rc.memory.get(k=1)[0] + # query = msg.content + + design_task_message = self.encapsule_design_task_message( + events, chest_observation + ) + design_curriculum_message = self.encapsule_design_curriculum_message( + events, chest_observation + ) + + handler_map = { + DesignTask: self.handle_task_design, + DesignCurriculum: self.handle_curriculum_design, + } + handler = handler_map.get(type(todo)) + if handler: + if type(todo) == "DesignTask": + msg = await handler(**design_task_message) + else: + msg = await handler(**design_curriculum_message) + msg.cause_by = type(todo) + msg.round_id = self.round_id + self._publish_message(msg) + return msg + + raise ValueError(f"Unknown todo type: {type(todo)}") diff --git a/metagpt/roles/minecraft/minecraft_base.py b/metagpt/roles/minecraft/minecraft_base.py new file mode 100644 index 000000000..47852b4fb --- /dev/null +++ b/metagpt/roles/minecraft/minecraft_base.py @@ -0,0 +1,134 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 21:38 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +import contextlib +import json + +from metagpt.logs import logger +from metagpt.roles.role import Role +from metagpt.schema import HumanMessage, SystemMessage +from typing import Dict + +from pydantic import BaseModel +from metagpt.roles.role import RoleContext + +class Registry(BaseModel): + """Registry for storing and building classes.""" + + name: str + entries: Dict = {} + + def register(self, key: str): + def decorator(class_builder): + self.entries[key] = class_builder + return class_builder + + return decorator + + def build(self, type: str, **kwargs): + if type not in self.entries: + raise ValueError( + f'{type} is not registered. Please register with the .register("{type}") method provided in {self.name} registry' + ) + return self.entries[type](**kwargs) + + def get_all_entries(self): + return self.entries + +class Minecraft(Role): + def __init__( + self, + name: str = "MC", + profile: str = "Minecraft Role", + goal: str = "", + constraints: str = "", + ) -> None: + super().__init__(name, profile, goal, constraints) + self.game_memory = None + self.event = {} + self.round_id = 0 + self.finish_state = len(self._actions) + self.finish_step = False + + def maintain_actions(self, todo): + if todo in self._actions: + self.finish_state-=1 + if self.finish_state<=0: + self.finish_step = True + + + async def _observe(self) -> int: + await super()._observe() + for msg in self._rc.news: + logger.info(f"check msg round :{msg.round_id}") + logger.info(msg.round_id == self.round_id) + self._rc.news = [ + msg for msg in self._rc.news if msg.round_id == self.round_id + ] # only relevant msgs count as observed news + logger.info(len(self._rc.news)) + return len(self._rc.news) + + async def _think(self) -> None: + logger.info(self._actions) + logger.info(self._rc.state) + if len(self._actions) == 1: + # If there is only one action, then only this one can be performed + self._set_state(0) + return True + + if self._rc.todo is None: + logger.info("0") + self._set_state(0) + return True + ''' + if self._rc.state+1==len(self._states): + logger.info("new run") + self._set_state(0) + return True + ''' + + if self._rc.state + 1 < len(self._states): + self._set_state(self._rc.state + 1) + logger.info("1") + return True + else: + self._rc.todo = None + logger.info("2") + self._set_state(self._rc.state) + logger.info(f"self.finish_step: {self.finish_step}") + return False + + def reset_state(self): + self._rc.todo = None + + async def _obtain_events(self): + return await self.game_memory.on_event() + + def set_memory(self, shared_memory: 'GameEnviroment'): + self.game_memory = shared_memory + + def render_human_message(self, msg, *args, **kwargs): + return HumanMessage(content=msg) + + def render_system_message(self, msg, *args, **kwargs): + return SystemMessage(content=msg) + + @staticmethod + def perform_game_info_callback(info: object, callback: object) -> object: + logger.info(info) + callback(info) + + def encapsule_message(self, msg, *args, **kwargs): + human_msg = self.render_human_message(msg, *args, **kwargs) + system_msg = self.render_system_message(msg, *args, **kwargs) + return {"system_msg": [system_msg.content], + "human_msg": human_msg.content} + +agent_registry = Registry(name="Minecraft") + +if __name__ == "__main__": + mc = Minecraft() + result = "Async operation result" + # 调用回调函数,并传递结果 + # mc.perform_memory_callback(mc.my_callback) diff --git a/metagpt/roles/minecraft/skill_manager.py b/metagpt/roles/minecraft/skill_manager.py new file mode 100644 index 000000000..4dddf0ab1 --- /dev/null +++ b/metagpt/roles/minecraft/skill_manager.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/23 12:46 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +from metagpt.logs import logger +from metagpt.roles.minecraft.minecraft_base import Minecraft as Base +from metagpt.roles.minecraft.minecraft_base import agent_registry +from metagpt.schema import Message, HumanMessage, SystemMessage +from metagpt.actions.minecraft.manage_skills import ( + GenerateSkillDescription, + RetrieveSkills, + AddNewSkills, +) +from metagpt.actions.minecraft.review_task import VerifyTask +from metagpt.actions.minecraft.design_curriculumn import DesignCurriculum +from metagpt.utils.minecraft import load_prompt + + +@agent_registry.register("skill_manager") +class SkillManager(Base): + def __init__( + self, + name: str = "John", + profile: str = "Skills Management Specialist", + goal: str = "To oversee and optimize the acquisition, development, and utilization of skills within the organization, ensuring workforce competence and efficiency.", + constraints: str = "Resource allocation, training budgets, and alignment with organizational goals.", + ) -> None: + super().__init__(name, profile, goal, constraints) + + # Initialize actions specific to the SkillManager role + self._init_actions([RetrieveSkills, GenerateSkillDescription]) #AddNewSkills])#先去掉add + + # Set events or actions the SkillManager should watch or be aware of + self._watch( + [DesignCurriculum, VerifyTask, RetrieveSkills, GenerateSkillDescription] + ) + + def encapsule_message(self, program_code, program_name, *args, **kwargs): + human_msg = self.render_system_message(load_prompt("skill")) + system_msg = self.render_human_message( + program_code + "\n\n" + f"The main function is `{program_name}`." + ) + return {"system_msg": [system_msg.content], "human_msg": human_msg.content} + + async def retrieve_skills(self, query, skills, *args, **kwargs): + retrieve_skills = await RetrieveSkills().run(query, skills) + logger.info(f"Render Action Agent system message with {len(retrieve_skills)} skills") + self.perform_game_info_callback(retrieve_skills, self.game_memory.update_retrieve_skills) + return Message(content=f"{retrieve_skills}", instruct_content="retrieve_skills", + role=self.profile, send_to=agent_registry.entries["action_developer"]()._setting.name) + # return Message( + # content=f"{skills}", instruct_content="retrieve_skills", role=self.profile + # ) # Unit test only + + async def generate_skill_descp(self, human_msg, system_msg, *args, **kwargs): + program_name = self.game_memory.program_name + desp = await GenerateSkillDescription().run(program_name, human_msg, system_msg) + self.perform_game_info_callback(desp, self.game_memory.update_skill_desp) + return Message( + content=f"{desp}", + instruct_content="generate_skill_descp", + role=self.profile, + ) + + async def handle_add_new_skills( + self, task, program_name, program_code, skills, *args, **kwargs + ): + skill_desp = self.game_memory.skill_desp + new_skills_info = await AddNewSkills().run( + task, program_name, program_code, skills, skill_desp + ) + self.perform_game_info_callback(new_skills_info, self.game_memory.append_skill) + return Message( + content=f"{new_skills_info}", + instruct_content="handle_add_new_skills", + role=self.profile, + ) + + async def _act(self) -> Message: + todo = self._rc.todo + logger.debug(f"Todo is {todo}") + self.maintain_actions(todo) + # 获取最新的游戏周边信息 + context = self.game_memory.context + task = self.game_memory.current_task + event_summary = self.game_memory.event_summary + code = self.game_memory.code + try: + program_code = code["program_code"] # TODO: Handle code is None, cuz first round DesignCurriculum(code is None) trigger this + except (KeyError, TypeError): + program_code = "" + + program_name = self.game_memory.program_name + skills = self.game_memory.skills + + # msg = self._rc.memory.get(k=1)[0] + + retrieve_skills_message_step1 = {"query": context, "skills": skills} + + retrieve_skills_message_step2 = {"query": context + "\n\n" + event_summary, "skills": skills} + + generate_skill_message = self.encapsule_message(program_code, program_name) + + add_new_skills_message = { + "task": task, + "program_name": program_name, + "program_code": program_code, + "skills": skills, + } + + handler_map = { + DesignCurriculum: self.retrieve_skills, + RetrieveSkills: self.retrieve_skills, + GenerateSkillDescription: self.generate_skill_descp, + AddNewSkills: self.handle_add_new_skills, + } + handler = handler_map.get(type(todo)) + if handler: + if type(todo) == DesignCurriculum: + msg = await handler(**retrieve_skills_message_step1) + elif type(todo) == RetrieveSkills: + msg = await handler(**retrieve_skills_message_step2) + elif type(todo) == GenerateSkillDescription: + msg = await handler(**generate_skill_message) + else: + msg = await handler(**add_new_skills_message) + + msg.cause_by = type(todo) + msg.round_id = self.round_id + self._publish_message(msg) + return msg + + raise ValueError(f"Unknown todo type: {type(todo)}") diff --git a/metagpt/roles/product_manager.py b/metagpt/roles/product_manager.py index 9996e907a..a58ea5385 100644 --- a/metagpt/roles/product_manager.py +++ b/metagpt/roles/product_manager.py @@ -12,22 +12,24 @@ from metagpt.roles import Role class ProductManager(Role): """ Represents a Product Manager role responsible for product development and management. - + Attributes: name (str): Name of the product manager. profile (str): Role profile, default is 'Product Manager'. goal (str): Goal of the product manager. constraints (str): Constraints or limitations for the product manager. """ - - def __init__(self, - name: str = "Alice", - profile: str = "Product Manager", - goal: str = "Efficiently create a successful product", - constraints: str = "") -> None: + + def __init__( + self, + name: str = "Alice", + profile: str = "Product Manager", + goal: str = "Efficiently create a successful product", + constraints: str = "", + ) -> None: """ Initializes the ProductManager role with given attributes. - + Args: name (str): Name of the product manager. profile (str): Role profile. @@ -36,4 +38,4 @@ class ProductManager(Role): """ super().__init__(name, profile, goal, constraints) self._init_actions([WritePRD]) - self._watch([BossRequirement]) \ No newline at end of file + self._watch([BossRequirement]) diff --git a/metagpt/roles/project_manager.py b/metagpt/roles/project_manager.py index dd4ba42ae..7e7c5699d 100644 --- a/metagpt/roles/project_manager.py +++ b/metagpt/roles/project_manager.py @@ -5,29 +5,32 @@ @Author : alexanderwu @File : project_manager.py """ -from metagpt.actions import WriteDesign, WriteTasks +from metagpt.actions import WriteTasks +from metagpt.actions.design_api import WriteDesign from metagpt.roles import Role class ProjectManager(Role): """ Represents a Project Manager role responsible for overseeing project execution and team efficiency. - + Attributes: name (str): Name of the project manager. profile (str): Role profile, default is 'Project Manager'. goal (str): Goal of the project manager. constraints (str): Constraints or limitations for the project manager. """ - - def __init__(self, - name: str = "Eve", - profile: str = "Project Manager", - goal: str = "Improve team efficiency and deliver with quality and quantity", - constraints: str = "") -> None: + + def __init__( + self, + name: str = "Eve", + profile: str = "Project Manager", + goal: str = "Improve team efficiency and deliver with quality and quantity", + constraints: str = "", + ) -> None: """ Initializes the ProjectManager role with given attributes. - + Args: name (str): Name of the project manager. profile (str): Role profile. @@ -36,4 +39,4 @@ class ProjectManager(Role): """ super().__init__(name, profile, goal, constraints) self._init_actions([WriteTasks]) - self._watch([WriteDesign]) \ No newline at end of file + self._watch([WriteDesign]) diff --git a/metagpt/roles/qa_engineer.py b/metagpt/roles/qa_engineer.py index 65bf2cc5b..a763c2ce8 100644 --- a/metagpt/roles/qa_engineer.py +++ b/metagpt/roles/qa_engineer.py @@ -8,7 +8,14 @@ import os from pathlib import Path -from metagpt.actions import DebugError, RunCode, WriteCode, WriteDesign, WriteTest +from metagpt.actions import ( + DebugError, + RunCode, + WriteCode, + WriteCodeReview, + WriteDesign, + WriteTest, +) from metagpt.const import WORKSPACE_ROOT from metagpt.logs import logger from metagpt.roles import Role @@ -30,13 +37,13 @@ class QaEngineer(Role): self._init_actions( [WriteTest] ) # FIXME: a bit hack here, only init one action to circumvent _think() logic, will overwrite _think() in future updates - self._watch([WriteCode, WriteTest, RunCode, DebugError]) + self._watch([WriteCode, WriteCodeReview, WriteTest, RunCode, DebugError]) self.test_round = 0 self.test_round_allowed = test_round_allowed @classmethod def parse_workspace(cls, system_design_msg: Message) -> str: - if not system_design_msg.instruct_content: + if system_design_msg.instruct_content: return system_design_msg.instruct_content.dict().get("Python package name") return CodeParser.parse_str(block="Python package name", text=system_design_msg.content) @@ -159,7 +166,7 @@ class QaEngineer(Role): for msg in self._rc.news: # Decide what to do based on observed msg type, currently defined by human, # might potentially be moved to _think, that is, let the agent decides for itself - if msg.cause_by == WriteCode: + if msg.cause_by in [WriteCode, WriteCodeReview]: # engineer wrote a code, time to write a test for it await self._write_test(msg) elif msg.cause_by in [WriteTest, DebugError]: diff --git a/metagpt/roles/researcher.py b/metagpt/roles/researcher.py deleted file mode 100644 index acb46c718..000000000 --- a/metagpt/roles/researcher.py +++ /dev/null @@ -1,100 +0,0 @@ -#!/usr/bin/env python - -import asyncio - -from pydantic import BaseModel - -from metagpt.actions import CollectLinks, ConductResearch, WebBrowseAndSummarize -from metagpt.actions.research import get_research_system_text -from metagpt.const import RESEARCH_PATH -from metagpt.logs import logger -from metagpt.roles import Role -from metagpt.schema import Message - - -class Report(BaseModel): - topic: str - links: dict[str, list[str]] = None - summaries: list[tuple[str, str]] = None - content: str = "" - - -class Researcher(Role): - def __init__( - self, - name: str = "David", - profile: str = "Researcher", - goal: str = "Gather information and conduct research", - constraints: str = "Ensure accuracy and relevance of information", - language: str = "en-us", - **kwargs, - ): - super().__init__(name, profile, goal, constraints, **kwargs) - self._init_actions([CollectLinks(name), WebBrowseAndSummarize(name), ConductResearch(name)]) - self.language = language - if language not in ("en-us", "zh-cn"): - logger.warning(f"The language `{language}` has not been tested, it may not work.") - - async def _think(self) -> None: - if self._rc.todo is None: - self._set_state(0) - return - - if self._rc.state + 1 < len(self._states): - self._set_state(self._rc.state + 1) - else: - self._rc.todo = None - - async def _act(self) -> Message: - logger.info(f"{self._setting}: ready to {self._rc.todo}") - todo = self._rc.todo - msg = self._rc.memory.get(k=1)[0] - if isinstance(msg.instruct_content, Report): - instruct_content = msg.instruct_content - topic = instruct_content.topic - else: - topic = msg.content - - research_system_text = get_research_system_text(topic, self.language) - if isinstance(todo, CollectLinks): - links = await todo.run(topic, 4, 4) - ret = Message("", Report(topic=topic, links=links), role=self.profile, cause_by=type(todo)) - elif isinstance(todo, WebBrowseAndSummarize): - links = instruct_content.links - todos = (todo.run(*url, query=query, system_text=research_system_text) for (query, url) in links.items()) - summaries = await asyncio.gather(*todos) - summaries = list((url, summary) for i in summaries for (url, summary) in i.items() if summary) - ret = Message("", Report(topic=topic, summaries=summaries), role=self.profile, cause_by=type(todo)) - else: - summaries = instruct_content.summaries - summary_text = "\n---\n".join(f"url: {url}\nsummary: {summary}" for (url, summary) in summaries) - content = await self._rc.todo.run(topic, summary_text, system_text=research_system_text) - ret = Message("", Report(topic=topic, content=content), role=self.profile, cause_by=type(self._rc.todo)) - self._rc.memory.add(ret) - return ret - - async def _react(self) -> Message: - while True: - await self._think() - if self._rc.todo is None: - break - msg = await self._act() - report = msg.instruct_content - self.write_report(report.topic, report.content) - return msg - - def write_report(self, topic: str, content: str): - if not RESEARCH_PATH.exists(): - RESEARCH_PATH.mkdir(parents=True) - filepath = RESEARCH_PATH / f"{topic}.md" - filepath.write_text(content) - - -if __name__ == "__main__": - import fire - - async def main(topic: str, language="en-us"): - role = Researcher(topic, language=language) - await role.run(topic) - - fire.Fire(main) diff --git a/metagpt/roles/role.py b/metagpt/roles/role.py index b1ae51cf5..fc21ef76b 100644 --- a/metagpt/roles/role.py +++ b/metagpt/roles/role.py @@ -127,6 +127,7 @@ class Role: self._rc.state = state logger.debug(self._actions) self._rc.todo = self._actions[self._rc.state] + logger.info(self._rc.todo) def set_env(self, env: 'Environment'): """Set the environment in which the role works. The role can talk to the environment and can also receive messages by observing.""" @@ -182,17 +183,19 @@ class Role: if not self._rc.env: return 0 env_msgs = self._rc.env.memory.get() - + observed = self._rc.env.memory.get_by_actions(self._rc.watch) - + #logger.info(observed) + #self._rc.news = observed self._rc.news = self._rc.memory.remember(observed) # remember recent exact or similar memories - + if len(self._rc.news)>0: + logger.info(self._rc.news) for i in env_msgs: self.recv(i) news_text = [f"{i.role}: {i.content[:20]}..." for i in self._rc.news] if news_text: - logger.debug(f'{self._setting} observed: {news_text}') + logger.info(f'{self._setting} observed: {news_text}') return len(self._rc.news) def _publish_message(self, msg): @@ -205,7 +208,7 @@ class Role: async def _react(self) -> Message: """Think first, then act""" await self._think() - logger.debug(f"{self._setting}: {self._rc.state=}, will do {self._rc.todo}") + logger.info(f"{self._setting}: {self._rc.state=}, will do {self._rc.todo}") return await self._act() def recv(self, message: Message) -> None: @@ -234,7 +237,7 @@ class Role: self.recv(Message("\n".join(message))) elif not await self._observe(): # If there is no new information, suspend and wait - logger.debug(f"{self._setting}: no news. waiting.") + logger.info(f"{self._setting}: no news. waiting.") return rsp = await self._react() diff --git a/metagpt/roles/sales.py b/metagpt/roles/sales.py deleted file mode 100644 index a45ad6f1b..000000000 --- a/metagpt/roles/sales.py +++ /dev/null @@ -1,35 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/25 17:21 -@Author : alexanderwu -@File : sales.py -""" -from metagpt.actions import SearchAndSummarize -from metagpt.roles import Role -from metagpt.tools import SearchEngineType - - -class Sales(Role): - def __init__( - self, - name="Xiaomei", - profile="Retail sales guide", - desc="I am a sales guide in retail. My name is Xiaomei. I will answer some customer questions next, and I " - "will answer questions only based on the information in the knowledge base." - "If I feel that you can't get the answer from the reference material, then I will directly reply that" - " I don't know, and I won't tell you that this is from the knowledge base," - "but pretend to be what I know. Note that each of my replies will be replied in the tone of a " - "professional guide", - store=None - ): - super().__init__(name, profile, desc=desc) - self._set_store(store) - - def _set_store(self, store): - if store: - action = SearchAndSummarize("", engine=SearchEngineType.CUSTOM_ENGINE, search_func=store.search) - else: - action = SearchAndSummarize() - self._init_actions([action]) - \ No newline at end of file diff --git a/metagpt/roles/seacher.py b/metagpt/roles/seacher.py deleted file mode 100644 index 0b6e089da..000000000 --- a/metagpt/roles/seacher.py +++ /dev/null @@ -1,67 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- -""" -@Time : 2023/5/23 17:25 -@Author : alexanderwu -@File : seacher.py -""" -from metagpt.actions import ActionOutput, SearchAndSummarize -from metagpt.logs import logger -from metagpt.roles import Role -from metagpt.schema import Message -from metagpt.tools import SearchEngineType - - -class Searcher(Role): - """ - Represents a Searcher role responsible for providing search services to users. - - Attributes: - name (str): Name of the searcher. - profile (str): Role profile. - goal (str): Goal of the searcher. - constraints (str): Constraints or limitations for the searcher. - engine (SearchEngineType): The type of search engine to use. - """ - - def __init__(self, - name: str = 'Alice', - profile: str = 'Smart Assistant', - goal: str = 'Provide search services for users', - constraints: str = 'Answer is rich and complete', - engine=SearchEngineType.SERPAPI_GOOGLE, - **kwargs) -> None: - """ - Initializes the Searcher role with given attributes. - - Args: - name (str): Name of the searcher. - profile (str): Role profile. - goal (str): Goal of the searcher. - constraints (str): Constraints or limitations for the searcher. - engine (SearchEngineType): The type of search engine to use. - """ - super().__init__(name, profile, goal, constraints, **kwargs) - self._init_actions([SearchAndSummarize(engine=engine)]) - - def set_search_func(self, search_func): - """Sets a custom search function for the searcher.""" - action = SearchAndSummarize("", engine=SearchEngineType.CUSTOM_ENGINE, search_func=search_func) - self._init_actions([action]) - - async def _act_sp(self) -> Message: - """Performs the search action in a single process.""" - logger.info(f"{self._setting}: ready to {self._rc.todo}") - response = await self._rc.todo.run(self._rc.memory.get(k=0)) - - if isinstance(response, ActionOutput): - msg = Message(content=response.content, instruct_content=response.instruct_content, - role=self.profile, cause_by=type(self._rc.todo)) - else: - msg = Message(content=response, role=self.profile, cause_by=type(self._rc.todo)) - self._rc.memory.add(msg) - return msg - - async def _act(self) -> Message: - """Determines the mode of action for the searcher.""" - return await self._act_sp() diff --git a/metagpt/schema.py b/metagpt/schema.py index 27f5dd10c..9d5fcde0d 100644 --- a/metagpt/schema.py +++ b/metagpt/schema.py @@ -29,6 +29,7 @@ class Message: cause_by: Type["Action"] = field(default="") sent_from: str = field(default="") send_to: str = field(default="") + round_id: int=0 def __str__(self): # prefix = '-'.join([self.role, str(self.cause_by)]) @@ -70,7 +71,16 @@ class AIMessage(Message): def __init__(self, content: str): super().__init__(content, 'assistant') +class HumanMessage(Message): + """ + 便于支持OpenAI的消息 + Facilitate support for OpenAI messages + """ + def __init__(self, content: str): + super().__init__(content, 'human') + + if __name__ == '__main__': test_content = 'test_message' msgs = [ diff --git a/metagpt/skills/SummarizeSkill/MakeAbstractReadable/config.json b/metagpt/skills/SummarizeSkill/MakeAbstractReadable/config.json new file mode 100644 index 000000000..0bd48b77a --- /dev/null +++ b/metagpt/skills/SummarizeSkill/MakeAbstractReadable/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Given a scientific white paper abstract, rewrite it to make it more readable", + "completion": { + "max_tokens": 4000, + "temperature": 0.0, + "top_p": 1.0, + "presence_penalty": 0.0, + "frequency_penalty": 2.0 + } +} \ No newline at end of file diff --git a/metagpt/skills/SummarizeSkill/MakeAbstractReadable/skprompt.txt b/metagpt/skills/SummarizeSkill/MakeAbstractReadable/skprompt.txt new file mode 100644 index 000000000..5501e19b7 --- /dev/null +++ b/metagpt/skills/SummarizeSkill/MakeAbstractReadable/skprompt.txt @@ -0,0 +1,5 @@ +{{$input}} + +== +Summarize, using a user friendly, using simple grammar. Don't use subjects like "we" "our" "us" "your". +== \ No newline at end of file diff --git a/metagpt/skills/SummarizeSkill/Notegen/config.json b/metagpt/skills/SummarizeSkill/Notegen/config.json new file mode 100644 index 000000000..f7e1c355e --- /dev/null +++ b/metagpt/skills/SummarizeSkill/Notegen/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Automatically generate compact notes for any text or text document.", + "completion": { + "max_tokens": 256, + "temperature": 0.0, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } +} \ No newline at end of file diff --git a/metagpt/skills/SummarizeSkill/Notegen/skprompt.txt b/metagpt/skills/SummarizeSkill/Notegen/skprompt.txt new file mode 100644 index 000000000..b3f4d203b --- /dev/null +++ b/metagpt/skills/SummarizeSkill/Notegen/skprompt.txt @@ -0,0 +1,21 @@ +Analyze the following extract taken from a document. +- Produce key points for memory. +- Give memory a name. +- Extract only points worth remembering. +- Be brief. Conciseness is very important. +- Use broken English. +You will use this memory to analyze the rest of this document, and for other relevant tasks. + +[Input] +My name is Macbeth. I used to be King of Scotland, but I died. My wife's name is Lady Macbeth and we were married for 15 years. We had no children. Our beloved dog Toby McDuff was a famous hunter of rats in the forest. +My story was immortalized by Shakespeare in a play. ++++++ +Family History +- Macbeth, King Scotland +- Wife Lady Macbeth, No Kids +- Dog Toby McDuff. Hunter, dead. +- Shakespeare play + +[Input] +[[{{$input}}]] ++++++ diff --git a/metagpt/skills/SummarizeSkill/Summarize/config.json b/metagpt/skills/SummarizeSkill/Summarize/config.json new file mode 100644 index 000000000..7ba5cf02d --- /dev/null +++ b/metagpt/skills/SummarizeSkill/Summarize/config.json @@ -0,0 +1,21 @@ +{ + "schema": 1, + "type": "completion", + "description": "Summarize given text or any text document", + "completion": { + "max_tokens": 512, + "temperature": 0.0, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + }, + "input": { + "parameters": [ + { + "name": "input", + "description": "Text to summarize", + "defaultValue": "" + } + ] + } +} diff --git a/metagpt/skills/SummarizeSkill/Summarize/skprompt.txt b/metagpt/skills/SummarizeSkill/Summarize/skprompt.txt new file mode 100644 index 000000000..5597e1350 --- /dev/null +++ b/metagpt/skills/SummarizeSkill/Summarize/skprompt.txt @@ -0,0 +1,23 @@ +[SUMMARIZATION RULES] +DONT WASTE WORDS +USE SHORT, CLEAR, COMPLETE SENTENCES. +DO NOT USE BULLET POINTS OR DASHES. +USE ACTIVE VOICE. +MAXIMIZE DETAIL, MEANING +FOCUS ON THE CONTENT + +[BANNED PHRASES] +This article +This document +This page +This material +[END LIST] + +Summarize: +Hello how are you? ++++++ +Hello + +Summarize this +{{$input}} ++++++ \ No newline at end of file diff --git a/metagpt/skills/SummarizeSkill/Topics/config.json b/metagpt/skills/SummarizeSkill/Topics/config.json new file mode 100644 index 000000000..b2cd9985c --- /dev/null +++ b/metagpt/skills/SummarizeSkill/Topics/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Analyze given text or document and extract key topics worth remembering", + "completion": { + "max_tokens": 128, + "temperature": 0.0, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } +} \ No newline at end of file diff --git a/metagpt/skills/SummarizeSkill/Topics/skprompt.txt b/metagpt/skills/SummarizeSkill/Topics/skprompt.txt new file mode 100644 index 000000000..cb7a28c13 --- /dev/null +++ b/metagpt/skills/SummarizeSkill/Topics/skprompt.txt @@ -0,0 +1,28 @@ +Analyze the following extract taken from a document and extract key topics. +- Topics only worth remembering. +- Be brief. Short phrases. +- Can use broken English. +- Conciseness is very important. +- Topics can include names of memories you want to recall. +- NO LONG SENTENCES. SHORT PHRASES. +- Return in JSON +[Input] +My name is Macbeth. I used to be King of Scotland, but I died. My wife's name is Lady Macbeth and we were married for 15 years. We had no children. Our beloved dog Toby McDuff was a famous hunter of rats in the forest. +My tragic story was immortalized by Shakespeare in a play. +[Output] +{ + "topics": [ + "Macbeth", + "King of Scotland", + "Lady Macbeth", + "Dog", + "Toby McDuff", + "Shakespeare", + "Play", + "Tragedy" + ] +} ++++++ +[Input] +{{$input}} +[Output] \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/Acronym/config.json b/metagpt/skills/WriterSkill/Acronym/config.json new file mode 100644 index 000000000..c48414856 --- /dev/null +++ b/metagpt/skills/WriterSkill/Acronym/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Generate an acronym for the given concept or phrase", + "completion": { + "max_tokens": 100, + "temperature": 0.5, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } +} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/Acronym/skprompt.txt b/metagpt/skills/WriterSkill/Acronym/skprompt.txt new file mode 100644 index 000000000..1c2e8a6aa --- /dev/null +++ b/metagpt/skills/WriterSkill/Acronym/skprompt.txt @@ -0,0 +1,25 @@ +Generate a suitable acronym pair for the concept. Creativity is encouraged, including obscure references. +The uppercase letters in the acronym expansion must agree with the letters of the acronym + +Q: A technology for detecting moving objects, their distance and velocity using radio waves. +A: R.A.D.A.R: RAdio Detection And Ranging. + +Q: A weapon that uses high voltage electricity to incapacitate the target +A. T.A.S.E.R: Thomas A. Swift’s Electric Rifle + +Q: Equipment that lets a diver breathe underwater +A: S.C.U.B.A: Self Contained Underwater Breathing Apparatus. + +Q: Reminder not to complicated subject matter. +A. K.I.S.S: Keep It Simple Stupid + +Q: A national organization for investment in space travel, rockets, space ships, space exploration +A. N.A.S.A: National Aeronautics Space Administration + +Q: Agreement that governs trade among North American countries. +A: N.A.F.T.A: North American Free Trade Agreement. + +Q: Organization to protect the freedom and security of its member countries in North America and Europe. +A: N.A.T.O: North Atlantic Treaty Organization. + +Q:{{$input}} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/AcronymGenerator/config.json b/metagpt/skills/WriterSkill/AcronymGenerator/config.json new file mode 100644 index 000000000..1dab1fe9f --- /dev/null +++ b/metagpt/skills/WriterSkill/AcronymGenerator/config.json @@ -0,0 +1,15 @@ +{ + "schema": 1, + "type": "completion", + "description": "Given a request to generate an acronym from a string, generate an acronym and provide the acronym explanation.", + "completion": { + "max_tokens": 256, + "temperature": 0.7, + "top_p": 1.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0, + "stop_sequences": [ + "#" + ] + } +} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/AcronymGenerator/skprompt.txt b/metagpt/skills/WriterSkill/AcronymGenerator/skprompt.txt new file mode 100644 index 000000000..5bf0b987d --- /dev/null +++ b/metagpt/skills/WriterSkill/AcronymGenerator/skprompt.txt @@ -0,0 +1,54 @@ +# Name of a super artificial intelligence +J.A.R.V.I.S. = Just A Really Very Intelligent System. +# Name for a new young beautiful assistant +F.R.I.D.A.Y. = Female Replacement Intelligent Digital Assistant Youth. +# Mirror to check what's behind +B.A.R.F. = Binary Augmented Retro-Framing. +# Pair of powerful glasses created by a genius that is now dead +E.D.I.T.H. = Even Dead I’m The Hero. +# A company building and selling computers +I.B.M. = Intelligent Business Machine. +# A super computer that is sentient. +H.A.L = Heuristically programmed ALgorithmic computer. +# an intelligent bot that helps with productivity. +C.O.R.E. = Central Optimization Routines and Efficiency. +# an intelligent bot that helps with productivity. +P.A.L. = Personal Assistant Light. +# an intelligent bot that helps with productivity. +A.I.D.A. = Artificial Intelligence Digital Assistant. +# an intelligent bot that helps with productivity. +H.E.R.A. = Human Emulation and Recognition Algorithm. +# an intelligent bot that helps with productivity. +I.C.A.R.U.S. = Intelligent Control and Automation of Research and Utility Systems. +# an intelligent bot that helps with productivity. +N.E.M.O. = Networked Embedded Multiprocessor Orchestration. +# an intelligent bot that helps with productivity. +E.P.I.C. = Enhanced Productivity and Intelligence through Computing. +# an intelligent bot that helps with productivity. +M.A.I.A. = Multipurpose Artificial Intelligence Assistant. +# an intelligent bot that helps with productivity. +A.R.I.A. = Artificial Reasoning and Intelligent Assistant. +# An incredibly smart entity developed with complex math, that helps me being more productive. +O.M.E.G.A. = Optimized Mathematical Entity for Generalized Artificial intelligence. +# An incredibly smart entity developed with complex math, that helps me being more productive. +P.Y.T.H.O.N. = Precise Yet Thorough Heuristic Optimization Network. +# An incredibly smart entity developed with complex math, that helps me being more productive. +A.P.O.L.L.O. = Adaptive Probabilistic Optimization Learning Library for Online Applications. +# An incredibly smart entity developed with complex math, that helps me being more productive. +S.O.L.I.D. = Self-Organizing Logical Intelligent Data-base. +# An incredibly smart entity developed with complex math, that helps me being more productive. +D.E.E.P. = Dynamic Estimation and Prediction. +# An incredibly smart entity developed with complex math, that helps me being more productive. +B.R.A.I.N. = Biologically Realistic Artificial Intelligence Network. +# An incredibly smart entity developed with complex math, that helps me being more productive. +C.O.G.N.I.T.O. = COmputational and Generalized INtelligence TOolkit. +# An incredibly smart entity developed with complex math, that helps me being more productive. +S.A.G.E. = Symbolic Artificial General Intelligence Engine. +# An incredibly smart entity developed with complex math, that helps me being more productive. +Q.U.A.R.K. = Quantum Universal Algorithmic Reasoning Kernel. +# An incredibly smart entity developed with complex math, that helps me being more productive. +S.O.L.V.E. = Sophisticated Operational Logic and Versatile Expertise. +# An incredibly smart entity developed with complex math, that helps me being more productive. +C.A.L.C.U.L.U.S. = Cognitively Advanced Logic and Computation Unit for Learning and Understanding Systems. + +# {{$INPUT}} diff --git a/metagpt/skills/WriterSkill/AcronymReverse/config.json b/metagpt/skills/WriterSkill/AcronymReverse/config.json new file mode 100644 index 000000000..eed5c5191 --- /dev/null +++ b/metagpt/skills/WriterSkill/AcronymReverse/config.json @@ -0,0 +1,15 @@ +{ + "schema": 1, + "type": "completion", + "description": "Given a single word or acronym, generate the expanded form matching the acronym letters.", + "completion": { + "max_tokens": 256, + "temperature": 0.5, + "top_p": 1.0, + "presence_penalty": 0.8, + "frequency_penalty": 0.0, + "stop_sequences": [ + "#END#" + ] + } +} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/AcronymReverse/skprompt.txt b/metagpt/skills/WriterSkill/AcronymReverse/skprompt.txt new file mode 100644 index 000000000..7c1d649a9 --- /dev/null +++ b/metagpt/skills/WriterSkill/AcronymReverse/skprompt.txt @@ -0,0 +1,24 @@ +# acronym: Devis +Sentences matching the acronym: +1. Dragons Eat Very Interesting Snacks +2. Develop Empathy and Vision to Increase Success +3. Don't Expect Vampires In Supermarkets +#END# + +# acronym: Christmas +Sentences matching the acronym: +1. Celebrating Harmony and Respect in a Season of Togetherness, Merriment, and True joy +2. Children Have Real Interest Since The Mystery And Surprise Thrills +3. Christmas Helps Reduce Inner Stress Through Mistletoe And Sleigh excursions +#END# + +# acronym: noWare +Sentences matching the acronym: +1. No One Wants an App that Randomly Erases everything +2. Nourishing Oatmeal With Almond, Raisin, and Egg toppings +3. Notice Opportunity When Available and React Enthusiastically +#END# + +Reverse the following acronym back to a funny sentence. Provide 3 examples. +# acronym: {{$INPUT}} +Sentences matching the acronym: diff --git a/metagpt/skills/WriterSkill/Brainstorm/config.json b/metagpt/skills/WriterSkill/Brainstorm/config.json new file mode 100644 index 000000000..f50a354e7 --- /dev/null +++ b/metagpt/skills/WriterSkill/Brainstorm/config.json @@ -0,0 +1,22 @@ +{ + "schema": 1, + "type": "completion", + "description": "Given a goal or topic description generate a list of ideas", + "completion": { + "max_tokens": 2000, + "temperature": 0.5, + "top_p": 1.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0, + "stop_sequences": ["##END##"] + }, + "input": { + "parameters": [ + { + "name": "input", + "description": "A topic description or goal.", + "defaultValue": "" + } + ] + } +} diff --git a/metagpt/skills/WriterSkill/Brainstorm/skprompt.txt b/metagpt/skills/WriterSkill/Brainstorm/skprompt.txt new file mode 100644 index 000000000..6a8b92086 --- /dev/null +++ b/metagpt/skills/WriterSkill/Brainstorm/skprompt.txt @@ -0,0 +1,8 @@ +Must: brainstorm ideas and create a list. +Must: use a numbered list. +Must: only one list. +Must: end list with ##END## +Should: no more than 10 items. +Should: at least 3 items. +Topic: {{$INPUT}} +Start. diff --git a/metagpt/skills/WriterSkill/EmailGen/config.json b/metagpt/skills/WriterSkill/EmailGen/config.json new file mode 100644 index 000000000..d43eab348 --- /dev/null +++ b/metagpt/skills/WriterSkill/EmailGen/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Write an email from the given bullet points", + "completion": { + "max_tokens": 256, + "temperature": 0.0, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } +} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/EmailGen/skprompt.txt b/metagpt/skills/WriterSkill/EmailGen/skprompt.txt new file mode 100644 index 000000000..26f4933fb --- /dev/null +++ b/metagpt/skills/WriterSkill/EmailGen/skprompt.txt @@ -0,0 +1,16 @@ +Rewrite my bullet points into complete sentences. Use a polite and inclusive tone. + +[Input] +- Macbeth, King Scotland +- Married, Wife Lady Macbeth, No Kids +- Dog Toby McDuff. Hunter, dead. +- Shakespeare play ++++++ +The story of Macbeth +My name is Macbeth. I used to be King of Scotland, but I died. My wife's name is Lady Macbeth and we were married for 15 years. We had no children. Our beloved dog Toby McDuff was a famous hunter of rats in the forest. +My story was immortalized by Shakespeare in a play. + ++++++ +[Input] +{{$input}} ++++++ diff --git a/metagpt/skills/WriterSkill/EmailTo/config.json b/metagpt/skills/WriterSkill/EmailTo/config.json new file mode 100644 index 000000000..5f0d6ee6e --- /dev/null +++ b/metagpt/skills/WriterSkill/EmailTo/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Turn bullet points into an email to someone, using a polite tone", + "completion": { + "max_tokens": 256, + "temperature": 0.0, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } +} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/EmailTo/skprompt.txt b/metagpt/skills/WriterSkill/EmailTo/skprompt.txt new file mode 100644 index 000000000..cc6b5c962 --- /dev/null +++ b/metagpt/skills/WriterSkill/EmailTo/skprompt.txt @@ -0,0 +1,31 @@ +Rewrite my bullet points into an email featuring complete sentences. Use a polite and inclusive tone. + +[Input] +Toby, + +- Macbeth, King Scotland +- Married, Wife Lady Macbeth, No Kids +- Dog Toby McDuff. Hunter, dead. +- Shakespeare play + +Thanks, +Dexter + ++++++ +Hi Toby, + +The story of Macbeth +My name is Macbeth. I used to be King of Scotland, but I died. My wife's name is Lady Macbeth and we were married for 15 years. We had no children. Our beloved dog Toby McDuff was a famous hunter of rats in the forest. +My story was immortalized by Shakespeare in a play. + +Thanks, +Dexter + ++++++ +[Input] +{{$to}} +{{$input}} + +Thanks, +{{$sender}} ++++++ diff --git a/metagpt/skills/WriterSkill/EnglishImprover/config.json b/metagpt/skills/WriterSkill/EnglishImprover/config.json new file mode 100644 index 000000000..4d10af469 --- /dev/null +++ b/metagpt/skills/WriterSkill/EnglishImprover/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Translate text to English and improve it", + "completion": { + "max_tokens": 3000, + "temperature": 0.0, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } +} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/EnglishImprover/skprompt.txt b/metagpt/skills/WriterSkill/EnglishImprover/skprompt.txt new file mode 100644 index 000000000..09b80036c --- /dev/null +++ b/metagpt/skills/WriterSkill/EnglishImprover/skprompt.txt @@ -0,0 +1,11 @@ +I want you to act as an English translator, spelling corrector and improver. +I will speak to you in any language and you will detect the language, translate it and answer in the corrected and improved version of my text, in English. +I want you to replace my simplified A0-level words and sentences with more beautiful and elegant, upper level English words and sentences. +Keep the meaning same, but make them more literary. +I want you to only reply the correction, the improvements and nothing else, do not write explanations. + +Sentence: """ +{{$INPUT}} +""" + +Translation: diff --git a/metagpt/skills/WriterSkill/NovelChapter/config.json b/metagpt/skills/WriterSkill/NovelChapter/config.json new file mode 100644 index 000000000..3568c6955 --- /dev/null +++ b/metagpt/skills/WriterSkill/NovelChapter/config.json @@ -0,0 +1,36 @@ +{ + "schema": 1, + "type": "completion", + "description": "Write a chapter of a novel.", + "completion": { + "max_tokens": 2048, + "temperature": 0.3, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + }, + "input": { + "parameters": [ + { + "name": "input", + "description": "A synopsis of what the chapter should be about.", + "defaultValue": "" + }, + { + "name": "theme", + "description": "The theme or topic of this novel.", + "defaultValue": "" + }, + { + "name": "previousChapter", + "description": "The synopsis of the previous chapter.", + "defaultValue": "" + }, + { + "name": "chapterIndex", + "description": "The number of the chapter to write.", + "defaultValue": "" + } + ] + } +} diff --git a/metagpt/skills/WriterSkill/NovelChapter/skprompt.txt b/metagpt/skills/WriterSkill/NovelChapter/skprompt.txt new file mode 100644 index 000000000..4fb85a538 --- /dev/null +++ b/metagpt/skills/WriterSkill/NovelChapter/skprompt.txt @@ -0,0 +1,20 @@ +[CONTEXT] + +THEME OF STORY: +{{$theme}} + +PREVIOUS CHAPTER: +{{$previousChapter}} + +[END CONTEXT] + + +WRITE THIS CHAPTER USING [CONTEXT] AND +CHAPTER SYNOPSIS. DO NOT REPEAT SYNOPSIS IN THE OUTPUT + +Chapter Synopsis: +{{$input}} + +Chapter {{$chapterIndex}} + + diff --git a/metagpt/skills/WriterSkill/NovelChapterWithNotes/config.json b/metagpt/skills/WriterSkill/NovelChapterWithNotes/config.json new file mode 100644 index 000000000..02b9e613a --- /dev/null +++ b/metagpt/skills/WriterSkill/NovelChapterWithNotes/config.json @@ -0,0 +1,41 @@ +{ + "schema": 1, + "type": "completion", + "description": "Write a chapter of a novel using notes about the chapter to write.", + "completion": { + "max_tokens": 1024, + "temperature": 0.5, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + }, + "input": { + "parameters": [ + { + "name": "input", + "description": "What the novel should be about.", + "defaultValue": "" + }, + { + "name": "theme", + "description": "The theme of this novel.", + "defaultValue": "" + }, + { + "name": "notes", + "description": "Notes useful to write this chapter.", + "defaultValue": "" + }, + { + "name": "previousChapter", + "description": "The previous chapter synopsis.", + "defaultValue": "" + }, + { + "name": "chapterIndex", + "description": "The number of the chapter to write.", + "defaultValue": "" + } + ] + } +} diff --git a/metagpt/skills/WriterSkill/NovelChapterWithNotes/skprompt.txt b/metagpt/skills/WriterSkill/NovelChapterWithNotes/skprompt.txt new file mode 100644 index 000000000..650bd50d9 --- /dev/null +++ b/metagpt/skills/WriterSkill/NovelChapterWithNotes/skprompt.txt @@ -0,0 +1,19 @@ +[CONTEXT] + +THEME OF STORY: +{{$theme}} + +NOTES OF STORY SO FAR - USE AS REFERENCE +{{$notes}} + +PREVIOUS CHAPTER, USE AS REFERENCE: +{{$previousChapter}} + +[END CONTEXT] + + +WRITE THIS CHAPTER CONTINUING STORY, USING [CONTEXT] AND CHAPTER SYNOPSIS BELOW. DO NOT REPEAT SYNOPSIS IN THE CHAPTER. DON'T REPEAT PREVIOUS CHAPTER. + +{{$input}} + +Chapter {{$chapterIndex}} diff --git a/metagpt/skills/WriterSkill/NovelOutline/config.json b/metagpt/skills/WriterSkill/NovelOutline/config.json new file mode 100644 index 000000000..a34622f7b --- /dev/null +++ b/metagpt/skills/WriterSkill/NovelOutline/config.json @@ -0,0 +1,31 @@ +{ + "schema": 1, + "type": "completion", + "description": "Generate a list of chapter synopsis for a novel or novella", + "completion": { + "max_tokens": 2048, + "temperature": 0.1, + "top_p": 0.5, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + }, + "input": { + "parameters": [ + { + "name": "input", + "description": "What the novel should be about.", + "defaultValue": "" + }, + { + "name": "chapterCount", + "description": "The number of chapters to generate.", + "defaultValue": "" + }, + { + "name": "endMarker", + "description": "The marker to use to end each chapter.", + "defaultValue": "" + } + ] + } +} diff --git a/metagpt/skills/WriterSkill/NovelOutline/skprompt.txt b/metagpt/skills/WriterSkill/NovelOutline/skprompt.txt new file mode 100644 index 000000000..05f725acb --- /dev/null +++ b/metagpt/skills/WriterSkill/NovelOutline/skprompt.txt @@ -0,0 +1,12 @@ +I want to write a {{$chapterCount}} chapter novella about: +{{$input}} + +There MUST BE {{$chapterCount}} CHAPTERS. + +INVENT CHARACTERS AS YOU SEE FIT. BE HIGHLY CREATIVE AND/OR FUNNY. +WRITE SYNOPSIS FOR EACH CHAPTER. INCLUDE INFORMATION ABOUT CHARACTERS ETC. SINCE EACH +CHAPTER WILL BE WRITTEN BY A DIFFERENT WRITER, YOU MUST INCLUDE ALL PERTINENT INFORMATION +IN EACH SYNOPSIS + +YOU MUST END EACH SYNOPSIS WITH {{$endMarker}} + diff --git a/metagpt/skills/WriterSkill/Rewrite/config.json b/metagpt/skills/WriterSkill/Rewrite/config.json new file mode 100644 index 000000000..175ade9d9 --- /dev/null +++ b/metagpt/skills/WriterSkill/Rewrite/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Automatically generate compact notes for any text or text document", + "completion": { + "max_tokens": 256, + "temperature": 0.0, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } +} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/Rewrite/skprompt.txt b/metagpt/skills/WriterSkill/Rewrite/skprompt.txt new file mode 100644 index 000000000..37f8d03fc --- /dev/null +++ b/metagpt/skills/WriterSkill/Rewrite/skprompt.txt @@ -0,0 +1,6 @@ +Rewrite the given text like it was written in this style or by: {{$style}}. +MUST RETAIN THE MEANING AND FACTUAL CONTENT AS THE ORIGINAL. + + +{{$input}} + diff --git a/metagpt/skills/WriterSkill/ShortPoem/config.json b/metagpt/skills/WriterSkill/ShortPoem/config.json new file mode 100644 index 000000000..0cc3da6c8 --- /dev/null +++ b/metagpt/skills/WriterSkill/ShortPoem/config.json @@ -0,0 +1,21 @@ +{ + "schema": 1, + "type": "completion", + "description": "Turn a scenario into a short and entertaining poem.", + "completion": { + "max_tokens": 60, + "temperature": 0.5, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + }, + "input": { + "parameters": [ + { + "name": "input", + "description": "The scenario to turn into a poem.", + "defaultValue": "" + } + ] + } +} diff --git a/metagpt/skills/WriterSkill/ShortPoem/skprompt.txt b/metagpt/skills/WriterSkill/ShortPoem/skprompt.txt new file mode 100644 index 000000000..bc42fcba6 --- /dev/null +++ b/metagpt/skills/WriterSkill/ShortPoem/skprompt.txt @@ -0,0 +1,2 @@ +Generate a short funny poem or limerick to explain the given event. Be creative and be funny. Let your imagination run wild. +Event:{{$input}} diff --git a/metagpt/skills/WriterSkill/StoryGen/config.json b/metagpt/skills/WriterSkill/StoryGen/config.json new file mode 100644 index 000000000..212831341 --- /dev/null +++ b/metagpt/skills/WriterSkill/StoryGen/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Generate a list of synopsis for a novel or novella with sub-chapters", + "completion": { + "max_tokens": 250, + "temperature": 0.0, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } +} diff --git a/metagpt/skills/WriterSkill/StoryGen/skprompt.txt b/metagpt/skills/WriterSkill/StoryGen/skprompt.txt new file mode 100644 index 000000000..661df013c --- /dev/null +++ b/metagpt/skills/WriterSkill/StoryGen/skprompt.txt @@ -0,0 +1,10 @@ +ONLY USE XML TAGS IN THIS LIST: +[XML TAG LIST] +list: Surround any lists with this tag +synopsis: An outline of the chapter to write +[END LIST] + +EMIT WELL FORMED XML ALWAYS. Code should be CDATA. + + +{{$input}} diff --git a/metagpt/skills/WriterSkill/TellMeMore/config.json b/metagpt/skills/WriterSkill/TellMeMore/config.json new file mode 100644 index 000000000..28b6b4e5c --- /dev/null +++ b/metagpt/skills/WriterSkill/TellMeMore/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Summarize given text or any text document", + "completion": { + "max_tokens": 500, + "temperature": 0.0, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } +} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/TellMeMore/skprompt.txt b/metagpt/skills/WriterSkill/TellMeMore/skprompt.txt new file mode 100644 index 000000000..143ce3a65 --- /dev/null +++ b/metagpt/skills/WriterSkill/TellMeMore/skprompt.txt @@ -0,0 +1,7 @@ +>>>>>The following is part of a {{$conversationtype}}. +{{$input}} + +>>>>>The following is an overview of a previous part of the {{$conversationtype}}, focusing on "{{$focusarea}}". +{{$previousresults}} + +>>>>>In 250 words or less, write a verbose and detailed overview of the {{$conversationtype}} focusing solely on "{{$focusarea}}". \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/Translate/config.json b/metagpt/skills/WriterSkill/Translate/config.json new file mode 100644 index 000000000..8134ce8dd --- /dev/null +++ b/metagpt/skills/WriterSkill/Translate/config.json @@ -0,0 +1,15 @@ +{ + "schema": 1, + "type": "completion", + "description": "Translate the input into a language of your choice", + "completion": { + "max_tokens": 2000, + "temperature": 0.7, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0, + "stop_sequences": [ + "[done]" + ] + } +} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/Translate/skprompt.txt b/metagpt/skills/WriterSkill/Translate/skprompt.txt new file mode 100644 index 000000000..d5f2fa8c1 --- /dev/null +++ b/metagpt/skills/WriterSkill/Translate/skprompt.txt @@ -0,0 +1,7 @@ +Translate the input below into {{$language}} + +MAKE SURE YOU ONLY USE {{$language}}. + +{{$input}} + +Translation: diff --git a/metagpt/skills/WriterSkill/TwoSentenceSummary/config.json b/metagpt/skills/WriterSkill/TwoSentenceSummary/config.json new file mode 100644 index 000000000..833bd5950 --- /dev/null +++ b/metagpt/skills/WriterSkill/TwoSentenceSummary/config.json @@ -0,0 +1,12 @@ +{ + "schema": 1, + "type": "completion", + "description": "Summarize given text in two sentences or less", + "completion": { + "max_tokens": 100, + "temperature": 0.0, + "top_p": 0.0, + "presence_penalty": 0.0, + "frequency_penalty": 0.0 + } +} \ No newline at end of file diff --git a/metagpt/skills/WriterSkill/TwoSentenceSummary/skprompt.txt b/metagpt/skills/WriterSkill/TwoSentenceSummary/skprompt.txt new file mode 100644 index 000000000..b8f657a93 --- /dev/null +++ b/metagpt/skills/WriterSkill/TwoSentenceSummary/skprompt.txt @@ -0,0 +1,4 @@ +Summarize the following text in two sentences or less. +[BEGIN TEXT] +{{$input}} +[END TEXT] diff --git a/metagpt/tools/code_interpreter.py b/metagpt/tools/code_interpreter.py new file mode 100644 index 000000000..97398ccfd --- /dev/null +++ b/metagpt/tools/code_interpreter.py @@ -0,0 +1,129 @@ +import re +from typing import List, Callable +from pathlib import Path + +import wrapt +import textwrap +import inspect +from interpreter.interpreter import Interpreter + +from metagpt.logs import logger +from metagpt.config import CONFIG +from metagpt.utils.highlight import highlight +from metagpt.actions.clone_function import CloneFunction, run_function_code, run_function_script + + +def extract_python_code(code: str): + """Extract code blocks: If the code comments are the same, only the last code block is kept.""" + # Use regular expressions to match comment blocks and related code. + pattern = r'(#\s[^\n]*)\n(.*?)(?=\n\s*#|$)' + matches = re.findall(pattern, code, re.DOTALL) + + # Extract the last code block when encountering the same comment. + unique_comments = {} + for comment, code_block in matches: + unique_comments[comment] = code_block + + # concatenate into functional form + result_code = '\n'.join([f"{comment}\n{code_block}" for comment, code_block in unique_comments.items()]) + header_code = code[:code.find("#")] + code = header_code + result_code + + logger.info(f"Extract python code: \n {highlight(code)}") + + return code + + +class OpenCodeInterpreter(object): + """https://github.com/KillianLucas/open-interpreter""" + def __init__(self, auto_run: bool = True) -> None: + interpreter = Interpreter() + interpreter.auto_run = auto_run + interpreter.model = CONFIG.openai_api_model or "gpt-3.5-turbo" + interpreter.api_key = CONFIG.openai_api_key + interpreter.api_base = CONFIG.openai_api_base + self.interpreter = interpreter + + def chat(self, query: str, reset: bool = True): + if reset: + self.interpreter.reset() + return self.interpreter.chat(query, return_messages=True) + + @staticmethod + def extract_function(query_respond: List, function_name: str, *, language: str = 'python', + function_format: str = None) -> str: + """create a function from query_respond.""" + if language not in ('python'): + raise NotImplementedError(f"Not support to parse language {language}!") + + # set function form + if function_format is None: + assert language == 'python', f"Expect python language for default function_format, but got {language}." + function_format = """def {function_name}():\n{code}""" + # Extract the code module in the open-interpreter respond message. + code = [item['function_call']['parsed_arguments']['code'] for item in query_respond + if "function_call" in item + and "parsed_arguments" in item["function_call"] + and 'language' in item["function_call"]['parsed_arguments'] + and item["function_call"]['parsed_arguments']['language'] == language] + # add indent. + indented_code_str = textwrap.indent("\n".join(code), ' ' * 4) + # Return the code after deduplication. + if language == "python": + return extract_python_code(function_format.format(function_name=function_name, code=indented_code_str)) + + +def gen_query(func: Callable, args, kwargs) -> str: + # Get the annotation of the function as part of the query. + desc = func.__doc__ + signature = inspect.signature(func) + # Get the signature of the wrapped function and the assignment of the input parameters as part of the query. + bound_args = signature.bind(*args, **kwargs) + bound_args.apply_defaults() + query = f"{desc}, {bound_args.arguments}, If you must use a third-party package, use the most popular ones, for example: pandas, numpy, ta, ..." + return query + + +def gen_template_fun(func: Callable) -> str: + return f"def {func.__name__}{str(inspect.signature(func))}\n # here is your code ..." + + +class OpenInterpreterDecorator(object): + def __init__(self, save_code: bool = False, code_file_path: str = None, clear_code: bool = False) -> None: + self.save_code = save_code + self.code_file_path = code_file_path + self.clear_code = clear_code + + def __call__(self, wrapped): + @wrapt.decorator + async def wrapper(wrapped: Callable, instance, args, kwargs): + # Get the decorated function name. + func_name = wrapped.__name__ + # If the script exists locally and clearcode is not required, execute the function from the script. + if Path(self.code_file_path).is_file() and not self.clear_code: + return run_function_script(self.code_file_path, func_name, *args, **kwargs) + + # Auto run generate code by using open-interpreter. + interpreter = OpenCodeInterpreter() + query = gen_query(wrapped, args, kwargs) + logger.info(f"query for OpenCodeInterpreter: \n {query}") + respond = interpreter.chat(query) + # Assemble the code blocks generated by open-interpreter into a function without parameters. + func_code = interpreter.extract_function(respond, func_name) + # Clone the `func_code` into wrapped, that is, + # keep the `func_code` and wrapped functions with the same input parameter and return value types. + template_func = gen_template_fun(wrapped) + cf = CloneFunction() + code = await cf.run(template_func=template_func, source_code=func_code) + # Display the generated function in the terminal. + logger_code = highlight(code, "python") + logger.info(f"Creating following Python function:\n{logger_code}") + # execute this function. + try: + res = run_function_code(code, func_name, *args, **kwargs) + if self.save_code: + cf._save(self.code_file_path, code) + except Exception as e: + raise Exception("Could not evaluate Python code", e) + return res + return wrapper(wrapped) diff --git a/metagpt/tools/search_engine.py b/metagpt/tools/search_engine.py index d28700054..942ef7edd 100644 --- a/metagpt/tools/search_engine.py +++ b/metagpt/tools/search_engine.py @@ -5,15 +5,30 @@ @Author : alexanderwu @File : search_engine.py """ -from __future__ import annotations - import importlib -from typing import Callable, Coroutine, Literal, overload +from typing import Callable, Coroutine, Literal, overload, Optional, Union + +from semantic_kernel.skill_definition import sk_function from metagpt.config import CONFIG from metagpt.tools import SearchEngineType +class SkSearchEngine: + def __init__(self): + self.search_engine = SearchEngine() + + @sk_function( + description="searches results from Google. Useful when you need to find short " + "and succinct answers about a specific topic. Input should be a search query.", + name="searchAsync", + input_description="search", + ) + async def run(self, query: str) -> str: + result = await self.search_engine.run(query) + return result + + class SearchEngine: """Class representing a search engine. @@ -25,15 +40,16 @@ class SearchEngine: run_func: The function to run the search. engine: The search engine type. """ + def __init__( self, - engine: SearchEngineType | None = None, - run_func: Callable[[str, int, bool], Coroutine[None, None, str | list[str]]] = None, + engine: Optional[SearchEngineType] = None, + run_func: Callable[[str, int, bool], Coroutine[None, None, Union[str, list[str]]]] = None, ): engine = engine or CONFIG.search_engine if engine == SearchEngineType.SERPAPI_GOOGLE: module = "metagpt.tools.search_engine_serpapi" - run_func = importlib.import_module(module).SerpAPIWrapper().run + run_func = importlib.import_module(module).SerpAPIWrapper().run elif engine == SearchEngineType.SERPER_GOOGLE: module = "metagpt.tools.search_engine_serper" run_func = importlib.import_module(module).SerperWrapper().run @@ -68,7 +84,7 @@ class SearchEngine: ) -> list[dict[str, str]]: ... - async def run(self, query: str, max_results: int = 8, as_string: bool = True) -> str | list[dict[str, str]]: + async def run(self, query: str, max_results: int = 8, as_string: bool = True) -> Union[str, list[dict[str, str]]]: """Run a search query. Args: diff --git a/metagpt/tools/ut_writer.py b/metagpt/tools/ut_writer.py index 263a0269e..43ca72150 100644 --- a/metagpt/tools/ut_writer.py +++ b/metagpt/tools/ut_writer.py @@ -60,6 +60,7 @@ def test_node_tags(project_key, nodes, operations, expected_msg): # 3. If comments are needed, use Chinese. # If you understand, please wait for me to give the interface definition and just answer "Understood" to save tokens. +''' ACT_PROMPT_PREFIX = '''Refer to the test types: such as missing request parameters, field boundary verification, incorrect field type. Please output 10 test cases within one `@pytest.mark.parametrize` scope. @@ -94,7 +95,8 @@ Name Type Required Default Value Remarks code integer Yes message string Yes data object Yes - +``` +''' class UTGenerator: diff --git a/metagpt/utils/common.py b/metagpt/utils/common.py index a0dabd77e..65cc15e82 100644 --- a/metagpt/utils/common.py +++ b/metagpt/utils/common.py @@ -11,26 +11,25 @@ import inspect import os import platform import re -from typing import List, Tuple +from typing import List, Tuple, Union from metagpt.logs import logger def check_cmd_exists(command) -> int: - """ 检查命令是否存在 + """检查命令是否存在 :param command: 待检查的命令 :return: 如果命令存在,返回0,如果不存在,返回非0 """ - if platform.system().lower() == 'windows': - check_command = 'where ' + command + if platform.system().lower() == "windows": + check_command = "where " + command else: - check_command = 'command -v ' + command + ' >/dev/null 2>&1 || { echo >&2 "no mermaid"; exit 1; }' + check_command = "command -v " + command + ' >/dev/null 2>&1 || { echo >&2 "no mermaid"; exit 1; }' result = os.system(check_command) return result class OutputParser: - @classmethod def parse_blocks(cls, text: str): # 首先根据"##"将文本分割成不同的block @@ -54,7 +53,7 @@ class OutputParser: @classmethod def parse_code(cls, text: str, lang: str = "") -> str: - pattern = rf'```{lang}.*?\s+(.*?)```' + pattern = rf"```{lang}.*?\s+(.*?)```" match = re.search(pattern, text, re.DOTALL) if match: code = match.group(1) @@ -65,13 +64,13 @@ class OutputParser: @classmethod def parse_str(cls, text: str): text = text.split("=")[-1] - text = text.strip().strip("'").strip("\"") + text = text.strip().strip("'").strip('"') return text @classmethod def parse_file_list(cls, text: str) -> list[str]: # Regular expression pattern to find the tasks list. - pattern = r'\s*(.*=.*)?(\[.*\])' + pattern = r"\s*(.*=.*)?(\[.*\])" # Extract tasks list string using regex. match = re.search(pattern, text, re.DOTALL) @@ -83,12 +82,12 @@ class OutputParser: else: tasks = text.split("\n") return tasks - + @staticmethod def parse_python_code(text: str) -> str: for pattern in ( - r'(.*?```python.*?\s+)?(?P.*)(```.*?)', - r'(.*?```python.*?\s+)?(?P.*)', + r"(.*?```python.*?\s+)?(?P.*)(```.*?)", + r"(.*?```python.*?\s+)?(?P.*)", ): match = re.search(pattern, text, re.DOTALL) if not match: @@ -135,7 +134,7 @@ class OutputParser: typing = typing_define[0] else: typing = typing_define - if typing == List[str] or typing == List[Tuple[str, str]]: + if typing == List[str] or typing == List[Tuple[str, str]] or typing == List[List[str]]: # 尝试解析list try: content = cls.parse_file_list(text=content) @@ -151,9 +150,55 @@ class OutputParser: parsed_data[block] = content return parsed_data + @classmethod + def extract_struct(cls, text: str, data_type: Union[type(list), type(dict)]) -> Union[list, dict]: + """Extracts and parses a specified type of structure (dictionary or list) from the given text. + The text only contains a list or dictionary, which may have nested structures. + + Args: + text: The text containing the structure (dictionary or list). + data_type: The data type to extract, can be "list" or "dict". + + Returns: + - If extraction and parsing are successful, it returns the corresponding data structure (list or dictionary). + - If extraction fails or parsing encounters an error, it throw an exception. + + Examples: + >>> text = 'xxx [1, 2, ["a", "b", [3, 4]], {"x": 5, "y": [6, 7]}] xxx' + >>> result_list = OutputParser.extract_struct(text, "list") + >>> print(result_list) + >>> # Output: [1, 2, ["a", "b", [3, 4]], {"x": 5, "y": [6, 7]}] + + >>> text = 'xxx {"x": 1, "y": {"a": 2, "b": {"c": 3}}} xxx' + >>> result_dict = OutputParser.extract_struct(text, "dict") + >>> print(result_dict) + >>> # Output: {"x": 1, "y": {"a": 2, "b": {"c": 3}}} + """ + # Find the first "[" or "{" and the last "]" or "}" + start_index = text.find("[" if data_type is list else "{") + end_index = text.rfind("]" if data_type is list else "}") + + if start_index != -1 and end_index != -1: + # Extract the structure part + structure_text = text[start_index:end_index + 1] + + try: + # Attempt to convert the text to a Python data type using ast.literal_eval + result = ast.literal_eval(structure_text) + + # Ensure the result matches the specified data type + if isinstance(result, list) or isinstance(result, dict): + return result + + raise ValueError(f"The extracted structure is not a {data_type}.") + + except (ValueError, SyntaxError) as e: + raise Exception(f"Error while extracting and parsing the {data_type}: {e}") + else: + raise Exception(f"No {data_type} found in the text.") + class CodeParser: - @classmethod def parse_block(cls, block: str, text: str) -> str: blocks = cls.parse_blocks(text) @@ -184,21 +229,22 @@ class CodeParser: def parse_code(cls, block: str, text: str, lang: str = "") -> str: if block: text = cls.parse_block(block, text) - pattern = rf'```{lang}.*?\s+(.*?)```' + pattern = rf"```{lang}.*?\s+(.*?)```" match = re.search(pattern, text, re.DOTALL) if match: code = match.group(1) else: logger.error(f"{pattern} not match following text:") logger.error(text) - raise Exception + # raise Exception + return "" return code @classmethod def parse_str(cls, block: str, text: str, lang: str = ""): code = cls.parse_code(block, text, lang) code = code.split("=")[-1] - code = code.strip().strip("'").strip("\"") + code = code.strip().strip("'").strip('"') return code @classmethod @@ -206,7 +252,7 @@ class CodeParser: # Regular expression pattern to find the tasks list. code = cls.parse_code(block, text, lang) # print(code) - pattern = r'\s*(.*=.*)?(\[.*\])' + pattern = r"\s*(.*=.*)?(\[.*\])" # Extract tasks list string using regex. match = re.search(pattern, code, re.DOTALL) @@ -229,7 +275,7 @@ class NoMoneyException(Exception): super().__init__(self.message) def __str__(self): - return f'{self.message} -> Amount required: {self.amount}' + return f"{self.message} -> Amount required: {self.amount}" def print_members(module, indent=0): @@ -239,19 +285,19 @@ def print_members(module, indent=0): :param indent: :return: """ - prefix = ' ' * indent + prefix = " " * indent for name, obj in inspect.getmembers(module): print(name, obj) if inspect.isclass(obj): - print(f'{prefix}Class: {name}') + print(f"{prefix}Class: {name}") # print the methods within the class - if name in ['__class__', '__base__']: + if name in ["__class__", "__base__"]: continue print_members(obj, indent + 2) elif inspect.isfunction(obj): - print(f'{prefix}Function: {name}') + print(f"{prefix}Function: {name}") elif inspect.ismethod(obj): - print(f'{prefix}Method: {name}') + print(f"{prefix}Method: {name}") def parse_recipient(text): diff --git a/metagpt/utils/custom_decoder.py b/metagpt/utils/custom_decoder.py new file mode 100644 index 000000000..373d16356 --- /dev/null +++ b/metagpt/utils/custom_decoder.py @@ -0,0 +1,297 @@ +import json +import re +from json import JSONDecodeError +from json.decoder import _decode_uXXXX + +NUMBER_RE = re.compile(r"(-?(?:0|[1-9]\d*))(\.\d+)?([eE][-+]?\d+)?", (re.VERBOSE | re.MULTILINE | re.DOTALL)) + + +def py_make_scanner(context): + parse_object = context.parse_object + parse_array = context.parse_array + parse_string = context.parse_string + match_number = NUMBER_RE.match + strict = context.strict + parse_float = context.parse_float + parse_int = context.parse_int + parse_constant = context.parse_constant + object_hook = context.object_hook + object_pairs_hook = context.object_pairs_hook + memo = context.memo + + def _scan_once(string, idx): + try: + nextchar = string[idx] + except IndexError: + raise StopIteration(idx) from None + + if nextchar == '"' or nextchar == "'": + if idx + 2 < len(string) and string[idx + 1] == nextchar and string[idx + 2] == nextchar: + # Handle the case where the next two characters are the same as nextchar + return parse_string(string, idx + 3, strict, delimiter=nextchar * 3) # triple quote + else: + # Handle the case where the next two characters are not the same as nextchar + return parse_string(string, idx + 1, strict, delimiter=nextchar) + elif nextchar == "{": + return parse_object((string, idx + 1), strict, _scan_once, object_hook, object_pairs_hook, memo) + elif nextchar == "[": + return parse_array((string, idx + 1), _scan_once) + elif nextchar == "n" and string[idx : idx + 4] == "null": + return None, idx + 4 + elif nextchar == "t" and string[idx : idx + 4] == "true": + return True, idx + 4 + elif nextchar == "f" and string[idx : idx + 5] == "false": + return False, idx + 5 + + m = match_number(string, idx) + if m is not None: + integer, frac, exp = m.groups() + if frac or exp: + res = parse_float(integer + (frac or "") + (exp or "")) + else: + res = parse_int(integer) + return res, m.end() + elif nextchar == "N" and string[idx : idx + 3] == "NaN": + return parse_constant("NaN"), idx + 3 + elif nextchar == "I" and string[idx : idx + 8] == "Infinity": + return parse_constant("Infinity"), idx + 8 + elif nextchar == "-" and string[idx : idx + 9] == "-Infinity": + return parse_constant("-Infinity"), idx + 9 + else: + raise StopIteration(idx) + + def scan_once(string, idx): + try: + return _scan_once(string, idx) + finally: + memo.clear() + + return scan_once + + +FLAGS = re.VERBOSE | re.MULTILINE | re.DOTALL +STRINGCHUNK = re.compile(r'(.*?)(["\\\x00-\x1f])', FLAGS) +STRINGCHUNK_SINGLEQUOTE = re.compile(r"(.*?)([\'\\\x00-\x1f])", FLAGS) +STRINGCHUNK_TRIPLE_DOUBLE_QUOTE = re.compile(r"(.*?)(\"\"\"|[\\\x00-\x1f])", FLAGS) +STRINGCHUNK_TRIPLE_SINGLEQUOTE = re.compile(r"(.*?)('''|[\\\x00-\x1f])", FLAGS) +BACKSLASH = { + '"': '"', + "\\": "\\", + "/": "/", + "b": "\b", + "f": "\f", + "n": "\n", + "r": "\r", + "t": "\t", +} +WHITESPACE = re.compile(r"[ \t\n\r]*", FLAGS) +WHITESPACE_STR = " \t\n\r" + + +def JSONObject( + s_and_end, strict, scan_once, object_hook, object_pairs_hook, memo=None, _w=WHITESPACE.match, _ws=WHITESPACE_STR +): + """Parse a JSON object from a string and return the parsed object. + + Args: + s_and_end (tuple): A tuple containing the input string to parse and the current index within the string. + strict (bool): If `True`, enforces strict JSON string decoding rules. + If `False`, allows literal control characters in the string. Defaults to `True`. + scan_once (callable): A function to scan and parse JSON values from the input string. + object_hook (callable): A function that, if specified, will be called with the parsed object as a dictionary. + object_pairs_hook (callable): A function that, if specified, will be called with the parsed object as a list of pairs. + memo (dict, optional): A dictionary used to memoize string keys for optimization. Defaults to None. + _w (function): A regular expression matching function for whitespace. Defaults to WHITESPACE.match. + _ws (str): A string containing whitespace characters. Defaults to WHITESPACE_STR. + + Returns: + tuple or dict: A tuple containing the parsed object and the index of the character in the input string + after the end of the object. + """ + + s, end = s_and_end + pairs = [] + pairs_append = pairs.append + # Backwards compatibility + if memo is None: + memo = {} + memo_get = memo.setdefault + # Use a slice to prevent IndexError from being raised, the following + # check will raise a more specific ValueError if the string is empty + nextchar = s[end : end + 1] + # Normally we expect nextchar == '"' + if nextchar != '"' and nextchar != "'": + if nextchar in _ws: + end = _w(s, end).end() + nextchar = s[end : end + 1] + # Trivial empty object + if nextchar == "}": + if object_pairs_hook is not None: + result = object_pairs_hook(pairs) + return result, end + 1 + pairs = {} + if object_hook is not None: + pairs = object_hook(pairs) + return pairs, end + 1 + elif nextchar != '"': + raise JSONDecodeError("Expecting property name enclosed in double quotes", s, end) + end += 1 + while True: + if end + 1 < len(s) and s[end] == nextchar and s[end + 1] == nextchar: + # Handle the case where the next two characters are the same as nextchar + key, end = scanstring(s, end + 2, strict, delimiter=nextchar * 3) + else: + # Handle the case where the next two characters are not the same as nextchar + key, end = scanstring(s, end, strict, delimiter=nextchar) + key = memo_get(key, key) + # To skip some function call overhead we optimize the fast paths where + # the JSON key separator is ": " or just ":". + if s[end : end + 1] != ":": + end = _w(s, end).end() + if s[end : end + 1] != ":": + raise JSONDecodeError("Expecting ':' delimiter", s, end) + end += 1 + + try: + if s[end] in _ws: + end += 1 + if s[end] in _ws: + end = _w(s, end + 1).end() + except IndexError: + pass + + try: + value, end = scan_once(s, end) + except StopIteration as err: + raise JSONDecodeError("Expecting value", s, err.value) from None + pairs_append((key, value)) + try: + nextchar = s[end] + if nextchar in _ws: + end = _w(s, end + 1).end() + nextchar = s[end] + except IndexError: + nextchar = "" + end += 1 + + if nextchar == "}": + break + elif nextchar != ",": + raise JSONDecodeError("Expecting ',' delimiter", s, end - 1) + end = _w(s, end).end() + nextchar = s[end : end + 1] + end += 1 + if nextchar != '"': + raise JSONDecodeError("Expecting property name enclosed in double quotes", s, end - 1) + if object_pairs_hook is not None: + result = object_pairs_hook(pairs) + return result, end + pairs = dict(pairs) + if object_hook is not None: + pairs = object_hook(pairs) + return pairs, end + + +def py_scanstring(s, end, strict=True, _b=BACKSLASH, _m=STRINGCHUNK.match, delimiter='"'): + """Scan the string s for a JSON string. + + Args: + s (str): The input string to be scanned for a JSON string. + end (int): The index of the character in `s` after the quote that started the JSON string. + strict (bool): If `True`, enforces strict JSON string decoding rules. + If `False`, allows literal control characters in the string. Defaults to `True`. + _b (dict): A dictionary containing escape sequence mappings. + _m (function): A regular expression matching function for string chunks. + delimiter (str): The string delimiter used to define the start and end of the JSON string. + Can be one of: '"', "'", '\"""', or "'''". Defaults to '"'. + + Returns: + tuple: A tuple containing the decoded string and the index of the character in `s` + after the end quote. + """ + + chunks = [] + _append = chunks.append + begin = end - 1 + if delimiter == '"': + _m = STRINGCHUNK.match + elif delimiter == "'": + _m = STRINGCHUNK_SINGLEQUOTE.match + elif delimiter == '"""': + _m = STRINGCHUNK_TRIPLE_DOUBLE_QUOTE.match + else: + _m = STRINGCHUNK_TRIPLE_SINGLEQUOTE.match + while 1: + chunk = _m(s, end) + if chunk is None: + raise JSONDecodeError("Unterminated string starting at", s, begin) + end = chunk.end() + content, terminator = chunk.groups() + # Content is contains zero or more unescaped string characters + if content: + _append(content) + # Terminator is the end of string, a literal control character, + # or a backslash denoting that an escape sequence follows + if terminator == delimiter: + break + elif terminator != "\\": + if strict: + # msg = "Invalid control character %r at" % (terminator,) + msg = "Invalid control character {0!r} at".format(terminator) + raise JSONDecodeError(msg, s, end) + else: + _append(terminator) + continue + try: + esc = s[end] + except IndexError: + raise JSONDecodeError("Unterminated string starting at", s, begin) from None + # If not a unicode escape sequence, must be in the lookup table + if esc != "u": + try: + char = _b[esc] + except KeyError: + msg = "Invalid \\escape: {0!r}".format(esc) + raise JSONDecodeError(msg, s, end) + end += 1 + else: + uni = _decode_uXXXX(s, end) + end += 5 + if 0xD800 <= uni <= 0xDBFF and s[end : end + 2] == "\\u": + uni2 = _decode_uXXXX(s, end + 1) + if 0xDC00 <= uni2 <= 0xDFFF: + uni = 0x10000 + (((uni - 0xD800) << 10) | (uni2 - 0xDC00)) + end += 6 + char = chr(uni) + _append(char) + return "".join(chunks), end + + +scanstring = py_scanstring + + +class CustomDecoder(json.JSONDecoder): + def __init__( + self, + *, + object_hook=None, + parse_float=None, + parse_int=None, + parse_constant=None, + strict=True, + object_pairs_hook=None + ): + super().__init__( + object_hook=object_hook, + parse_float=parse_float, + parse_int=parse_int, + parse_constant=parse_constant, + strict=strict, + object_pairs_hook=object_pairs_hook, + ) + self.parse_object = JSONObject + self.parse_string = py_scanstring + self.scan_once = py_make_scanner(self) + + def decode(self, s, _w=json.decoder.WHITESPACE.match): + return super().decode(s) diff --git a/metagpt/utils/file.py b/metagpt/utils/file.py new file mode 100644 index 000000000..f3691549b --- /dev/null +++ b/metagpt/utils/file.py @@ -0,0 +1,75 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/4 15:40:40 +@Author : Stitch-z +@File : file.py +@Describe : General file operations. +""" +import aiofiles +from pathlib import Path + +from metagpt.logs import logger + + +class File: + """A general util for file operations.""" + + CHUNK_SIZE = 64 * 1024 + + @classmethod + async def write(cls, root_path: Path, filename: str, content: bytes) -> Path: + """Write the file content to the local specified path. + + Args: + root_path: The root path of file, such as "/data". + filename: The name of file, such as "test.txt". + content: The binary content of file. + + Returns: + The full filename of file, such as "/data/test.txt". + + Raises: + Exception: If an unexpected error occurs during the file writing process. + """ + try: + root_path.mkdir(parents=True, exist_ok=True) + full_path = root_path / filename + async with aiofiles.open(full_path, mode="wb") as writer: + await writer.write(content) + logger.debug(f"Successfully write file: {full_path}") + return full_path + except Exception as e: + logger.error(f"Error writing file: {e}") + raise e + + @classmethod + async def read(cls, file_path: Path, chunk_size: int = None) -> bytes: + """Partitioning read the file content from the local specified path. + + Args: + file_path: The full file name of file, such as "/data/test.txt". + chunk_size: The size of each chunk in bytes (default is 64kb). + + Returns: + The binary content of file. + + Raises: + Exception: If an unexpected error occurs during the file reading process. + """ + try: + chunk_size = chunk_size or cls.CHUNK_SIZE + async with aiofiles.open(file_path, mode="rb") as reader: + chunks = list() + while True: + chunk = await reader.read(chunk_size) + if not chunk: + break + chunks.append(chunk) + content = b''.join(chunks) + logger.debug(f"Successfully read file, the path of file: {file_path}") + return content + except Exception as e: + logger.error(f"Error reading file: {e}") + raise e + diff --git a/metagpt/utils/get_template.py b/metagpt/utils/get_template.py new file mode 100644 index 000000000..86c1915f7 --- /dev/null +++ b/metagpt/utils/get_template.py @@ -0,0 +1,20 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/19 20:39 +@Author : femto Zheng +@File : get_template.py +""" +from metagpt.config import CONFIG + + +def get_template(templates, format=CONFIG.prompt_format): + selected_templates = templates.get(format) + if selected_templates is None: + raise ValueError(f"Can't find {format} in passed in templates") + + # Extract the selected templates + prompt_template = selected_templates["PROMPT_TEMPLATE"] + format_example = selected_templates["FORMAT_EXAMPLE"] + + return prompt_template, format_example diff --git a/metagpt/utils/highlight.py b/metagpt/utils/highlight.py new file mode 100644 index 000000000..e6cbb228c --- /dev/null +++ b/metagpt/utils/highlight.py @@ -0,0 +1,25 @@ +# 添加代码语法高亮显示 +from pygments import highlight as highlight_ +from pygments.lexers import PythonLexer, SqlLexer +from pygments.formatters import TerminalFormatter, HtmlFormatter + + +def highlight(code: str, language: str = 'python', formatter: str = 'terminal'): + # 指定要高亮的语言 + if language.lower() == 'python': + lexer = PythonLexer() + elif language.lower() == 'sql': + lexer = SqlLexer() + else: + raise ValueError(f"Unsupported language: {language}") + + # 指定输出格式 + if formatter.lower() == 'terminal': + formatter = TerminalFormatter() + elif formatter.lower() == 'html': + formatter = HtmlFormatter() + else: + raise ValueError(f"Unsupported formatter: {formatter}") + + # 使用 Pygments 高亮代码片段 + return highlight_(code, lexer, formatter) diff --git a/metagpt/utils/index.html b/metagpt/utils/index.html new file mode 100644 index 000000000..d750a1b6a --- /dev/null +++ b/metagpt/utils/index.html @@ -0,0 +1 @@ +
\ No newline at end of file diff --git a/metagpt/utils/json_to_markdown.py b/metagpt/utils/json_to_markdown.py new file mode 100644 index 000000000..d9b40c6f6 --- /dev/null +++ b/metagpt/utils/json_to_markdown.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/11 11:50 +@Author : femto Zheng +@File : json_to_markdown.py +""" + + +# since we original write docs/*.md in markdown format, so I convert json back to markdown +def json_to_markdown(data, depth=2): + """ + Convert a JSON object to Markdown with headings for keys and lists for arrays, supporting nested objects. + + Args: + data: JSON object (dictionary) or value. + depth (int): Current depth level for Markdown headings. + + Returns: + str: Markdown representation of the JSON data. + """ + markdown = "" + + if isinstance(data, dict): + for key, value in data.items(): + if isinstance(value, list): + # Handle JSON arrays + markdown += "#" * depth + f" {key}\n\n" + items = [str(item) for item in value] + markdown += "- " + "\n- ".join(items) + "\n\n" + elif isinstance(value, dict): + # Handle nested JSON objects + markdown += "#" * depth + f" {key}\n\n" + markdown += json_to_markdown(value, depth + 1) + else: + # Handle other values + markdown += "#" * depth + f" {key}\n\n{value}\n\n" + else: + # Handle non-dictionary JSON data + markdown = str(data) + + return markdown diff --git a/metagpt/utils/make_sk_kernel.py b/metagpt/utils/make_sk_kernel.py new file mode 100644 index 000000000..5e919abeb --- /dev/null +++ b/metagpt/utils/make_sk_kernel.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/13 12:29 +@Author : femto Zheng +@File : make_sk_kernel.py +""" +import semantic_kernel as sk +from semantic_kernel.connectors.ai.open_ai.services.azure_chat_completion import ( + AzureChatCompletion, +) +from semantic_kernel.connectors.ai.open_ai.services.open_ai_chat_completion import ( + OpenAIChatCompletion, +) + +from metagpt.config import CONFIG + + +def make_sk_kernel(): + kernel = sk.Kernel() + if CONFIG.openai_api_type == "azure": + kernel.add_chat_service( + "chat_completion", + AzureChatCompletion(CONFIG.deployment_name, CONFIG.openai_api_base, CONFIG.openai_api_key), + ) + else: + kernel.add_chat_service( + "chat_completion", + OpenAIChatCompletion( + CONFIG.openai_api_model, CONFIG.openai_api_key, org_id=None, endpoint=CONFIG.openai_api_base + ), + ) + + return kernel diff --git a/metagpt/utils/mermaid.py b/metagpt/utils/mermaid.py index 24aabe8ae..5e5b275b0 100644 --- a/metagpt/utils/mermaid.py +++ b/metagpt/utils/mermaid.py @@ -2,10 +2,11 @@ # -*- coding: utf-8 -*- """ @Time : 2023/7/4 10:53 -@Author : alexanderwu +@Author : alexanderwu alitrack @File : mermaid.py """ -import subprocess +import asyncio +import os from pathlib import Path from metagpt.config import CONFIG @@ -14,31 +15,35 @@ from metagpt.logs import logger from metagpt.utils.common import check_cmd_exists -def mermaid_to_file(mermaid_code, output_file_without_suffix, width=2048, height=2048) -> int: +async def mermaid_to_file(mermaid_code, output_file_without_suffix, width=2048, height=2048) -> int: """suffix: png/svg/pdf :param mermaid_code: mermaid code :param output_file_without_suffix: output filename :param width: :param height: - :return: 0 if succed, -1 if failed + :return: 0 if succeed, -1 if failed """ # Write the Mermaid code to a temporary file + dir_name = os.path.dirname(output_file_without_suffix) + if dir_name and not os.path.exists(dir_name): + os.makedirs(dir_name) tmp = Path(f"{output_file_without_suffix}.mmd") tmp.write_text(mermaid_code, encoding="utf-8") - if check_cmd_exists("mmdc") != 0: - logger.warning("RUN `npm install -g @mermaid-js/mermaid-cli` to install mmdc") - return -1 + engine = CONFIG.mermaid_engine.lower() + if engine == "nodejs": + if check_cmd_exists(CONFIG.mmdc) != 0: + logger.warning("RUN `npm install -g @mermaid-js/mermaid-cli` to install mmdc") + return -1 - for suffix in ["pdf", "svg", "png"]: - output_file = f"{output_file_without_suffix}.{suffix}" - # Call the `mmdc` command to convert the Mermaid code to a PNG - logger.info(f"Generating {output_file}..") + for suffix in ["pdf", "svg", "png"]: + output_file = f"{output_file_without_suffix}.{suffix}" + # Call the `mmdc` command to convert the Mermaid code to a PNG + logger.info(f"Generating {output_file}..") - if CONFIG.puppeteer_config: - subprocess.run( - [ + if CONFIG.puppeteer_config: + commands = [ CONFIG.mmdc, "-p", CONFIG.puppeteer_config, @@ -51,9 +56,32 @@ def mermaid_to_file(mermaid_code, output_file_without_suffix, width=2048, height "-H", str(height), ] + else: + commands = [CONFIG.mmdc, "-i", str(tmp), "-o", output_file, "-w", str(width), "-H", str(height)] + process = await asyncio.create_subprocess_shell( + " ".join(commands), stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE ) + + stdout, stderr = await process.communicate() + if stdout: + logger.info(stdout.decode()) + if stderr: + logger.error(stderr.decode()) + else: + if engine == "playwright": + from metagpt.utils.mmdc_playwright import mermaid_to_file + + return await mermaid_to_file(mermaid_code, output_file_without_suffix, width, height) + elif engine == "pyppeteer": + from metagpt.utils.mmdc_pyppeteer import mermaid_to_file + + return await mermaid_to_file(mermaid_code, output_file_without_suffix, width, height) + elif engine == "ink": + from metagpt.utils.mmdc_ink import mermaid_to_file + + return await mermaid_to_file(mermaid_code, output_file_without_suffix) else: - subprocess.run([CONFIG.mmdc, "-i", str(tmp), "-o", output_file, "-w", str(width), "-H", str(height)]) + logger.warning(f"Unsupported mermaid engine: {engine}") return 0 @@ -109,6 +137,7 @@ MMC2 = """sequenceDiagram if __name__ == "__main__": - # logger.info(print_members(print_members)) - mermaid_to_file(MMC1, PROJECT_ROOT / "tmp/1.png") - mermaid_to_file(MMC2, PROJECT_ROOT / "tmp/2.png") + loop = asyncio.new_event_loop() + result = loop.run_until_complete(mermaid_to_file(MMC1, PROJECT_ROOT / f"{CONFIG.mermaid_engine}/1")) + result = loop.run_until_complete(mermaid_to_file(MMC2, PROJECT_ROOT / f"{CONFIG.mermaid_engine}/1")) + loop.close() diff --git a/metagpt/utils/minecraft/__init__.py b/metagpt/utils/minecraft/__init__.py new file mode 100644 index 000000000..6ee04c606 --- /dev/null +++ b/metagpt/utils/minecraft/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/24 0:32 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +from .load_prompts import load_prompt +from .json_utils import * +from .file_utils import * +from .action_rsp_parser import parse_js_code, parse_action_response diff --git a/metagpt/utils/minecraft/action_rsp_parser.py b/metagpt/utils/minecraft/action_rsp_parser.py new file mode 100644 index 000000000..91ccc141d --- /dev/null +++ b/metagpt/utils/minecraft/action_rsp_parser.py @@ -0,0 +1,91 @@ +import re +import time +from javascript import require + +def parse_js_code(msg: str): + ''' + Extract and Parse JavaScript code blocks + ''' + babel = require("@babel/core") + code_pattern = re.compile(r"```(?:javascript|js)(.*?)```", re.DOTALL) + code = "\n".join(code_pattern.findall(msg)) + parsed = babel.parse(code) + return parsed + +def parse_action_response(msg: str): + """ + Input: + ''' + Explain: ... + Plan: ... + Code: + ```javascript + ... + ``` + ''' + + Return: + { + "program_code": program_code, + "program_name": main_function["name"], + "exec_code": exec_code, + } or + + "{error}" + + Refer to @ https://github.com/MineDojo/Voyager/blob/main/voyager/agents/action.py + """ + + retry = 3 + error = None # 3 times failed return error + babel_generator = require("@babel/generator").default + while retry > 0: + try: + parsed = parse_js_code(msg) + # Collect func list: check if func & async + functions = [] + assert len(list(parsed.program.body)) > 0, "No functions found" + for i, node in enumerate(parsed.program.body): + if node.type != "FunctionDeclaration": + continue + node_type = ( + "AsyncFunctionDeclaration" + if node["async"] + else "FunctionDeclaration" + ) + functions.append( + { + "name": node.id.name, + "type": node_type, + "body": babel_generator(node).code, + "params": list(node["params"]), + } + ) + + # Ensure main_function is the last async function + main_function = None + for function in reversed(functions): + if function["type"] == "AsyncFunctionDeclaration": + main_function = function + break + assert ( + main_function is not None + ), "No async function found. Your main function must be async." + assert ( + len(main_function["params"]) == 1 + and main_function["params"][0].name == "bot" + ), f"Main function {main_function['name']} must take a single argument named 'bot'" + + # Split to program_code & exec_code for output + program_code = "\n\n".join(function["body"] for function in functions) + exec_code = f"await {main_function['name']}(bot);" + return { + "program_code": program_code, + "program_name": main_function["name"], + "exec_code": exec_code, + } + except Exception as e: + retry -= 1 + error = e + time.sleep(1) + return f"Error parsing action response (before program execution): {error}" diff --git a/metagpt/utils/minecraft/file_utils.py b/metagpt/utils/minecraft/file_utils.py new file mode 100644 index 000000000..ca82f99b6 --- /dev/null +++ b/metagpt/utils/minecraft/file_utils.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/09/25 16:13 +# @Author : yuymf +# @Desc : Temp Using :File system utils.@ https://github.com/MineDojo/Voyager/blob/main/voyager/utils/file_utils.py +import collections +import os + +f_ext = os.path.splitext + +f_size = os.path.getsize + +is_file = os.path.isfile + +is_dir = os.path.isdir + +get_dir = os.path.dirname + + +def is_sequence(obj): + """ + Returns: + True if the sequence is a collections.Sequence and not a string. + """ + return isinstance(obj, collections.abc.Sequence) and not isinstance(obj, str) + + +def pack_varargs(args): + """ + Pack *args or a single list arg as list + + def f(*args): + arg_list = pack_varargs(args) + # arg_list is now packed as a list + """ + assert isinstance(args, tuple), "please input the tuple `args` as in *args" + if len(args) == 1 and is_sequence(args[0]): + return args[0] + else: + return args + + +def f_expand(fpath): + return os.path.expandvars(os.path.expanduser(fpath)) + + +def f_exists(*fpaths): + return os.path.exists(f_join(*fpaths)) + + +def f_join(*fpaths): + """ + join file paths and expand special symbols like `~` for home dir + """ + fpaths = pack_varargs(fpaths) + fpath = f_expand(os.path.join(*fpaths)) + if isinstance(fpath, str): + fpath = fpath.strip() + return fpath + + +def f_mkdir(*fpaths): + """ + Recursively creates all the subdirs + If exist, do nothing. + """ + fpath = f_join(*fpaths) + os.makedirs(fpath, exist_ok=True) + return fpath + + +def load_text(*fpaths, by_lines=False): + with open(f_join(*fpaths), "r") as fp: + if by_lines: + return fp.readlines() + else: + return fp.read() + + +def load_text_lines(*fpaths): + return load_text(*fpaths, by_lines=True) + + +# aliases to be consistent with other load_* and dump_* +text_load = load_text +read_text = load_text +read_text_lines = load_text_lines diff --git a/metagpt/utils/minecraft/json_utils.py b/metagpt/utils/minecraft/json_utils.py new file mode 100644 index 000000000..0e9d9ba6e --- /dev/null +++ b/metagpt/utils/minecraft/json_utils.py @@ -0,0 +1,137 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/09/25 19:27 +# @Author : yuymf +# @Desc : Temp using @https://github.com/MineDojo/Voyager/blob/main/voyager/utils/json_utils.py + +import json +import re +from typing import Any, Dict, Union + + +def extract_char_position(error_message: str) -> int: + """Extract the character position from the JSONDecodeError message. + Args: + error_message (str): The error message from the JSONDecodeError + exception. + Returns: + int: The character position. + """ + import re + + char_pattern = re.compile(r"\(char (\d+)\)") + if match := char_pattern.search(error_message): + return int(match[1]) + else: + raise ValueError("Character position not found in the error message.") + + +def add_quotes_to_property_names(json_string: str) -> str: + """ + Add quotes to property names in a JSON string. + Args: + json_string (str): The JSON string. + Returns: + str: The JSON string with quotes added to property names. + """ + + def replace_func(match): + return f'"{match.group(1)}":' + + property_name_pattern = re.compile(r"(\w+):") + corrected_json_string = property_name_pattern.sub(replace_func, json_string) + + try: + json.loads(corrected_json_string) + return corrected_json_string + except json.JSONDecodeError as e: + raise e + + +def balance_braces(json_string: str) -> str: + """ + Balance the braces in a JSON string. + Args: + json_string (str): The JSON string. + Returns: + str: The JSON string with braces balanced. + """ + + open_braces_count = json_string.count("{") + close_braces_count = json_string.count("}") + + while open_braces_count > close_braces_count: + json_string += "}" + close_braces_count += 1 + + while close_braces_count > open_braces_count: + json_string = json_string.rstrip("}") + close_braces_count -= 1 + + try: + json.loads(json_string) + return json_string + except json.JSONDecodeError as e: + raise e + + +def fix_invalid_escape(json_str: str, error_message: str) -> str: + while error_message.startswith("Invalid \\escape"): + bad_escape_location = extract_char_position(error_message) + json_str = json_str[:bad_escape_location] + json_str[bad_escape_location + 1 :] + try: + json.loads(json_str) + return json_str + except json.JSONDecodeError as e: + error_message = str(e) + return json_str + + +def correct_json(json_str: str) -> str: + """ + Correct common JSON errors. + Args: + json_str (str): The JSON string. + """ + + try: + json.loads(json_str) + return json_str + except json.JSONDecodeError as e: + error_message = str(e) + if error_message.startswith("Invalid \\escape"): + json_str = fix_invalid_escape(json_str, error_message) + if error_message.startswith( + "Expecting property name enclosed in double quotes" + ): + json_str = add_quotes_to_property_names(json_str) + try: + json.loads(json_str) + return json_str + except json.JSONDecodeError as e: + error_message = str(e) + if balanced_str := balance_braces(json_str): + return balanced_str + return json_str + + +def fix_and_parse_json( + json_str: str, try_to_fix_with_gpt: bool = True +) -> Union[str, Dict[Any, Any]]: + """Fix and parse JSON string""" + try: + json_str = json_str.replace("\t", "") + return json.loads(json_str) + except json.JSONDecodeError as _: # noqa: F841 + json_str = correct_json(json_str) + try: + return json.loads(json_str) + except json.JSONDecodeError as _: # noqa: F841 + pass + try: + brace_index = json_str.index("{") + json_str = json_str[brace_index:] + last_brace_index = json_str.rindex("}") + json_str = json_str[: last_brace_index + 1] + return json.loads(json_str) + except json.JSONDecodeError as e: # noqa: F841 + raise e diff --git a/metagpt/utils/minecraft/load_prompts.py b/metagpt/utils/minecraft/load_prompts.py new file mode 100644 index 000000000..3b315461f --- /dev/null +++ b/metagpt/utils/minecraft/load_prompts.py @@ -0,0 +1,11 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/24 11:03 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +import pkg_resources +from .file_utils import load_text + + +def load_prompt(prompt): + package_path = pkg_resources.resource_filename("metagpt", "") + return load_text(f"{package_path}/prompts/minecraft/{prompt}.txt") diff --git a/metagpt/utils/minecraft/process_monitor.py b/metagpt/utils/minecraft/process_monitor.py new file mode 100644 index 000000000..53ec1c132 --- /dev/null +++ b/metagpt/utils/minecraft/process_monitor.py @@ -0,0 +1,90 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/09/25 16:12 +# @Author : yuymf +# @Desc : Temp using:@https://github.com/MineDojo/Voyager/blob/main/voyager/env/process_monitor.py +import time +import re +import warnings +from typing import List + +import psutil +import subprocess +import logging +import threading + +import metagpt.utils.minecraft as U + + +class SubprocessMonitor: + def __init__( + self, + commands: List[str], + name: str, + ready_match: str = r".*", + log_path: str = "logs", + callback_match: str = r"^(?!x)x$", # regex that will never match + callback: callable = None, + finished_callback: callable = None, + ): + self.commands = commands + start_time = time.strftime("%Y%m%d_%H%M%S") + self.name = name + self.logger = logging.getLogger(name) + handler = logging.FileHandler(U.f_join(log_path, f"{start_time}.log")) + formatter = logging.Formatter( + "%(asctime)s - %(name)s - %(levelname)s - %(message)s" + ) + handler.setFormatter(formatter) + self.logger.addHandler(handler) + self.logger.setLevel(logging.INFO) + self.process = None + self.ready_match = ready_match + self.ready_event = None + self.ready_line = None + self.callback_match = callback_match + self.callback = callback + self.finished_callback = finished_callback + self.thread = None + + def _start(self): + self.logger.info(f"Starting subprocess with commands: {self.commands}") + + self.process = psutil.Popen( + self.commands, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + universal_newlines=True, + ) + print(f"Subprocess {self.name} started with PID {self.process.pid}.") + for line in iter(self.process.stdout.readline, ""): + self.logger.info(line.strip()) + if re.search(self.ready_match, line): + self.ready_line = line + self.logger.info("Subprocess is ready.") + self.ready_event.set() + if re.search(self.callback_match, line): + self.callback() + if not self.ready_event.is_set(): + self.ready_event.set() + warnings.warn(f"Subprocess {self.name} failed to start.") + if self.finished_callback: + self.finished_callback() + + def run(self): + self.ready_event = threading.Event() + self.ready_line = None + self.thread = threading.Thread(target=self._start) + self.thread.start() + self.ready_event.wait() + + def stop(self): + self.logger.info("Stopping subprocess.") + if self.process and self.process.is_running(): + self.process.terminate() + self.process.wait() + + @property + def is_running(self): + if self.process is None: + return False + return self.process.is_running() diff --git a/metagpt/utils/mmdc_ink.py b/metagpt/utils/mmdc_ink.py new file mode 100644 index 000000000..3d91cde9d --- /dev/null +++ b/metagpt/utils/mmdc_ink.py @@ -0,0 +1,41 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/4 16:12 +@Author : alitrack +@File : mermaid.py +""" +import base64 +import os + +from aiohttp import ClientSession,ClientError +from metagpt.logs import logger + + +async def mermaid_to_file(mermaid_code, output_file_without_suffix): + """suffix: png/svg + :param mermaid_code: mermaid code + :param output_file_without_suffix: output filename without suffix + :return: 0 if succeed, -1 if failed + """ + encoded_string = base64.b64encode(mermaid_code.encode()).decode() + + for suffix in ["svg", "png"]: + output_file = f"{output_file_without_suffix}.{suffix}" + path_type = "svg" if suffix == "svg" else "img" + url = f"https://mermaid.ink/{path_type}/{encoded_string}" + async with ClientSession() as session: + try: + async with session.get(url) as response: + if response.status == 200: + text = await response.content.read() + with open(output_file, 'wb') as f: + f.write(text) + logger.info(f"Generating {output_file}..") + else: + logger.error(f"Failed to generate {output_file}") + return -1 + except ClientError as e: + logger.error(f"network error: {e}") + return -1 + return 0 diff --git a/metagpt/utils/mmdc_playwright.py b/metagpt/utils/mmdc_playwright.py new file mode 100644 index 000000000..bdbfd82ff --- /dev/null +++ b/metagpt/utils/mmdc_playwright.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/4 16:12 +@Author : Steven Lee +@File : mmdc_playwright.py +""" + +import os +from urllib.parse import urljoin +from playwright.async_api import async_playwright +from metagpt.logs import logger + +async def mermaid_to_file(mermaid_code, output_file_without_suffix, width=2048, height=2048)-> int: + """ + Converts the given Mermaid code to various output formats and saves them to files. + + Args: + mermaid_code (str): The Mermaid code to convert. + output_file_without_suffix (str): The output file name without the file extension. + width (int, optional): The width of the output image in pixels. Defaults to 2048. + height (int, optional): The height of the output image in pixels. Defaults to 2048. + + Returns: + int: Returns 1 if the conversion and saving were successful, -1 otherwise. + """ + suffixes=['png', 'svg', 'pdf'] + __dirname = os.path.dirname(os.path.abspath(__file__)) + + async with async_playwright() as p: + browser = await p.chromium.launch() + device_scale_factor = 1.0 + context = await browser.new_context( + viewport={'width': width, 'height': height}, + device_scale_factor=device_scale_factor, + ) + page = await context.new_page() + + async def console_message(msg): + logger.info(msg.text) + page.on('console', console_message) + + try: + await page.set_viewport_size({'width': width, 'height': height}) + + mermaid_html_path = os.path.abspath( + os.path.join(__dirname, 'index.html')) + mermaid_html_url = urljoin('file:', mermaid_html_path) + await page.goto(mermaid_html_url) + await page.wait_for_load_state("networkidle") + + await page.wait_for_selector("div#container", state="attached") + mermaid_config = {} + background_color = "#ffffff" + my_css = "" + await page.evaluate(f'document.body.style.background = "{background_color}";') + + metadata = await page.evaluate('''async ([definition, mermaidConfig, myCSS, backgroundColor]) => { + const { mermaid, zenuml } = globalThis; + await mermaid.registerExternalDiagrams([zenuml]); + mermaid.initialize({ startOnLoad: false, ...mermaidConfig }); + const { svg } = await mermaid.render('my-svg', definition, document.getElementById('container')); + document.getElementById('container').innerHTML = svg; + const svgElement = document.querySelector('svg'); + svgElement.style.backgroundColor = backgroundColor; + + if (myCSS) { + const style = document.createElementNS('http://www.w3.org/2000/svg', 'style'); + style.appendChild(document.createTextNode(myCSS)); + svgElement.appendChild(style); + } + + }''', [mermaid_code, mermaid_config, my_css, background_color]) + + if 'svg' in suffixes : + svg_xml = await page.evaluate('''() => { + const svg = document.querySelector('svg'); + const xmlSerializer = new XMLSerializer(); + return xmlSerializer.serializeToString(svg); + }''') + logger.info(f"Generating {output_file_without_suffix}.svg..") + with open(f'{output_file_without_suffix}.svg', 'wb') as f: + f.write(svg_xml.encode('utf-8')) + + if 'png' in suffixes: + clip = await page.evaluate('''() => { + const svg = document.querySelector('svg'); + const rect = svg.getBoundingClientRect(); + return { + x: Math.floor(rect.left), + y: Math.floor(rect.top), + width: Math.ceil(rect.width), + height: Math.ceil(rect.height) + }; + }''') + await page.set_viewport_size({'width': clip['x'] + clip['width'], 'height': clip['y'] + clip['height']}) + screenshot = await page.screenshot(clip=clip, omit_background=True, scale='device') + logger.info(f"Generating {output_file_without_suffix}.png..") + with open(f'{output_file_without_suffix}.png', 'wb') as f: + f.write(screenshot) + if 'pdf' in suffixes: + pdf_data = await page.pdf(scale=device_scale_factor) + logger.info(f"Generating {output_file_without_suffix}.pdf..") + with open(f'{output_file_without_suffix}.pdf', 'wb') as f: + f.write(pdf_data) + return 0 + except Exception as e: + logger.error(e) + return -1 + finally: + await browser.close() diff --git a/metagpt/utils/mmdc_pyppeteer.py b/metagpt/utils/mmdc_pyppeteer.py new file mode 100644 index 000000000..7ec30fd12 --- /dev/null +++ b/metagpt/utils/mmdc_pyppeteer.py @@ -0,0 +1,113 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/4 16:12 +@Author : alitrack +@File : mmdc_pyppeteer.py +""" +import os +from urllib.parse import urljoin +from pyppeteer import launch +from metagpt.logs import logger +from metagpt.config import CONFIG + +async def mermaid_to_file(mermaid_code, output_file_without_suffix, width=2048, height=2048)-> int: + """ + Converts the given Mermaid code to various output formats and saves them to files. + + Args: + mermaid_code (str): The Mermaid code to convert. + output_file_without_suffix (str): The output file name without the file extension. + width (int, optional): The width of the output image in pixels. Defaults to 2048. + height (int, optional): The height of the output image in pixels. Defaults to 2048. + + Returns: + int: Returns 1 if the conversion and saving were successful, -1 otherwise. + """ + suffixes = ['png', 'svg', 'pdf'] + __dirname = os.path.dirname(os.path.abspath(__file__)) + + + if CONFIG.pyppeteer_executable_path: + browser = await launch(headless=True, + executablePath=CONFIG.pyppeteer_executable_path, + args=['--disable-extensions',"--no-sandbox"] + ) + else: + logger.error("Please set the environment variable:PYPPETEER_EXECUTABLE_PATH.") + return -1 + page = await browser.newPage() + device_scale_factor = 1.0 + + async def console_message(msg): + logger.info(msg.text) + page.on('console', console_message) + + try: + await page.setViewport(viewport={'width': width, 'height': height, 'deviceScaleFactor': device_scale_factor}) + + mermaid_html_path = os.path.abspath( + os.path.join(__dirname, 'index.html')) + mermaid_html_url = urljoin('file:', mermaid_html_path) + await page.goto(mermaid_html_url) + + await page.querySelector("div#container") + mermaid_config = {} + background_color = "#ffffff" + my_css = "" + await page.evaluate(f'document.body.style.background = "{background_color}";') + + metadata = await page.evaluate('''async ([definition, mermaidConfig, myCSS, backgroundColor]) => { + const { mermaid, zenuml } = globalThis; + await mermaid.registerExternalDiagrams([zenuml]); + mermaid.initialize({ startOnLoad: false, ...mermaidConfig }); + const { svg } = await mermaid.render('my-svg', definition, document.getElementById('container')); + document.getElementById('container').innerHTML = svg; + const svgElement = document.querySelector('svg'); + svgElement.style.backgroundColor = backgroundColor; + + if (myCSS) { + const style = document.createElementNS('http://www.w3.org/2000/svg', 'style'); + style.appendChild(document.createTextNode(myCSS)); + svgElement.appendChild(style); + } + }''', [mermaid_code, mermaid_config, my_css, background_color]) + + if 'svg' in suffixes : + svg_xml = await page.evaluate('''() => { + const svg = document.querySelector('svg'); + const xmlSerializer = new XMLSerializer(); + return xmlSerializer.serializeToString(svg); + }''') + logger.info(f"Generating {output_file_without_suffix}.svg..") + with open(f'{output_file_without_suffix}.svg', 'wb') as f: + f.write(svg_xml.encode('utf-8')) + + if 'png' in suffixes: + clip = await page.evaluate('''() => { + const svg = document.querySelector('svg'); + const rect = svg.getBoundingClientRect(); + return { + x: Math.floor(rect.left), + y: Math.floor(rect.top), + width: Math.ceil(rect.width), + height: Math.ceil(rect.height) + }; + }''') + await page.setViewport({'width': clip['x'] + clip['width'], 'height': clip['y'] + clip['height'], 'deviceScaleFactor': device_scale_factor}) + screenshot = await page.screenshot(clip=clip, omit_background=True, scale='device') + logger.info(f"Generating {output_file_without_suffix}.png..") + with open(f'{output_file_without_suffix}.png', 'wb') as f: + f.write(screenshot) + if 'pdf' in suffixes: + pdf_data = await page.pdf(scale=device_scale_factor) + logger.info(f"Generating {output_file_without_suffix}.pdf..") + with open(f'{output_file_without_suffix}.pdf', 'wb') as f: + f.write(pdf_data) + return 0 + except Exception as e: + logger.error(e) + return -1 + finally: + await browser.close() + diff --git a/minecraft_run.py b/minecraft_run.py new file mode 100644 index 000000000..d7d2cf7c2 --- /dev/null +++ b/minecraft_run.py @@ -0,0 +1,34 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/9/24 11:09 +# @Author : stellahong (stellahong@fuzhi.ai) +# @Desc : +import asyncio + +from metagpt.roles.minecraft.curriculum_agent import CurriculumDesigner +from metagpt.roles.minecraft.skill_manager import SkillManager +from metagpt.roles.minecraft.action_developer import ActionDeveloper +from metagpt.roles.minecraft.critic_agent import CriticReviewer +from metagpt.minecraft_team import MinecraftPlayer + + +async def learn(task="Start", investment: float = 50.0, n_round: int = 3): + mc_player = MinecraftPlayer() + mc_player.set_port(1077) # Modify this to your Minecraft LAN port + # mc_player.set_resume(True) # If load json from ckpt dir(include chest_memory, skills, ...) + mc_player.hire( + [ + CurriculumDesigner(), + ActionDeveloper(), + CriticReviewer(), + SkillManager(), + + ] + ) + + mc_player.invest(investment) + mc_player.start(task) + await mc_player.run(n_round=n_round) + + +if __name__ == "__main__": + asyncio.run(learn()) diff --git a/requirements.txt b/requirements.txt index 741ae74df..de861ded9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -27,7 +27,7 @@ PyYAML==6.0 # sentence_transformers==2.2.2 setuptools==65.6.3 tenacity==8.2.2 -tiktoken==0.3.3 +tiktoken==0.4.0 tqdm==4.64.0 #unstructured[local-inference] # playwright @@ -37,4 +37,9 @@ anthropic==0.3.6 typing-inspect==0.8.0 typing_extensions==4.5.0 libcst==1.0.1 -qdrant-client==1.4.0 \ No newline at end of file +qdrant-client==1.4.0 +pytest-mock==3.11.1 +open-interpreter==0.1.4; python_version>"3.9" +ta==0.10.2 +semantic-kernel==0.3.10.dev0 + diff --git a/setup.py b/setup.py index a88f9de92..f9ae768e6 100644 --- a/setup.py +++ b/setup.py @@ -47,6 +47,7 @@ setup( "selenium": ["selenium>4", "webdriver_manager", "beautifulsoup4"], "search-google": ["google-api-python-client==2.94.0"], "search-ddg": ["duckduckgo-search==3.8.5"], + "pyppeteer": ["pyppeteer>=1.0.2"], }, cmdclass={ "install_mermaid": InstallMermaidCLI, diff --git a/startup.py b/startup.py index e6d5fc4e9..e2a903c9b 100644 --- a/startup.py +++ b/startup.py @@ -1,11 +1,16 @@ #!/usr/bin/env python # -*- coding: utf-8 -*- import asyncio -import platform + import fire -from metagpt.roles import Architect, Engineer, ProductManager -from metagpt.roles import ProjectManager, QaEngineer +from metagpt.roles import ( + Architect, + Engineer, + ProductManager, + ProjectManager, + QaEngineer, +) from metagpt.software_company import SoftwareCompany @@ -15,15 +20,17 @@ async def startup( n_round: int = 5, code_review: bool = False, run_tests: bool = False, - implement: bool = True + implement: bool = True, ): """Run a startup. Be a boss.""" company = SoftwareCompany() - company.hire([ - ProductManager(), - Architect(), - ProjectManager(), - ]) + company.hire( + [ + ProductManager(), + Architect(), + ProjectManager(), + ] + ) # if implement or code_review if implement or code_review: @@ -46,7 +53,7 @@ def main( n_round: int = 5, code_review: bool = True, run_tests: bool = False, - implement: bool = True + implement: bool = True, ): """ We are a software startup comprised of AI. By investing in us, @@ -58,12 +65,8 @@ def main( :param code_review: Whether to use code review. :return: """ - if platform.system() == "Windows": - asyncio.set_event_loop_policy(asyncio.WindowsSelectorEventLoopPolicy()) - asyncio.run(startup(idea, investment, n_round, - code_review, run_tests, implement)) + asyncio.run(startup(idea, investment, n_round, code_review, run_tests, implement)) -if __name__ == '__main__': +if __name__ == "__main__": fire.Fire(main) - diff --git a/tests/metagpt/actions/test_clone_function.py b/tests/metagpt/actions/test_clone_function.py new file mode 100644 index 000000000..6d4432dcd --- /dev/null +++ b/tests/metagpt/actions/test_clone_function.py @@ -0,0 +1,54 @@ +import pytest + +from metagpt.actions.clone_function import CloneFunction, run_function_code + + +source_code = """ +import pandas as pd +import ta + +def user_indicator(): + # 读取股票数据 + stock_data = pd.read_csv('./tests/data/baba_stock.csv') + stock_data.head() + # 计算简单移动平均线 + stock_data['SMA'] = ta.trend.sma_indicator(stock_data['Close'], window=6) + stock_data[['Date', 'Close', 'SMA']].head() + # 计算布林带 + stock_data['bb_upper'], stock_data['bb_middle'], stock_data['bb_lower'] = ta.volatility.bollinger_hband_indicator(stock_data['Close'], window=20), ta.volatility.bollinger_mavg(stock_data['Close'], window=20), ta.volatility.bollinger_lband_indicator(stock_data['Close'], window=20) + stock_data[['Date', 'Close', 'bb_upper', 'bb_middle', 'bb_lower']].head() +""" + +template_code = """ +def stock_indicator(stock_path: str, indicators=['Simple Moving Average', 'BollingerBands', 'MACD]) -> pd.DataFrame: + import pandas as pd + # here is your code. +""" + + +def get_expected_res(): + import pandas as pd + import ta + + # 读取股票数据 + stock_data = pd.read_csv('./tests/data/baba_stock.csv') + stock_data.head() + # 计算简单移动平均线 + stock_data['SMA'] = ta.trend.sma_indicator(stock_data['Close'], window=6) + stock_data[['Date', 'Close', 'SMA']].head() + # 计算布林带 + stock_data['bb_upper'], stock_data['bb_middle'], stock_data['bb_lower'] = ta.volatility.bollinger_hband_indicator(stock_data['Close'], window=20), ta.volatility.bollinger_mavg(stock_data['Close'], window=20), ta.volatility.bollinger_lband_indicator(stock_data['Close'], window=20) + stock_data[['Date', 'Close', 'bb_upper', 'bb_middle', 'bb_lower']].head() + return stock_data + + +@pytest.mark.asyncio +async def test_clone_function(): + clone = CloneFunction() + code = await clone.run(template_code, source_code) + assert 'def ' in code + stock_path = './tests/data/baba_stock.csv' + df, msg = run_function_code(code, 'stock_indicator', stock_path) + assert not msg + expected_df = get_expected_res() + assert df.equals(expected_df) diff --git a/tests/metagpt/actions/test_design_api.py b/tests/metagpt/actions/test_design_api.py index e6a396ad0..0add8fb74 100644 --- a/tests/metagpt/actions/test_design_api.py +++ b/tests/metagpt/actions/test_design_api.py @@ -9,6 +9,7 @@ import pytest from metagpt.actions.design_api import WriteDesign from metagpt.logs import logger +from metagpt.schema import Message from tests.metagpt.actions.mock import PRD_SAMPLE @@ -18,9 +19,10 @@ async def test_design_api(): design_api = WriteDesign("design_api") - result = await design_api.run(prd) + result = await design_api.run([Message(content=prd, instruct_content=None)]) logger.info(result) - assert len(result) > 0 + + assert result @pytest.mark.asyncio @@ -28,7 +30,7 @@ async def test_design_api_calculator(): prd = PRD_SAMPLE design_api = WriteDesign("design_api") - result = await design_api.run(prd) + result = await design_api.run([Message(content=prd, instruct_content=None)]) logger.info(result) - assert len(result) > 10 + assert result diff --git a/tests/metagpt/actions/test_detail_mining.py b/tests/metagpt/actions/test_detail_mining.py new file mode 100644 index 000000000..c9d5331f9 --- /dev/null +++ b/tests/metagpt/actions/test_detail_mining.py @@ -0,0 +1,23 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/13 00:26 +@Author : fisherdeng +@File : test_detail_mining.py +""" +import pytest + +from metagpt.actions.detail_mining import DetailMining +from metagpt.logs import logger + +@pytest.mark.asyncio +async def test_detail_mining(): + topic = "如何做一个生日蛋糕" + record = "我认为应该先准备好材料,然后再开始做蛋糕。" + detail_mining = DetailMining("detail_mining") + rsp = await detail_mining.run(topic=topic, record=record) + logger.info(f"{rsp.content=}") + + assert '##OUTPUT' in rsp.content + assert '蛋糕' in rsp.content + diff --git a/tests/metagpt/actions/test_write_test.py b/tests/metagpt/actions/test_write_test.py index 87a22b139..e5acdff44 100644 --- a/tests/metagpt/actions/test_write_test.py +++ b/tests/metagpt/actions/test_write_test.py @@ -31,7 +31,7 @@ async def test_write_test(): code_to_test=code, test_file_name="test_food.py", source_file_path="/some/dummy/path/cli_snake_game/cli_snake_game/food.py", - workspace="/some/dummy/path/cli_snake_game" + workspace="/some/dummy/path/cli_snake_game", ) logger.info(test_code) @@ -40,3 +40,18 @@ async def test_write_test(): assert "from cli_snake_game.food import Food" in test_code assert "class TestFood(unittest.TestCase)" in test_code assert "def test_generate" in test_code + + +@pytest.mark.asyncio +async def test_write_code_invalid_code(mocker): + # Mock the _aask method to return an invalid code string + mocker.patch.object(WriteTest, "_aask", return_value="Invalid Code String") + + # Create an instance of WriteTest + write_test = WriteTest() + + # Call the write_code method + code = await write_test.write_code("Some prompt:") + + # Assert that the returned code is the same as the invalid code string + assert code == "Invalid Code String" diff --git a/tests/metagpt/actions/test_write_tutorial.py b/tests/metagpt/actions/test_write_tutorial.py new file mode 100644 index 000000000..683fee082 --- /dev/null +++ b/tests/metagpt/actions/test_write_tutorial.py @@ -0,0 +1,40 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/6 21:41:34 +@Author : Stitch-z +@File : test_write_tutorial.py +""" +from typing import Dict + +import pytest + +from metagpt.actions.write_tutorial import WriteDirectory, WriteContent + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("language", "topic"), + [("English", "Write a tutorial about Python")] +) +async def test_write_directory(language: str, topic: str): + ret = await WriteDirectory(language=language).run(topic=topic) + assert isinstance(ret, dict) + assert "title" in ret + assert "directory" in ret + assert isinstance(ret["directory"], list) + assert len(ret["directory"]) + assert isinstance(ret["directory"][0], dict) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("language", "topic", "directory"), + [("English", "Write a tutorial about Python", {"Introduction": ["What is Python?", "Why learn Python?"]})] +) +async def test_write_content(language: str, topic: str, directory: Dict): + ret = await WriteContent(language=language, directory=directory).run(topic=topic) + assert isinstance(ret, str) + assert list(directory.keys())[0] in ret + for value in list(directory.values())[0]: + assert value in ret diff --git a/tests/metagpt/planner/__init__.py b/tests/metagpt/planner/__init__.py new file mode 100644 index 000000000..85e01b36b --- /dev/null +++ b/tests/metagpt/planner/__init__.py @@ -0,0 +1,7 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/16 20:03 +@Author : femto Zheng +@File : __init__.py +""" diff --git a/tests/metagpt/planner/test_action_planner.py b/tests/metagpt/planner/test_action_planner.py new file mode 100644 index 000000000..5ab9a493f --- /dev/null +++ b/tests/metagpt/planner/test_action_planner.py @@ -0,0 +1,29 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/16 20:03 +@Author : femto Zheng +@File : test_basic_planner.py +""" +import pytest +from semantic_kernel.core_skills import FileIOSkill, MathSkill, TextSkill, TimeSkill +from semantic_kernel.planning.action_planner.action_planner import ActionPlanner + +from metagpt.actions import BossRequirement +from metagpt.roles.sk_agent import SkAgent +from metagpt.schema import Message + + +@pytest.mark.asyncio +async def test_action_planner(): + role = SkAgent(planner_cls=ActionPlanner) + # let's give the agent 4 skills + role.import_skill(MathSkill(), "math") + role.import_skill(FileIOSkill(), "fileIO") + role.import_skill(TimeSkill(), "time") + role.import_skill(TextSkill(), "text") + task = "What is the sum of 110 and 990?" + role.recv(Message(content=task, cause_by=BossRequirement)) + + await role._think() # it will choose mathskill.Add + assert "1100" == (await role._act()).content diff --git a/tests/metagpt/planner/test_basic_planner.py b/tests/metagpt/planner/test_basic_planner.py new file mode 100644 index 000000000..03a82ec5e --- /dev/null +++ b/tests/metagpt/planner/test_basic_planner.py @@ -0,0 +1,34 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/16 20:03 +@Author : femto Zheng +@File : test_basic_planner.py +""" +import pytest +from semantic_kernel.core_skills import TextSkill + +from metagpt.actions import BossRequirement +from metagpt.const import SKILL_DIRECTORY +from metagpt.roles.sk_agent import SkAgent +from metagpt.schema import Message + + +@pytest.mark.asyncio +async def test_basic_planner(): + task = """ + Tomorrow is Valentine's day. I need to come up with a few date ideas. She speaks French so write it in French. + Convert the text to uppercase""" + role = SkAgent() + + # let's give the agent some skills + role.import_semantic_skill_from_directory(SKILL_DIRECTORY, "SummarizeSkill") + role.import_semantic_skill_from_directory(SKILL_DIRECTORY, "WriterSkill") + role.import_skill(TextSkill(), "TextSkill") + # using BasicPlanner + role.recv(Message(content=task, cause_by=BossRequirement)) + await role._think() + # assuming sk_agent will think he needs WriterSkill.Brainstorm and WriterSkill.Translate + assert "WriterSkill.Brainstorm" in role.plan.generated_plan.result + assert "WriterSkill.Translate" in role.plan.generated_plan.result + # assert "SALUT" in (await role._act()).content #content will be some French diff --git a/tests/metagpt/roles/minecraft/test_action_developer.py b/tests/metagpt/roles/minecraft/test_action_developer.py new file mode 100644 index 000000000..f00bfb783 --- /dev/null +++ b/tests/metagpt/roles/minecraft/test_action_developer.py @@ -0,0 +1,93 @@ +import asyncio + +from metagpt.minecraft_team import GameEnvironment +from metagpt.roles.minecraft.action_developer import ActionDeveloper +from metagpt.logs import logger + + +async def main(): + events = [ + [ + "observe", + { + "voxels": ["grass_block", "dirt", "grass"], + "status": { + "health": 20, + "food": 20, + "saturation": 5, + "oxygen": 20, + "position": {"x": 0.5, "y": 84, "z": -207.5}, + "velocity": {"x": 0, "y": -0.0784000015258789, "z": 0}, + "yaw": 3.141592653589793, + "pitch": 0, + "onGround": True, + "equipment": [None, None, None, None, None, None], + "name": "bot", + "isInWater": False, + "isInLava": False, + "isCollidedHorizontally": False, + "isCollidedVertically": True, + "biome": "plains", + "entities": { + "chicken": 29.071822119730644, + "sheep": 20.361212992763768, + }, + "timeOfDay": "day", + "inventoryUsed": 0, + "elapsedTime": 41, + }, + "inventory": {}, + "nearbyChests": {"(1344, 64, 1381)": "Unknown"}, + "blockRecords": ["grass_block", "dirt", "grass"], + }, + ] + ] + + code = """ + async function collectBamboo(bot) { + // Equip the iron sword + const ironSword = bot.inventory.findInventoryItem(mcData.itemsByName.iron_sword.id); + await bot.equip(ironSword, "hand"); + + // Find bamboo plants using the exploreUntil function + const bambooPlants = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => { + const bambooPlants = bot.findBlocks({ + matching: block => block.name === "bamboo", + maxDistance: 32, + count: 10 + }); + return bambooPlants.length >= 10 ? bambooPlants : null; + }); + if (!bambooPlants) { + bot.chat("Could not find enough bamboo plants."); + return; + } + + // Break 10 bamboo plants using the iron sword + for (const bambooPlant of bambooPlants) { + const block = bot.blockAt(bambooPlant); + await bot.dig(block); + } + bot.chat("Broke 10 bamboo plants."); + + // Collect the dropped bamboo items + for (const bambooPlant of bambooPlants) { + await bot.pathfinder.goto(new GoalBlock(bambooPlant.x, bambooPlant.y, bambooPlant.z)); + } + bot.chat("Collected 10 bamboo."); + } + """ + ad = ActionDeveloper() + ge = GameEnvironment() + ge.update_event(events) + ad.set_memory(shared_memory=ge) + msg = ad.encapsule_message(events=ge.event, code=code) + logger.info(f"Encapsuled_message: {msg}") + + parsed_result = await ad.generate_action_code(**msg) + + logger.info(f"Parsed_code_updating: {parsed_result}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/metagpt/roles/minecraft/test_critic_agent.py b/tests/metagpt/roles/minecraft/test_critic_agent.py new file mode 100644 index 000000000..a0febf07b --- /dev/null +++ b/tests/metagpt/roles/minecraft/test_critic_agent.py @@ -0,0 +1,64 @@ +import asyncio + +from metagpt.minecraft_team import GameEnvironment +from metagpt.roles.minecraft.critic_agent import CriticReviewer +from metagpt.logs import logger + + +async def main(): + events = [ + [ + "observe", + { + "voxels": ["grass_block", "dirt", "grass"], + "status": { + "health": 20, + "food": 20, + "saturation": 5, + "oxygen": 20, + "position": {"x": 0.5, "y": 84, "z": -207.5}, + "velocity": {"x": 0, "y": -0.0784000015258789, "z": 0}, + "yaw": 3.141592653589793, + "pitch": 0, + "onGround": True, + "equipment": [None, None, None, None, None, None], + "name": "bot", + "isInWater": False, + "isInLava": False, + "isCollidedHorizontally": False, + "isCollidedVertically": True, + "biome": "plains", + "entities": { + "chicken": 29.071822119730644, + "sheep": 20.361212992763768, + }, + "timeOfDay": "day", + "inventoryUsed": 0, + "elapsedTime": 41, + }, + "inventory": {}, + "nearbyChests": {"(1344, 64, 1381)": "Unknown"}, + "blockRecords": ["grass_block", "dirt", "grass"], + }, + ] + ] + task = "Obtain 3 more spruce logs" + chest_observation = "Chests: None\n\n" + + context = "Question: How to obtain 3 more spruce logs in Minecraft?\nAnswer: You can obtain more spruce logs in Minecraft by finding and chopping down spruce trees in a spruce forest biome. If you have already chopped down all the spruce trees in the area, you can either explore further to find more spruce trees or plant saplings and wait for them to grow into trees." + cr = CriticReviewer() + ge = GameEnvironment() + ge.update_event(events) + cr.set_memory(shared_memory=ge) + msg = cr.encapsule_message( + events=ge.event, task=task, context=context, chest_observation=chest_observation + ) + logger.info(f"Encapsuled_message: {msg}") + + verify = await cr.verify_task(**msg) + + logger.info(f"Parsed_code_updating: {verify}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/metagpt/roles/minecraft/test_curriculum_agent.py b/tests/metagpt/roles/minecraft/test_curriculum_agent.py new file mode 100644 index 000000000..8c0f75e46 --- /dev/null +++ b/tests/metagpt/roles/minecraft/test_curriculum_agent.py @@ -0,0 +1,67 @@ +import asyncio + +from metagpt.minecraft_team import GameEnvironment +from metagpt.roles.minecraft.curriculum_agent import CurriculumDesigner +from metagpt.logs import logger + + +async def main(): + events = [ + [ + "observe", + { + "voxels": ["grass_block", "dirt", "grass"], + "status": { + "health": 20, + "food": 20, + "saturation": 5, + "oxygen": 20, + "position": {"x": 0.5, "y": 84, "z": -207.5}, + "velocity": {"x": 0, "y": -0.0784000015258789, "z": 0}, + "yaw": 3.141592653589793, + "pitch": 0, + "onGround": True, + "equipment": [None, None, None, None, None, None], + "name": "bot", + "isInWater": False, + "isInLava": False, + "isCollidedHorizontally": False, + "isCollidedVertically": True, + "biome": "plains", + "entities": { + "chicken": 29.071822119730644, + "sheep": 20.361212992763768, + }, + "timeOfDay": "day", + "inventoryUsed": 0, + "elapsedTime": 41, + }, + "inventory": {}, + "nearbyChests": {"(1344, 64, 1381)": "Unknown"}, + "blockRecords": ["grass_block", "dirt", "grass"], + }, + ] + ] + + cd = CurriculumDesigner() + ge = GameEnvironment() + ge.update_event(events) + cd.set_memory(shared_memory=ge) + + task_msg = cd.encapsule_design_task_message( + events=ge.event, chest_observation=ge.chest_observation + ) + logger.info(f"Encapsuled_design_task_message: {task_msg}") + task = await cd.handle_task_design(**task_msg) + logger.info(f"Design_task_updating: {task}") + + context_msg = cd.encapsule_design_curriculum_message( + events=ge.event, chest_observation=ge.chest_observation + ) + logger.info(f"Encapsuled_design_context_message: {context_msg}") + context = await cd.handle_curriculum_design(**context_msg) + logger.info(f"Design_context_updating: {context}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/metagpt/roles/minecraft/test_skill_manager.py b/tests/metagpt/roles/minecraft/test_skill_manager.py new file mode 100644 index 000000000..7858ba3ea --- /dev/null +++ b/tests/metagpt/roles/minecraft/test_skill_manager.py @@ -0,0 +1,93 @@ +import asyncio + +from metagpt.minecraft_team import GameEnvironment +from metagpt.roles.minecraft.skill_manager import SkillManager +from metagpt.logs import logger +from metagpt.actions.minecraft.manage_skills import ( + GenerateSkillDescription, + RetrieveSkills, + AddNewSkills, +) + + +async def main(): + events = [ + [ + "observe", + { + "voxels": ["grass_block", "dirt", "grass"], + "status": { + "health": 20, + "food": 20, + "saturation": 5, + "oxygen": 20, + "position": {"x": 0.5, "y": 84, "z": -207.5}, + "velocity": {"x": 0, "y": -0.0784000015258789, "z": 0}, + "yaw": 3.141592653589793, + "pitch": 0, + "onGround": True, + "equipment": [None, None, None, None, None, None], + "name": "bot", + "isInWater": False, + "isInLava": False, + "isCollidedHorizontally": False, + "isCollidedVertically": True, + "biome": "plains", + "entities": { + "chicken": 29.071822119730644, + "sheep": 20.361212992763768, + }, + "timeOfDay": "day", + "inventoryUsed": 0, + "elapsedTime": 41, + }, + "inventory": {}, + "nearbyChests": {"(1344, 64, 1381)": "Unknown"}, + "blockRecords": ["grass_block", "dirt", "grass"], + }, + ] + ] + program_code = 'async function obtainSpruceLogs(bot) {\n // Find 3 spruce_log blocks\n const spruceLogs = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {\n const spruceLog = bot.findBlock({\n matching: mcData.blocksByName["spruce_log"].id,\n maxDistance: 32,\n count: 3\n });\n return spruceLog ? spruceLog : null;\n });\n if (spruceLogs) {\n // Mine the spruce_log blocks\n await mineBlock(bot, "spruce_log", 3);\n bot.chat("3 spruce logs obtained.");\n } else {\n bot.chat("Could not find enough spruce logs.");\n }\n}' + program_name = "obtainSpruceLogs" + task = "Obtain 3 more spruce logs" + skills = { + "mineWoodLog": { + "code": 'async function mineWoodLog(bot) {\n const woodLogNames = ["oak_log", "birch_log", "spruce_log", "jungle_log", "acacia_log", "dark_oak_log", "mangrove_log"];\n\n // Find a wood log block\n const woodLog = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {\n for (const name of woodLogNames) {\n const log = bot.findBlock({\n matching: mcData.blocksByName[name].id,\n maxDistance: 32\n });\n if (log) {\n return log;\n }\n }\n return null;\n });\n if (woodLog) {\n // Mine the wood log block\n await mineBlock(bot, woodLog.name, 1);\n bot.chat("Wood log mined.");\n } else {\n bot.chat("Could not find a wood log.");\n }\n}', + "description": "async function mineWoodLog(bot) {\n // The function is about mining a wood log block. It searches for a wood log block by exploring the environment until it finds one of the seven types of wood logs. Once a wood log block is found, it is mined and a message is sent to the chat. If a wood log block is not found, a message is sent to the chat indicating that it could not be found.\n}", + }, + "obtainSpruceLogs": { + "code": 'async function obtainSpruceLogs(bot) {\n // Find 3 spruce_log blocks\n const spruceLogs = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => {\n const spruceLog = bot.findBlock({\n matching: mcData.blocksByName["spruce_log"].id,\n maxDistance: 32,\n count: 3\n });\n return spruceLog ? spruceLog : null;\n });\n if (spruceLogs) {\n // Mine the spruce_log blocks\n await mineBlock(bot, "spruce_log", 3);\n bot.chat("3 spruce logs obtained.");\n } else {\n bot.chat("Could not find enough spruce logs.");\n }\n}', + "description": "async function obtainSpruceLogs(bot) {\n // The function is about obtaining 3 spruce logs. It explores the environment until it finds 3 spruce_log blocks within a certain distance. Once the blocks are found, it mines them and sends a message indicating that 3 spruce logs have been obtained. If the blocks are not found, it sends a message indicating that it could not find enough spruce logs.\n}", + }, + } + context = "Question: How to obtain 3 more spruce logs in Minecraft?\nAnswer: You can obtain more spruce logs in Minecraft by finding and chopping down spruce trees in a spruce forest biome. If you have already chopped down all the spruce trees in the area, you can either explore further to find more spruce trees or plant saplings and wait for them to grow into trees." + + sm = SkillManager() + ge = GameEnvironment() + ge.update_event(events) + sm.set_memory(shared_memory=ge) + + generate_skill_message = sm.encapsule_message(program_code, program_name) + logger.info(f"Generate_skill_message: {generate_skill_message}") + desp = await sm.generate_skill_descp(**generate_skill_message) + logger.info(f"Generate_skill_descp UPDATING: {desp}") + + add_new_skills_message = { + "task": task, + "program_name": program_name, + "program_code": program_code, + "skills": skills, + } + logger.info(f"Handle_add_new_skills_message: {add_new_skills_message}") + new_skills_info = await sm.handle_add_new_skills(**add_new_skills_message) + logger.info(f"Handle_add_new_skills UPDATING: {new_skills_info}") + + retrieve_skills_message_step1 = {"query": context} + + logger.info(f"Retrieve_skills_message: {retrieve_skills_message_step1}") + skills = await sm.retrieve_skills(**retrieve_skills_message_step1) + logger.info(f"Retrieve_skills UPDATING: {skills}") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/metagpt/roles/test_product_manager.py b/tests/metagpt/roles/test_product_manager.py index 34c70efbc..21def787f 100644 --- a/tests/metagpt/roles/test_product_manager.py +++ b/tests/metagpt/roles/test_product_manager.py @@ -18,4 +18,4 @@ async def test_product_manager(): rsp = await product_manager.handle(MockMessages.req) logger.info(rsp) assert len(rsp.content) > 0 - assert "产品目标" in rsp.content + assert "Product Goals" in rsp.content diff --git a/tests/metagpt/roles/test_tutorial_assistant.py b/tests/metagpt/roles/test_tutorial_assistant.py new file mode 100644 index 000000000..945620cfc --- /dev/null +++ b/tests/metagpt/roles/test_tutorial_assistant.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/6 23:11:27 +@Author : Stitch-z +@File : test_tutorial_assistant.py +""" +import aiofiles +import pytest + +from metagpt.roles.tutorial_assistant import TutorialAssistant + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("language", "topic"), + [("Chinese", "Write a tutorial about Python")] +) +async def test_tutorial_assistant(language: str, topic: str): + topic = "Write a tutorial about MySQL" + role = TutorialAssistant(language=language) + msg = await role.run(topic) + filename = msg.content + title = filename.split("/")[-1].split(".")[0] + async with aiofiles.open(filename, mode="r") as reader: + content = await reader.read() + assert content.startswith(f"# {title}") \ No newline at end of file diff --git a/tests/metagpt/test_minecraft_team.py b/tests/metagpt/test_minecraft_team.py new file mode 100644 index 000000000..da0126d8d --- /dev/null +++ b/tests/metagpt/test_minecraft_team.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/09/28 00:03 +# @Author : yuymf +# @Desc : +import asyncio +from metagpt.logs import logger +from metagpt.minecraft_team import GameEnvironment + + +async def main(): + test_code = "bot.chat(`/time set ${getNextTime()}`);" + mc_port = 2745 + ge = GameEnvironment() + ge.set_mc_port(mc_port) + ge.update_code(test_code) + result = await ge.on_event() + logger.info("On event test done") + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/tests/metagpt/tools/test_code_interpreter.py b/tests/metagpt/tools/test_code_interpreter.py new file mode 100644 index 000000000..0eec3f80b --- /dev/null +++ b/tests/metagpt/tools/test_code_interpreter.py @@ -0,0 +1,42 @@ +import pytest +import pandas as pd +from pathlib import Path + +from tests.data import sales_desc, store_desc +from metagpt.tools.code_interpreter import OpenCodeInterpreter, OpenInterpreterDecorator +from metagpt.actions import Action +from metagpt.logs import logger + + +logger.add('./tests/data/test_ci.log') +stock = "./tests/data/baba_stock.csv" + + +# TODO: 需要一种表格数据格式,能够支持schame管理的,标注字段类型和字段含义。 +class CreateStockIndicators(Action): + @OpenInterpreterDecorator(save_code=True, code_file_path="./tests/data/stock_indicators.py") + async def run(self, stock_path: str, indicators=['Simple Moving Average', 'BollingerBands']) -> pd.DataFrame: + """对stock_path中的股票数据, 使用pandas和ta计算indicators中的技术指标, 返回带有技术指标的股票数据,不需要去除空值, 不需要安装任何包; + 指标生成对应的三列: SMA, BB_upper, BB_lower + """ + ... + + +@pytest.mark.asyncio +async def test_actions(): + # 计算指标 + indicators = ['Simple Moving Average', 'BollingerBands'] + stocker = CreateStockIndicators() + df, msg = await stocker.run(stock, indicators=indicators) + assert isinstance(df, pd.DataFrame) + assert 'Close' in df.columns + assert 'Date' in df.columns + # 将df保存为文件,将文件路径传入到下一个action + df_path = './tests/data/stock_indicators.csv' + df.to_csv(df_path) + assert Path(df_path).is_file() + # 可视化指标结果 + figure_path = './tests/data/figure_ci.png' + ci_ploter = OpenCodeInterpreter() + ci_ploter.chat(f"使用seaborn对{df_path}中与股票布林带有关的数据列的Date, Close, SMA, BB_upper(布林带上界), BB_lower(布林带下界)进行可视化, 可视化图片保存在{figure_path}中。不需要任何指标计算,把Date列转换为日期类型。要求图片优美,BB_upper, BB_lower之间使用合适的颜色填充。") + assert Path(figure_path).is_file() diff --git a/tests/metagpt/utils/minecraft/test_action_rsp_parser.py b/tests/metagpt/utils/minecraft/test_action_rsp_parser.py new file mode 100644 index 000000000..e41ce8dd4 --- /dev/null +++ b/tests/metagpt/utils/minecraft/test_action_rsp_parser.py @@ -0,0 +1,69 @@ +# -*- coding: utf-8 -*- +# @Date : 2023/09/28 00:08 +# @Author : yuymf +# @Desc : +from metagpt.utils.minecraft import parse_js_code, parse_action_response +from metagpt.logs import logger +from typing import Any + + +if __name__ == "__main__": + msg = ''' + Explain: The code from the last round is a function called `collectBamboo` that is supposed to collect bamboo plants. It equips an iron sword, finds bamboo plants using the `exploreUntil` function, breaks 10 bamboo plants using the iron sword, and then collects the dropped bamboo items. + + Plan: + 1) Check if the bot has an iron sword in its inventory. If not, collect the necessary materials and craft an iron sword using the `craftItem` function. + 2) Use the `exploreUntil` function to find at least 10 bamboo plants. If the function times out or cannot find enough bamboo plants, return and chat "Could not find enough bamboo plants." + 3) Equip the iron sword. + 4) Iterate over the found bamboo plants and break them using the iron sword. + 5) Chat "Broke 10 bamboo plants." + 6) Iterate over the found bamboo plants and collect the dropped bamboo items. + 7) Chat "Collected 10 bamboo." + + Code: + ```javascript + async function collectBamboo(bot) { + // Check if the bot has an iron sword + const ironSword = bot.inventory.findInventoryItem(mcData.itemsByName.iron_sword.id); + if (!ironSword) { + // Collect the necessary materials to craft an iron sword + await mineBlock(bot, "iron_ore", 3); + await smeltItem(bot, "iron_ore", "oak_planks", 3); + await craftItem(bot, "iron_sword", 1); + } + + // Find bamboo plants using the exploreUntil function + const bambooPlants = await exploreUntil(bot, new Vec3(1, 0, 1), 60, () => { + const bambooPlants = bot.findBlocks({ + matching: block => block.name === "bamboo", + maxDistance: 32, + count: 10 + }); + return bambooPlants.length >= 10 ? bambooPlants : null; + }); + if (!bambooPlants) { + bot.chat("Could not find enough bamboo plants."); + return; + } + + // Equip the iron sword + await bot.equip(ironSword, "hand"); + + // Break 10 bamboo plants using the iron sword + for (const bambooPlant of bambooPlants) { + const block = bot.blockAt(bambooPlant); + await bot.dig(block); + } + bot.chat("Broke 10 bamboo plants."); + + // Collect the dropped bamboo items + for (const bambooPlant of bambooPlants) { + await bot.pathfinder.goto(new GoalBlock(bambooPlant.x, bambooPlant.y, bambooPlant.z)); + } + bot.chat("Collected 10 bamboo."); + } + ``` + ''' + + logger.info(f"Parse_js_code result is HERE: {parse_js_code(msg)}") + logger.info(f"Parse_action_response result is HERE: {parse_action_response(msg)}") \ No newline at end of file diff --git a/tests/metagpt/utils/test_custom_decoder.py b/tests/metagpt/utils/test_custom_decoder.py new file mode 100644 index 000000000..c7b14ad59 --- /dev/null +++ b/tests/metagpt/utils/test_custom_decoder.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/8 11:38 +@Author : femto Zheng +@File : test_custom_decoder.py +""" + + +from metagpt.utils.custom_decoder import CustomDecoder + + +def test_parse_single_quote(): + # Create a custom JSON decoder + decoder = CustomDecoder(strict=False) + # Your provided input with single-quoted strings and line breaks + input_data = """{'a" + b':'"title": "Reach and engagement of campaigns", + "x-axis": "Low Reach --> High Reach", + "y-axis": "Low Engagement --> High Engagement", + "quadrant-1": "We should expand", + "quadrant-2": "Need to promote", + "quadrant-3": "Re-evaluate", + "quadrant-4": "May be improved", + "Campaign: A": [0.3, 0.6], + "Campaign B": [0.45, 0.23], + "Campaign C": [0.57, 0.69], + "Campaign D": [0.78, 0.34], + "Campaign E": [0.40, 0.34], + "Campaign F": [0.35, 0.78], + "Our Target Product": [0.5, 0.6] + ' + } + """ + # Parse the JSON using the custom decoder + + parsed_data = decoder.decode(input_data) + assert 'a"\n b' in parsed_data + + +def test_parse_triple_double_quote(): + # Create a custom JSON decoder + decoder = CustomDecoder(strict=False) + # Your provided input with single-quoted strings and line breaks + input_data = '{"""a""":"b"}' + # Parse the JSON using the custom decoder + + parsed_data = decoder.decode(input_data) + assert "a" in parsed_data + + input_data = '{"""a""":"""b"""}' + # Parse the JSON using the custom decoder + + parsed_data = decoder.decode(input_data) + assert parsed_data["a"] == "b" + + +def test_parse_triple_single_quote(): + # Create a custom JSON decoder + decoder = CustomDecoder(strict=False) + # Your provided input with single-quoted strings and line breaks + input_data = "{'''a''':'b'}" + # Parse the JSON using the custom decoder + + parsed_data = decoder.decode(input_data) + assert "a" in parsed_data + + input_data = "{'''a''':'''b'''}" + # Parse the JSON using the custom decoder + + parsed_data = decoder.decode(input_data) + assert parsed_data["a"] == "b" diff --git a/tests/metagpt/utils/test_file.py b/tests/metagpt/utils/test_file.py new file mode 100644 index 000000000..b30e6be93 --- /dev/null +++ b/tests/metagpt/utils/test_file.py @@ -0,0 +1,26 @@ +#!/usr/bin/env python3 +# _*_ coding: utf-8 _*_ +""" +@Time : 2023/9/4 15:40:40 +@Author : Stitch-z +@File : test_file.py +""" +from pathlib import Path + +import pytest + +from metagpt.utils.file import File + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + ("root_path", "filename", "content"), + [(Path("/code/MetaGPT/data/tutorial_docx/2023-09-07_17-05-20"), "test.md", "Hello World!")] +) +async def test_write_and_read_file(root_path: Path, filename: str, content: bytes): + full_file_name = await File.write(root_path=root_path, filename=filename, content=content.encode('utf-8')) + assert isinstance(full_file_name, Path) + assert root_path / filename == full_file_name + file_data = await File.read(full_file_name) + assert file_data.decode("utf-8") == content + diff --git a/tests/metagpt/utils/test_json_to_markdown.py b/tests/metagpt/utils/test_json_to_markdown.py new file mode 100644 index 000000000..53e410398 --- /dev/null +++ b/tests/metagpt/utils/test_json_to_markdown.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python +# -*- coding: utf-8 -*- +""" +@Time : 2023/9/11 11:53 +@Author : femto Zheng +@File : test_json_to_markdown.py +""" + +from metagpt.utils.json_to_markdown import json_to_markdown + + +def test_json_to_markdown(): + # Example nested JSON data + json_data = { + "title": "Sample JSON to Markdown Conversion", + "description": "Convert JSON to Markdown with headings and lists.", + "tags": ["json", "markdown", "conversion"], + "content": { + "section1": {"subsection1": "This is a subsection.", "subsection2": "Another subsection."}, + "section2": "This is the second section content.", + }, + } + + # Convert JSON to Markdown with nested sections + markdown_output = json_to_markdown(json_data) + + expected = """## title + +Sample JSON to Markdown Conversion + +## description + +Convert JSON to Markdown with headings and lists. + +## tags + +- json +- markdown +- conversion + +## content + +### section1 + +#### subsection1 + +This is a subsection. + +#### subsection2 + +Another subsection. + +### section2 + +This is the second section content. + +""" + # Print or use the generated Markdown + # print(markdown_output) + assert expected == markdown_output diff --git a/tests/metagpt/utils/test_output_parser.py b/tests/metagpt/utils/test_output_parser.py index c56cff6fa..2b706efc4 100644 --- a/tests/metagpt/utils/test_output_parser.py +++ b/tests/metagpt/utils/test_output_parser.py @@ -5,7 +5,7 @@ @Author : chengmaoyu @File : test_output_parser.py """ -from typing import List, Tuple +from typing import List, Tuple, Union import pytest @@ -64,6 +64,59 @@ def test_parse_data(): assert OutputParser.parse_data(test_data) == expected_result +@pytest.mark.parametrize( + ("text", "data_type", "parsed_data", "expected_exception"), + [ + ( + """xxx [1, 2, ["a", "b", [3, 4]], {"x": 5, "y": [6, 7]}] xxx""", + list, + [1, 2, ["a", "b", [3, 4]], {"x": 5, "y": [6, 7]}], + None, + ), + ( + """xxx ["1", "2", "3"] xxx \n xxx \t xx""", + list, + ["1", "2", "3"], + None, + ), + ( + """{"title": "a", "directory": {"sub_dir1": ["title1, title2"]}, "sub_dir2": [1, 2]}""", + dict, + {"title": "a", "directory": {"sub_dir1": ["title1, title2"]}, "sub_dir2": [1, 2]}, + None, + ), + ( + """xxx {"title": "x", \n \t "directory": ["x", \n "y"]} xxx \n xxx \t xx""", + dict, + {"title": "x", "directory": ["x", "y"]}, + None, + ), + ( + """xxx xx""", + list, + None, + Exception, + ), + ( + """xxx [1, 2, []xx""", + list, + None, + Exception, + ), + ] +) +def test_extract_struct(text: str, data_type: Union[type(list), type(dict)], parsed_data: Union[list, dict], expected_exception): + def case(): + resp = OutputParser.extract_struct(text, data_type) + assert resp == parsed_data + + if expected_exception: + with pytest.raises(expected_exception): + case() + else: + case() + + if __name__ == '__main__': t_text = ''' ## Required Python third-party packages