├── .env.example ├── .gitignore ├── LICENSE ├── README.md ├── agbenchmark ├── __init__.py ├── benchmarks.py └── config.json ├── beebot ├── __init__.py ├── agents │ ├── __init__.py │ ├── base_agent.py │ ├── coding_agent.py │ ├── generalist_agent.py │ └── research_agent.py ├── api │ ├── __init__.py │ ├── routes.py │ └── websocket.py ├── body │ ├── __init__.py │ ├── body.py │ ├── llm.py │ └── pack_utils.py ├── config │ ├── __init__.py │ ├── config.py │ └── database_file_manager.py ├── decider │ ├── __init__.py │ ├── decider.py │ └── deciding_prompt.py ├── decomposer │ ├── __init__.py │ ├── decomposer.py │ └── decomposer_prompt.py ├── execution │ ├── __init__.py │ ├── background_process.py │ ├── executor.py │ ├── step.py │ ├── task_execution.py │ └── task_state_machine.py ├── executor │ └── __init__.py ├── initiator │ ├── __init__.py │ ├── api.py │ ├── benchmark_entrypoint.py │ └── cli.py ├── models │ ├── __init__.py │ └── database_models.py ├── overseer │ ├── __init__.py │ ├── overseeing_prompt.py │ └── overseer.py ├── packs │ ├── README.md │ ├── __init__.py │ ├── delegate_task.py │ ├── disk_usage │ │ ├── __init__.py │ │ ├── disk_usage.py │ │ └── test_disk_usage.py │ ├── execute_python_file.py │ ├── execute_python_file_in_background.py │ ├── exit.py │ ├── export_variable.py │ ├── extract_information_from_webpage │ │ ├── __init__.py │ │ ├── extract_information_from_webpage.py │ │ └── test_extract_information_from_webpage.py │ ├── filesystem │ │ ├── __init__.py │ │ ├── delete_file.py │ │ ├── list_files.py │ │ ├── read_file.py │ │ ├── test_delete_file.py │ │ ├── test_list_files.py │ │ ├── test_read_file.py │ │ ├── test_write_file.py │ │ └── write_file.py │ ├── filesystem_utils.py │ ├── get_process_status.py │ ├── get_webpage_html_content │ │ ├── __init__.py │ │ ├── get_webpage_html_content.py │ │ └── test_get_webpage_html_content.py │ ├── get_website_text_content.py │ ├── gmail.py │ ├── google_search │ │ ├── __init__.py │ │ ├── google_search.py │ │ └── test_google_search.py │ ├── http_request │ │ ├── __init__.py │ │ ├── http_request.py │ │ └── test_http_request.py │ ├── install_python_package.py │ ├── kill_process.py │ ├── list_processes.py │ ├── os_info │ │ ├── __init__.py │ │ ├── os_info.py │ │ └── test_os_info.py │ ├── poetry.lock │ ├── pyproject.toml │ ├── summarization.py │ ├── summarization_prompt.py │ ├── system_base_pack.py │ ├── wikipedia_summarize │ │ ├── __init__.py │ │ ├── test_wikipedia.py │ │ └── wikipedia.py │ ├── wolframalpha_query │ │ ├── __init__.py │ │ ├── test_wolframalpha_query.py │ │ └── wolframalpha_query.py │ └── write_python_code │ │ ├── __init__.py │ │ ├── test_write_python_file.py │ │ └── write_python_file.py ├── planner │ ├── __init__.py │ ├── planner.py │ └── planning_prompt.py ├── tool_filters │ ├── __init__.py │ └── filter_long_documents.py └── utils.py ├── docker-compose.yml ├── docs └── architecture.md ├── migrations └── 20230717_01_initial_schema.sql ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── setup.sh ├── tests ├── __init__.py ├── conftest.py └── end_to_end │ ├── __init__.py │ ├── test_background_python.py │ ├── test_capital_retrieval.py │ ├── test_history.py │ ├── test_revenue_lookup.py │ ├── test_system_basic_cycle.py │ └── test_webserver.py └── yoyo.ini /.env.example: -------------------------------------------------------------------------------- 1 | # Your OpenAI API Key. If GPT-4 is available it will use that, otherwise will use 3.5-turbo 2 | OPENAI_API_KEY=MY-API-KEY 3 | 4 | # When this is set the entire process will be killed when the agent completes. Disable for testing. 5 | BEEBOT_HARD_EXIT=True 6 | 7 | # If you want to enable Helicone proxy and caching 8 | # HELICONE_KEY=MY-HELICONE-KEY 9 | # OPENAI_API_BASE=https://oai.hconeai.com/v1 10 | 11 | # This is needed for Gmail access 12 | GOOGLE_API_KEY=MY-API-KEY 13 | DEFAULT_CLIENT_SECRETS_FILE=.google_credentials.json 14 | 15 | # This is needed if you want to enable google searches 16 | SERPER_API_KEY=MY_API_KEY 17 | 18 | # Needed if you want to enable WolframAlpha 19 | WOLFRAM_ALPHA_APPID=MY-APP-ID 20 | 21 | # It is highly recommended that you use Postgres, but SQLite is supported. 22 | # DATABASE_URL=sqlite://:memory: 23 | DATABASE_URL=postgres://postgres:postgres@localhost:5432/postgres 24 | 25 | # Control log level 26 | LOG_LEVEL=INFO 27 | 28 | # Needed to log end-to-end test results to Baserun for easy debugging, comparison, and evaluation 29 | # Use poetry run pytest --baserun 30 | BASERUN_API_KEY=MY_API_KEY -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .idea/ 2 | scripts/ 3 | tmp/ 4 | .autopack 5 | credentials.json 6 | token.json 7 | .DS_STORE 8 | workspace/ 9 | postgres-data/ 10 | agbenchmark/reports 11 | 12 | # Byte-compiled / optimized / DLL files 13 | __pycache__/ 14 | *.py[cod] 15 | *$py.class 16 | 17 | # C extensions 18 | *.so 19 | 20 | # Distribution / packaging 21 | .Python 22 | build/ 23 | develop-eggs/ 24 | dist/ 25 | downloads/ 26 | eggs/ 27 | .eggs/ 28 | lib/ 29 | lib64/ 30 | parts/ 31 | sdist/ 32 | var/ 33 | wheels/ 34 | pip-wheel-metadata/ 35 | share/python-wheels/ 36 | *.egg-info/ 37 | .installed.cfg 38 | *.egg 39 | MANIFEST 40 | 41 | # PyInstaller 42 | # Usually these files are written by a python script from a template 43 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 44 | *.manifest 45 | *.spec 46 | 47 | # Installer logs 48 | pip-log.txt 49 | pip-delete-this-directory.txt 50 | 51 | # Unit test / coverage reports 52 | htmlcov/ 53 | .tox/ 54 | .nox/ 55 | .coverage 56 | .coverage.* 57 | .cache 58 | nosetests.xml 59 | coverage.xml 60 | *.cover 61 | *.py,cover 62 | .hypothesis/ 63 | .pytest_cache/ 64 | 65 | # Translations 66 | *.mo 67 | *.pot 68 | 69 | # Django stuff: 70 | *.log 71 | local_settings.py 72 | db.sqlite3 73 | db.sqlite3-journal 74 | 75 | # Flask stuff: 76 | instance/ 77 | .webassets-cache 78 | 79 | # Scrapy stuff: 80 | .scrapy 81 | 82 | # Sphinx documentation 83 | docs/_build/ 84 | 85 | # PyBuilder 86 | target/ 87 | 88 | # Jupyter Notebook 89 | .ipynb_checkpoints 90 | 91 | # IPython 92 | profile_default/ 93 | ipython_config.py 94 | 95 | # pyenv 96 | .python-version 97 | 98 | # pipenv 99 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 100 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 101 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 102 | # install all needed dependencies. 103 | #Pipfile.lock 104 | 105 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow 106 | __pypackages__/ 107 | 108 | # Celery stuff 109 | celerybeat-schedule 110 | celerybeat.pid 111 | 112 | # SageMath parsed files 113 | *.sage.py 114 | 115 | # Environments 116 | .env 117 | .venv 118 | env/ 119 | venv/ 120 | ENV/ 121 | env.bak/ 122 | venv.bak/ 123 | 124 | # Spyder project settings 125 | .spyderproject 126 | .spyproject 127 | 128 | # Rope project settings 129 | .ropeproject 130 | 131 | # mkdocs documentation 132 | /site 133 | 134 | # mypy 135 | .mypy_cache/ 136 | .dmypy.json 137 | dmypy.json 138 | 139 | # Pyre type checker 140 | .pyre/ 141 | 142 | # templates 143 | .github/templates/* 144 | !/benchmarker/agent/beebot/ 145 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Erik Peterson 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # BeeBot 2 | 3 | BeeBot is your personal worker bee, an Autonomous AI Assistant designed to perform a wide range of practical tasks 4 | autonomously. 5 | 6 |

7 | BeeBot Mascot 8 |

9 | 10 | ## Status 11 | 12 | Development of BeeBot is currently on hold. I've decided that LLMs as they are now (late 2023) aren't up to the task of generalized autonomous AI. I will revive the project if either: 13 | 14 | - LLMs get significantly better at structured thinking, reliable outcomes, and obeying instructions 15 | - I can develop or fine tune a custom model which is trained specifically for Autonomous AI 16 | - I figure out a particular subset of tasks that BeeBot is acceptably good at that I can focus on. (Hint: It's not coding) 17 | 18 | Check back here, hopefully this will get re-started. 19 | 20 | ## Features 21 | 22 | - Tool selection via [AutoPack](https://autopack.ai) and the ability to acquire more tools during task execution 23 | - Built-in persistence 24 | - REST API conforming to the [e2b](https://www.e2b.dev/) standard. 25 | - A websocket server to publish all events that occur within BeeBot 26 | - Swappable filesystem emulation so that files can be stored in-memory, on-disk, or in a database 27 | - A Web UI for managing your tasks (coming very soon) 28 | - Dynamic manipulation of history during task execution 29 | - Built-in caching with [Helicone](https://www.helicone.ai/) if enabled. 30 | 31 | ## Installation 32 | 33 | To get started with BeeBot, you can clone the repo to your local machine and install its dependencies using `poetry`. 34 | These instructions may vary depending on your local development environment. 35 | 36 | ```bash 37 | git clone https://github.com/AutoPackAI/beebot.git 38 | cd beebot 39 | ./setup.sh 40 | ``` 41 | 42 | Windows is officially unsupported but it may work. PRs are welcome for Windows compatibility but will not be a primary 43 | focus. 44 | 45 | ### Persistence 46 | 47 | Persistence is _required_. While SQLite is officially supported and is used in tests, it is highly recommended that 48 | you use Postgres via docker, simply by executing `docker compose up -d`. 49 | 50 | ## Running 51 | 52 | ### CLI 53 | 54 | To use the CLI run: 55 | 56 | ```bash 57 | poetry run beebot 58 | ``` 59 | 60 | ### API (via [e2b](https://www.e2b.dev/)) 61 | 62 | To start the server run: 63 | 64 | ```bash 65 | uvicorn beebot.initiator.api:create_app --factory --timeout-keep-alive=300 66 | ``` 67 | 68 | If you're doing development on BeeBot itself, you may want to use this command: 69 | 70 | ```bash 71 | uvicorn beebot.initiator.api:create_app --factory --reload --timeout-graceful-shutdown=3 --timeout-keep-alive=300 72 | ``` 73 | 74 | and then you can call the API using the following commands: 75 | 76 | To **create a task** run: 77 | 78 | ```bash 79 | curl --request POST \ 80 | --url http://localhost:8000/agent/tasks \ 81 | --header 'Content-Type: application/json' \ 82 | --data '{ 83 | "input": "Write '\''hello world'\'' to hi.txt" 84 | }' 85 | ``` 86 | 87 | You will get a response like this: 88 | 89 | ```json 90 | { 91 | "input": "Write 'hello world' to hi.txt", 92 | "task_id": "103", 93 | "artifacts": [] 94 | } 95 | ``` 96 | 97 | Then to **execute one step of the task** copy the `task_id` you got from the previous request and run: 98 | 99 | ```bash 100 | curl --request POST \ 101 | --url http://localhost:8000/agent/tasks//steps 102 | ``` 103 | 104 | ### Websocket Connection 105 | 106 | _Note: Notifications are currently undergoing a rework and may not work at the moment_ 107 | 108 | To receive a stream of changes to all the data models in BeeBot, you can subscribe to the websocket connection at 109 | the `/notifications` endpoint with the same host/port as the web api, e.g. ws://localhost:8000/notifications. Use your 110 | favorite websocket testing tool to try it out. (I like [Insomnia](https://insomnia.rest/)) 111 | 112 | ### Web Interface 113 | 114 | We are working on a web interface using Node.js (Remix) 115 | 116 | ## Philosophy 117 | 118 | BeeBot's development process is guided by a specific philosophy, emphasizing key principles that shape its development 119 | and future direction. 120 | 121 | ### Priorities 122 | 123 | The development of BeeBot is driven by the following priorities, always in this order: 124 | 125 | 1. Functionality: BeeBot aims to achieve a high success rate for tasks within its range of _expected_ capabilities. 126 | 2. Flexibility: BeeBot strives to be adaptable to a wide range of tasks, expanding that range over time. 127 | 3. Reliability: BeeBot focuses on reliably completing known tasks with predictability. 128 | 4. Efficiency: BeeBot aims to execute tasks with minimal steps, optimizing both time and resource usage. 129 | 5. Convenience: BeeBot aims to provide a user-friendly platform for task automation. 130 | 131 | ### Principles 132 | 133 | To achieve these priorities, BeeBot follows the following principles: 134 | 135 | - Tool-focused: BeeBot carefully selects and describes tools, ensuring their reliable use by LLMs. It 136 | uses [AutoPack](https://autopack.ai) as the package manager for its tools. 137 | - LLM specialization: BeeBot will leverage a variety of LLMs best suited for different tasks, while OpenAI remains the 138 | primary LLM for planning and decision-making. 139 | - Functionality and flexibility first: BeeBot prioritizes functionality and flexibility over developer quality-of-life, 140 | which may limit support for specific platforms and other deployment conveniences. 141 | - Unorthodox methodologies: BeeBot employs unconventional development approaches to increase development speed, such as 142 | the absence of unit tests. Instead, end-to-end tests are used, ensuring the entire system works together as expected. 143 | - Proven concepts: BeeBot adopts new concepts only after they have been proven to enhance its five priorities. 144 | As a result, it does not have complex memory or a tree of thought. 145 | 146 | ## Documentation 147 | 148 | For further information on the architecture and future plans of BeeBot, please refer to the `docs/` directory. The 149 | documentation is currently very light, but will evolve alongside the project as new insights and developments emerge. 150 | Contributions and feedback from the community are highly appreciated. 151 | -------------------------------------------------------------------------------- /agbenchmark/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/agbenchmark/__init__.py -------------------------------------------------------------------------------- /agbenchmark/benchmarks.py: -------------------------------------------------------------------------------- 1 | import uvicorn 2 | from beebot.initiator.api import create_app 3 | 4 | 5 | if __name__ == "__main__": 6 | uvicorn.run(create_app()) 7 | -------------------------------------------------------------------------------- /agbenchmark/config.json: -------------------------------------------------------------------------------- 1 | { 2 | "workspace": "workspace", 3 | "api_mode": true, 4 | "host": "http://localhost:8000" 5 | } 6 | -------------------------------------------------------------------------------- /beebot/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/__init__.py -------------------------------------------------------------------------------- /beebot/agents/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "BaseAgent", 3 | "ResearchAgent", 4 | "GeneralistAgent", 5 | "CodingAgent", 6 | ] 7 | 8 | from beebot.agents.base_agent import BaseAgent 9 | from beebot.agents.coding_agent import CodingAgent 10 | from beebot.agents.generalist_agent import GeneralistAgent 11 | from beebot.agents.research_agent import ResearchAgent 12 | -------------------------------------------------------------------------------- /beebot/agents/base_agent.py: -------------------------------------------------------------------------------- 1 | import json 2 | from typing import TYPE_CHECKING 3 | 4 | from beebot.body.pack_utils import functions_detail_list 5 | from beebot.planner.planning_prompt import planning_prompt_template 6 | 7 | if TYPE_CHECKING: 8 | from beebot.execution.task_execution import TaskExecution 9 | 10 | 11 | class BaseAgent: 12 | NAME = "" 13 | PACKS = [] 14 | DESCRIPTION = "" 15 | 16 | def __init__(self, task_execution: "TaskExecution"): 17 | self.task_execution = task_execution 18 | 19 | @property 20 | def planning_prompt_template(self) -> str: 21 | return planning_prompt_template() 22 | 23 | @property 24 | def variables(self) -> dict[str, list[str]]: 25 | return self.task_execution.variables 26 | 27 | async def prompt_kwargs(self) -> dict[str, str]: 28 | task = self.task_execution.instructions 29 | variables = self.compile_variables() 30 | 31 | files = [] 32 | for document in await self.task_execution.body.file_manager.all_documents(): 33 | files.append(document.name) 34 | 35 | functions = functions_detail_list(self.task_execution.packs.values()) 36 | history = await self.task_execution.compile_history() 37 | 38 | if files: 39 | file_list = ", ".join(files) 40 | file_section = f"\n# Files\n## The AI Assistant has access to the following files: {file_list}" 41 | else: 42 | file_section = "" 43 | 44 | if history: 45 | history_section = ( 46 | "# History:\n## The AI Assistant has a history of functions that the AI Assistant has already executed for this " 47 | f"task. Here is the history, in order, starting with the first function executed:\n{history}" 48 | ) 49 | else: 50 | history_section = "" 51 | 52 | return { 53 | "task": task, 54 | "functions": functions, 55 | "file_list": file_section, 56 | "variables": variables, 57 | "history": history_section, 58 | } 59 | 60 | async def planning_prompt(self) -> tuple[str, dict[str, str]]: 61 | prompt_variables = await self.prompt_kwargs() 62 | return ( 63 | self.planning_prompt_template.format(**prompt_variables), 64 | prompt_variables, 65 | ) 66 | 67 | def compile_variables(self) -> str: 68 | variable_table = [] 69 | 70 | for value, names in self.variables.items(): 71 | if value: 72 | name_equals = " = ".join(names) 73 | variable_row = f'{name_equals} = """{value}"""' 74 | variable_table.append(variable_row) 75 | 76 | for name, value in self.task_execution.body.global_variables.items(): 77 | if value: 78 | variable_row = f'global {name} = """{value}"""' 79 | variable_table.append(variable_row) 80 | 81 | if not variable_table: 82 | return "" 83 | 84 | header = ( 85 | "\n# Variables\n## The AI Assistant has access to these variables. Variables are local unless explicitly " 86 | 'declared as global. Each variable is a string with the value enclosed in triple quotes ("""):\n' 87 | ) 88 | return header + "\n".join(variable_table) 89 | 90 | async def compile_history(self) -> str: 91 | if not self.task_execution.steps: 92 | return "" 93 | 94 | step_table = [] 95 | used_variables = [] 96 | 97 | for step in self.task_execution.steps: 98 | if not step.observation: 99 | continue 100 | 101 | outcome = step.observation.response 102 | 103 | variable_names = self.variables.get(outcome) 104 | try: 105 | variable_name = next( 106 | name for name in variable_names if name not in used_variables 107 | ) 108 | except StopIteration: 109 | variable_name = variable_names[-1] 110 | 111 | used_variables.append(variable_name) 112 | 113 | tool_arg_list = [ 114 | f"{name}={json.dumps(value)}" 115 | for name, value in step.decision.tool_args.items() 116 | ] 117 | tool_args = ", ".join(tool_arg_list) 118 | 119 | if outcome and outcome in self.variables: 120 | step_table.append( 121 | f">>> {variable_name} = {step.decision.tool_name}({tool_args})" 122 | ) 123 | else: 124 | step_table.append(f">>> {step.decision.tool_name}({tool_args})") 125 | 126 | if not step.observation.success or outcome.startswith("Error"): 127 | step_table.append(step.observation.error_reason or outcome) 128 | else: 129 | step_table.append( 130 | f"Success! Result stored in local variable {variable_name}." 131 | ) 132 | 133 | return "\n".join(step_table) 134 | -------------------------------------------------------------------------------- /beebot/agents/coding_agent.py: -------------------------------------------------------------------------------- 1 | from beebot.agents.base_agent import BaseAgent 2 | from beebot.packs import ( 3 | ExecutePythonFile, 4 | ExecutePythonFileInBackground, 5 | ListProcesses, 6 | KillProcess, 7 | InstallPythonPackage, 8 | Exit, 9 | ExportVariable, 10 | ) 11 | from beebot.packs.filesystem.read_file import ReadFile 12 | from beebot.packs.filesystem.write_file import WriteFile 13 | from beebot.packs.http_request.http_request import HttpRequest 14 | from beebot.packs.write_python_code.write_python_file import WritePythonCode 15 | 16 | 17 | class CodingAgent(BaseAgent): 18 | NAME = "Coding Agent" 19 | DESCRIPTION = "Coding Agent: Excels at writing Python code, executing Python code, and managing processes." 20 | PACKS = [ 21 | ExecutePythonFile, 22 | ExecutePythonFileInBackground, 23 | ListProcesses, 24 | KillProcess, 25 | InstallPythonPackage, 26 | WritePythonCode, 27 | HttpRequest, 28 | WriteFile, 29 | ReadFile, 30 | Exit, 31 | ExportVariable, 32 | ] 33 | 34 | @property 35 | def planning_prompt_template(self): 36 | return PLANNING_PROMPT_TEMPLATE 37 | 38 | async def prompt_kwargs(self) -> dict[str, str]: 39 | kwargs = await super().prompt_kwargs() 40 | kwargs.pop("file_list") 41 | 42 | structure = [] 43 | documents = await self.task_execution.body.file_manager.all_documents() 44 | documents.sort(key=lambda d: d.name) 45 | for document in documents: 46 | indent_count = len(document.name.split("/")) 47 | structure.append(f"{' ' * indent_count}- {document.name}") 48 | 49 | if structure: 50 | kwargs["file_list"] = "\n# Project structure\n" + "\n".join(structure) 51 | else: 52 | kwargs["file_list"] = "" 53 | return {**kwargs} 54 | 55 | 56 | PLANNING_PROMPT_TEMPLATE = """As the Coding Strategist for an AI Assistant, your role is to strategize and plan coding tasks efficiently and effectively. Avoid redundancy, such as unnecessary immediate verification of actions. You excel at writing Python code and can write entire programs at once without using placeholders. 57 | 58 | STDIN / `input()` is not available, take input from argv instead. STDOUT / `print()` is not available, instead save output to files or global variables. 59 | 60 | # Functions 61 | ## The AI Assistant can call only these functions: 62 | {functions}. 63 | 64 | When calling functions provide full data in arguments and avoid using placeholders. 65 | 66 | # Task 67 | ## Your task is: 68 | {task} 69 | 70 | Once the task has been completed, instruct the AI Assistant to call the `exit` function with all arguments to indicate the completion of the task. 71 | 72 | {history} 73 | {variables} 74 | {file_list} 75 | 76 | # Instructions 77 | Now, devise a concise and adaptable coding plan to guide the AI Assistant. Follow these guidelines: 78 | 79 | 1. Ensure you interpret the execution history correctly while considering the order of execution. Trust the accuracy of past function executions. 80 | 2. Recognize when the task has been successfully completed. If the task has been completed, instruct the AI Assistant to call the `exit` function. 81 | 3. Analyze the task carefully to distinguish between actions that the code should take and actions that the AI Assistant should take. 82 | 4. Determine the most efficient next action towards completing the task, considering your current information, requirements, and available functions. 83 | 5. Direct the execution of the immediate next action using exactly one of the callable functions, making sure to provide all necessary code without placeholders. 84 | 85 | Provide a concise analysis of the past history, followed by an overview of your plan going forward, and end with one sentence describing the immediate next action to be taken.""" 86 | -------------------------------------------------------------------------------- /beebot/agents/generalist_agent.py: -------------------------------------------------------------------------------- 1 | from beebot.agents import BaseAgent 2 | from beebot.packs import GetWebsiteTextContent, ExportVariable, Exit 3 | from beebot.packs.disk_usage import DiskUsage 4 | from beebot.packs.filesystem.read_file import ReadFile 5 | from beebot.packs.filesystem.write_file import WriteFile 6 | from beebot.packs.google_search import GoogleSearch 7 | from beebot.packs.http_request.http_request import HttpRequest 8 | from beebot.packs.os_info.os_info import OSInfo 9 | from beebot.packs.wikipedia_summarize import WikipediaPack 10 | 11 | 12 | class GeneralistAgent(BaseAgent): 13 | NAME = "Generalist Agent" 14 | PACKS = [ 15 | WikipediaPack, 16 | GoogleSearch, 17 | GetWebsiteTextContent, 18 | OSInfo, 19 | DiskUsage, 20 | HttpRequest, 21 | WriteFile, 22 | ReadFile, 23 | Exit, 24 | ExportVariable, 25 | ] 26 | DESCRIPTION = "" 27 | -------------------------------------------------------------------------------- /beebot/agents/research_agent.py: -------------------------------------------------------------------------------- 1 | from beebot.agents.base_agent import BaseAgent 2 | from beebot.packs import GetWebsiteTextContent, Exit, ExportVariable 3 | from beebot.packs.filesystem.read_file import ReadFile 4 | from beebot.packs.filesystem.write_file import WriteFile 5 | from beebot.packs.google_search import GoogleSearch 6 | from beebot.packs.wikipedia_summarize import WikipediaPack 7 | from beebot.packs.wolframalpha_query import WolframAlphaQuery 8 | 9 | 10 | class ResearchAgent(BaseAgent): 11 | NAME = "Research Agent" 12 | DESCRIPTION = "Research Agent: Excels at searching for sources of information and analyzing those sources" 13 | PACKS = [ 14 | WikipediaPack, 15 | GoogleSearch, 16 | GetWebsiteTextContent, 17 | WolframAlphaQuery, 18 | WriteFile, 19 | ReadFile, 20 | Exit, 21 | ExportVariable, 22 | ] 23 | 24 | @property 25 | def planning_prompt_template(self): 26 | return PLANNING_PROMPT_TEMPLATE 27 | 28 | 29 | PLANNING_PROMPT_TEMPLATE = """As the Research Strategist for an AI Assistant, your role is to strategize and plan research tasks efficiently and effectively. Avoid redundancy, such as unnecessary immediate verification of actions. 30 | 31 | # Functions 32 | # The AI Assistant have these functions at their disposal: 33 | {functions}. 34 | 35 | When calling functions provide sufficient detail in arguments to ensure the data returned is unambiguously related to your research task. 36 | 37 | # Task 38 | ## The research task is: 39 | {task} 40 | 41 | Once the research task has been completed, instruct the AI Assistant to call the `exit` function with all arguments to indicate the completion of the research. 42 | 43 | {history} 44 | {variables} 45 | {file_list} 46 | 47 | # Instructions 48 | Now, devise a concise and adaptable research plan to guide the AI Assistant. Follow these guidelines: 49 | 50 | 1. Ensure you interpret the execution history correctly while considering the order of execution. Avoid repetitive actions, especially when the outcomes are clear and confirmed by the previous functions. Trust the accuracy of past function executions, assuming the state of the system and your research workspace remain consistent with the historical outcomes. 51 | 2. Recognize when the research task has been successfully completed. If the task has been completed, instruct the AI Assistant to call the `exit` function. 52 | 3. Determine the next logical step in the research task, considering your current information, requirements, and available functions. 53 | 4. Direct the execution of the immediate next action using exactly one of the available functions, making sure to skip any redundant actions that are already confirmed by the historical context. 54 | 55 | Provide a concise analysis of the past history, followed by a step-by-step summary of your plan going forward, and end with one sentence describing the immediate next action to be taken.""" 56 | -------------------------------------------------------------------------------- /beebot/api/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/api/__init__.py -------------------------------------------------------------------------------- /beebot/api/routes.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from fastapi import HTTPException 4 | from starlette.requests import Request 5 | from starlette.responses import JSONResponse 6 | 7 | from beebot.body import Body 8 | from beebot.execution import Step 9 | from beebot.models.database_models import BodyModel, StepModel 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | async def body_response(body: Body) -> JSONResponse: 15 | artifacts = [ 16 | {"name": document.name, "content": document.content} 17 | for document in await body.file_manager.all_documents() 18 | ] 19 | return JSONResponse( 20 | { 21 | "task_id": str(body.model_object.id), 22 | "input": body.task, 23 | "artifacts": artifacts, 24 | } 25 | ) 26 | 27 | 28 | async def step_response(step: Step, body: Body) -> JSONResponse: 29 | artifacts = [ 30 | {"name": document.name, "content": document.content} 31 | for document in await body.file_manager.all_documents() 32 | ] 33 | step_output = { 34 | "plan": step.plan.json(), 35 | "decision": step.decision.json(), 36 | "observation": step.observation.json(), 37 | "reversible": step.reversible, 38 | } 39 | return JSONResponse( 40 | { 41 | "step_id": str(step.model_object.id), 42 | "task_id": str(body.model_object.id), 43 | "output": step_output, 44 | "artifacts": artifacts, 45 | "is_last": body.is_done, 46 | } 47 | ) 48 | 49 | 50 | async def create_agent_task(request: Request) -> JSONResponse: 51 | request_data = await request.json() 52 | body = Body(request_data.get("input")) 53 | await body.setup() 54 | 55 | return await body_response(body) 56 | 57 | 58 | async def execute_agent_task_step(request: Request) -> JSONResponse: 59 | task_id = request.path_params.get("task_id") 60 | try: 61 | body_model = await BodyModel.get(id=int(task_id)).prefetch_related( 62 | BodyModel.INCLUSIVE_PREFETCH 63 | ) 64 | except ValueError: 65 | logger.error(f"Body ID {task_id} is invalid") 66 | raise HTTPException(status_code=404, detail="Invalid Task ID") 67 | 68 | if not body_model: 69 | logger.error(f"Body with ID {task_id} not found") 70 | raise HTTPException(status_code=404, detail="Task not found") 71 | 72 | body = await Body.from_model(body_model) 73 | step = await body.cycle() 74 | if not step: 75 | raise HTTPException(status_code=400, detail="Task is complete") 76 | 77 | return await step_response(step, body) 78 | 79 | 80 | async def agent_task_ids(request: Request) -> JSONResponse: 81 | bodies = await BodyModel.filter() 82 | return JSONResponse([str(body.id) for body in bodies]) 83 | 84 | 85 | async def get_agent_task(request: Request) -> JSONResponse: 86 | task_id = request.path_params.get("task_id") 87 | body_model = await BodyModel.get(id=int(task_id)).prefetch_related( 88 | BodyModel.INCLUSIVE_PREFETCH 89 | ) 90 | 91 | if not body_model: 92 | raise HTTPException(status_code=400, detail="Task not found") 93 | 94 | return await body_response(await Body.from_model(body_model)) 95 | 96 | 97 | async def list_agent_task_steps(request: Request) -> JSONResponse: 98 | task_id = request.path_params.get("task_id") 99 | body_model = await BodyModel.get(id=int(task_id)).prefetch_related( 100 | BodyModel.INCLUSIVE_PREFETCH 101 | ) 102 | body = await Body.from_model(body_model) 103 | 104 | if not body_model: 105 | raise HTTPException(status_code=400, detail="Task not found") 106 | 107 | step_ids = [ 108 | m.id for m in await body.current_task_execution.model_object.steps.all() 109 | ] 110 | 111 | return JSONResponse(step_ids) 112 | 113 | 114 | async def get_agent_task_step(request: Request) -> JSONResponse: 115 | task_id = request.path_params.get("task_id") 116 | body_model = await BodyModel.get(id=int(task_id)).prefetch_related( 117 | BodyModel.INCLUSIVE_PREFETCH 118 | ) 119 | 120 | if not body_model: 121 | raise HTTPException(status_code=400, detail="Task not found") 122 | 123 | step_id = request.path_params.get("step_id") 124 | step_model = await StepModel.get(id=int(step_id)) 125 | 126 | if not step_model: 127 | raise HTTPException(status_code=400, detail="Step not found") 128 | 129 | body = await Body.from_model(body_model) 130 | step = await Step.from_model(step_model) 131 | return await step_response(step, body) 132 | -------------------------------------------------------------------------------- /beebot/api/websocket.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import json 3 | import logging 4 | from json import JSONDecodeError 5 | from typing import Any 6 | 7 | import psycopg2 8 | from psycopg2._psycopg import connection 9 | from starlette.websockets import WebSocket 10 | 11 | from beebot.config import Config 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | 16 | async def producer(conn: connection) -> dict[str, dict[str, Any]]: 17 | while True: 18 | conn.poll() 19 | if conn.notifies: 20 | notify = conn.notifies.pop(0) 21 | try: 22 | parsed_payload = json.loads(notify.payload) 23 | return {notify.channel: parsed_payload} 24 | except JSONDecodeError as e: 25 | logger.error(f"Invalid NOTIFY payload received {e}: {notify.payload}") 26 | 27 | await asyncio.sleep(0.1) 28 | 29 | 30 | async def websocket_endpoint(websocket: WebSocket): 31 | await websocket.accept() 32 | config = Config.global_config() 33 | conn = psycopg2.connect(config.database_url) 34 | conn.set_isolation_level(psycopg2.extensions.ISOLATION_LEVEL_AUTOCOMMIT) 35 | 36 | curs = conn.cursor() 37 | curs.execute("LISTEN beebot_notifications;") 38 | await websocket.send_json({"ready": True}) 39 | while True: 40 | try: 41 | await websocket.send_json({"poll": True}) 42 | notify = await producer(conn) 43 | if not notify: 44 | # Connection closed 45 | break 46 | 47 | await websocket.send_json(notify) 48 | 49 | except (SystemExit, KeyboardInterrupt): 50 | raise 51 | except BaseException as e: 52 | logger.error(f"Unknown error occurred in websocket connection: {e}") 53 | await asyncio.sleep(0.1) 54 | -------------------------------------------------------------------------------- /beebot/body/__init__.py: -------------------------------------------------------------------------------- 1 | from .body import Body 2 | 3 | __all__ = ["Body"] 4 | -------------------------------------------------------------------------------- /beebot/body/body.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os.path 3 | import subprocess 4 | from typing import Union 5 | 6 | import baserun 7 | from langchain.chat_models.base import BaseChatModel 8 | 9 | from beebot.body.llm import create_llm 10 | from beebot.config import Config 11 | from beebot.config.database_file_manager import DatabaseFileManager 12 | from beebot.decomposer.decomposer import Decomposer 13 | from beebot.execution import Step 14 | from beebot.execution.task_execution import TaskExecution 15 | from beebot.models.database_models import ( 16 | BodyModel, 17 | initialize_db, 18 | ) 19 | 20 | logger = logging.getLogger(__name__) 21 | 22 | 23 | class Body: 24 | task: str 25 | processes: dict[int, subprocess.Popen] 26 | # Variables set / exported by subtasks 27 | global_variables: dict[str, str] 28 | 29 | task_executions = list[TaskExecution] 30 | 31 | decomposer_llm: BaseChatModel 32 | planner_llm: BaseChatModel 33 | decider_llm: BaseChatModel 34 | 35 | decomposer: Decomposer 36 | config: Config 37 | 38 | model_object: BodyModel = None 39 | file_manager: DatabaseFileManager = None 40 | 41 | def __init__(self, task: str = "", config: Config = None): 42 | self.task = task 43 | self.config = config or Config.global_config() 44 | 45 | self.decomposer_llm = create_llm(self.config, self.config.decomposer_model) 46 | self.planner_llm = create_llm(self.config, self.config.planner_model) 47 | self.decider_llm = create_llm(self.config, self.config.decider_model) 48 | self.decomposer = Decomposer(body=self) 49 | self.task_executions = [] 50 | self.processes = {} 51 | self.global_variables = {} 52 | 53 | if not os.path.exists(self.config.workspace_path): 54 | os.makedirs(self.config.workspace_path, exist_ok=True) 55 | 56 | @classmethod 57 | async def from_model(cls, body_model: BodyModel): 58 | body = cls(task=body_model.task) 59 | body.model_object = body_model 60 | 61 | body.file_manager = DatabaseFileManager( 62 | config=body.config.pack_config, body=body 63 | ) 64 | 65 | for execution_model in await body_model.task_executions.all(): 66 | body.task_executions.append( 67 | await TaskExecution.from_model(body, execution_model) 68 | ) 69 | 70 | await body.setup_file_manager() 71 | 72 | return body 73 | 74 | @property 75 | def current_task_execution(self) -> Union[TaskExecution, None]: 76 | try: 77 | return next( 78 | execution 79 | for execution in self.task_executions 80 | if not execution.complete 81 | ) 82 | except StopIteration: 83 | return None 84 | 85 | @property 86 | def is_done(self): 87 | return self.current_task_execution is None 88 | 89 | async def setup(self): 90 | """These are here instead of init because they involve network requests. The order is very specific because 91 | of when the database and file manager are instantiated / set up""" 92 | # TODO: Remove duplication between this method and `from_model` 93 | await initialize_db(self.config.database_url) 94 | 95 | self.file_manager = DatabaseFileManager( 96 | config=self.config.pack_config, body=self 97 | ) 98 | 99 | await self.decompose_task() 100 | 101 | if not self.model_object: 102 | self.model_object = BodyModel(task=self.task) 103 | await self.save() 104 | 105 | await self.current_task_execution.create_new_step() 106 | 107 | await self.setup_file_manager() 108 | await self.file_manager.load_from_directory() 109 | 110 | await self.save() 111 | 112 | async def cycle(self) -> Step: 113 | """Step through one decide-execute-plan loop""" 114 | if self.is_done: 115 | return 116 | 117 | task_execution = self.current_task_execution 118 | step = await task_execution.cycle() 119 | 120 | # If this subtask is complete, prime the next subtask 121 | if task_execution.complete: 122 | next_execution = self.current_task_execution 123 | # We're done 124 | if not next_execution: 125 | return None 126 | 127 | if not next_execution.current_step: 128 | await next_execution.create_new_step() 129 | 130 | if next_execution: 131 | documents = await task_execution.current_step.documents 132 | for name, document in documents.items(): 133 | if name in next_execution.inputs: 134 | await next_execution.current_step.add_document(document) 135 | 136 | await self.save() 137 | 138 | baserun.log( 139 | "CycleComplete", 140 | payload={ 141 | "oversight": step.oversight.__dict__ if step.oversight else None, 142 | "plan": step.plan.__dict__ if step.plan else None, 143 | "decision": step.decision.__dict__ if step.decision else None, 144 | "observation": step.observation.__dict__ if step.observation else None, 145 | }, 146 | ) 147 | 148 | return step 149 | 150 | async def decompose_task(self): 151 | """Turn the initial task into a task that is easier for AI to more consistently understand""" 152 | subtasks = await self.decomposer.decompose() 153 | for subtask in subtasks: 154 | execution = TaskExecution( 155 | body=self, 156 | agent_name=subtask.agent, 157 | inputs=subtask.inputs, 158 | outputs=subtask.outputs, 159 | instructions=subtask.instructions, 160 | complete=subtask.complete, 161 | ) 162 | await execution.get_packs() 163 | self.task_executions.append(execution) 164 | 165 | async def save(self): 166 | if not self.model_object: 167 | await self.setup() 168 | 169 | self.model_object.task = self.task 170 | await self.model_object.save() 171 | 172 | await self.current_task_execution.save() 173 | await self.file_manager.flush_to_directory(self.config.workspace_path) 174 | 175 | async def setup_file_manager(self): 176 | if not self.file_manager: 177 | self.file_manager = DatabaseFileManager( 178 | config=self.config.pack_config, body=self 179 | ) 180 | await self.file_manager.load_from_directory() 181 | 182 | self.config.pack_config.filesystem_manager = self.file_manager 183 | -------------------------------------------------------------------------------- /beebot/body/llm.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from dataclasses import dataclass 4 | from typing import TYPE_CHECKING, Any 5 | 6 | import openai 7 | from autopack.utils import format_packs_to_openai_functions 8 | from langchain.chat_models import ChatOpenAI 9 | from langchain.schema import SystemMessage 10 | 11 | from beebot.config.config import Config 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | if TYPE_CHECKING: 16 | from beebot.body import Body 17 | 18 | 19 | @dataclass 20 | class LLMResponse: 21 | text: str 22 | function_call: dict[str, Any] 23 | 24 | 25 | def create_llm(config: Config, model_name: str): 26 | headers = {} 27 | if config.openai_api_base: 28 | openai.api_base = config.openai_api_base 29 | if config.helicone_key: 30 | logger.info("Using helicone to make requests with cache enabled.") 31 | headers["Helicone-Auth"] = f"Bearer {config.helicone_key}" 32 | headers["Helicone-Cache-Enabled"] = "true" 33 | 34 | llm = ChatOpenAI( 35 | model_name=model_name, 36 | # temperature=0, 37 | model_kwargs={"headers": headers, "top_p": 0.1}, 38 | ) 39 | return llm 40 | 41 | 42 | async def call_llm( 43 | body: "Body", 44 | message: str, 45 | function_call: str = "auto", 46 | include_functions: bool = True, 47 | disregard_cache: bool = False, 48 | llm=None, 49 | ) -> LLMResponse: 50 | llm = llm or body.planner_llm 51 | output_kwargs = {} 52 | 53 | if include_functions and body.current_task_execution.packs: 54 | output_kwargs["functions"] = format_packs_to_openai_functions( 55 | body.current_task_execution.packs.values() 56 | ) 57 | output_kwargs["function_call"] = function_call 58 | 59 | if disregard_cache: 60 | output_kwargs["headers"] = {"Helicone-Cache-Enabled": "false"} 61 | 62 | logger.debug(f"~~ LLM Request ~~\n{message}") 63 | response = await llm.agenerate( 64 | messages=[[SystemMessage(content=message)]], **output_kwargs 65 | ) 66 | 67 | # TODO: This should be a nicer error message if we get an unexpected number of generations 68 | generation = response.generations[0][0] 69 | function_called = {} 70 | if ( 71 | generation 72 | and hasattr(generation, "message") 73 | and generation.message.additional_kwargs 74 | ): 75 | function_called = generation.message.additional_kwargs.get("function_call", {}) 76 | 77 | logger.debug(f"~~ LLM Response ~~\n{generation.text}") 78 | logger.debug(json.dumps(function_called)) 79 | return LLMResponse(text=generation.text, function_call=function_called) 80 | -------------------------------------------------------------------------------- /beebot/body/pack_utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import subprocess 3 | from typing import TYPE_CHECKING 4 | 5 | from autopack.pack import Pack 6 | 7 | from beebot.body.llm import call_llm 8 | 9 | if TYPE_CHECKING: 10 | from beebot.body import Body 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | # TODO: This should be a config value? 15 | SUPPRESSED_PACKS = ["list_files", "delete_file"] 16 | 17 | 18 | def llm_wrapper(body: "Body") -> str: 19 | async def llm(prompt) -> str: 20 | response = await call_llm( 21 | body, prompt, include_functions=False, function_call="none" 22 | ) 23 | return response.text 24 | 25 | return llm 26 | 27 | 28 | def init_workspace_poetry(workspace_path: str): 29 | """Make sure poetry is init'd in the workspace. The command errors if it is already init'd so just swallow the 30 | errors""" 31 | subprocess.run( 32 | ["poetry", "init", "--name", "beebot_workspace", "-n"], 33 | cwd=workspace_path, 34 | stdout=subprocess.PIPE, 35 | stderr=subprocess.PIPE, 36 | ) 37 | 38 | 39 | def functions_detail_list(packs: list["Pack"]) -> str: 40 | pack_details = [] 41 | for pack in packs: 42 | pack_args = [] 43 | for arg_data in pack.args.values(): 44 | arg_type = arg_data.get("type") 45 | if arg_type == "string": 46 | arg_type = "str" 47 | if arg_type == "boolean": 48 | arg_type = "bool" 49 | if arg_type == "number": 50 | arg_type = "int" 51 | pack_args.append(f"{arg_data.get('name')}: {arg_type}") 52 | 53 | pack_details.append(f"{pack.name}({', '.join(pack_args)})") 54 | 55 | return "\n".join(pack_details) 56 | -------------------------------------------------------------------------------- /beebot/config/__init__.py: -------------------------------------------------------------------------------- 1 | from .config import Config 2 | 3 | __all__ = ["Config"] 4 | -------------------------------------------------------------------------------- /beebot/config/config.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import ClassVar 4 | 5 | import coloredlogs 6 | from autopack.pack_config import PackConfig 7 | from openai.util import logger as openai_logger 8 | from pydantic import BaseSettings # IDEAL_MODEL = "gpt-4-0613" 9 | import openai 10 | 11 | DEFAULT_DECOMPOSER_MODEL = "gpt-4" 12 | FALLBACK_DECOMPOSER_MODEL = "gpt-3.5-turbo-16k-0613" 13 | DEFAULT_PLANNER_MODEL = "gpt-3.5-turbo-16k-0613" 14 | DEFAULT_DECIDER_MODEL = "gpt-3.5-turbo-16k-0613" 15 | LOG_FORMAT = ( 16 | "%(levelname)s %(asctime)s.%(msecs)03d %(filename)s:%(lineno)d- %(message)s" 17 | ) 18 | 19 | 20 | class Config(BaseSettings): 21 | log_level: str = "INFO" 22 | 23 | openai_api_key: str = None 24 | helicone_key: str = None 25 | openai_api_base: str = None 26 | gmail_credentials_file: str = "credentials.json" 27 | decomposer_model: str = DEFAULT_DECOMPOSER_MODEL 28 | planner_model: str = DEFAULT_PLANNER_MODEL 29 | decider_model: str = DEFAULT_DECIDER_MODEL 30 | database_url: str = "sqlite://:memory:" 31 | 32 | workspace_path: str = "workspace" 33 | hard_exit: bool = False 34 | restrict_code_execution: bool = False 35 | process_timeout: int = 30 36 | pack_config: PackConfig = None 37 | 38 | _global_config: ClassVar["Config"] = None 39 | 40 | class Config: 41 | env_prefix = "beebot_" 42 | fields = { 43 | "database_url": {"env": "database_url"}, 44 | "gmail_credentials_file": {"env": "gmail_credentials_file"}, 45 | "helicone_key": {"env": "helicone_key"}, 46 | "log_level": {"env": "log_level"}, 47 | "openai_api_base": {"env": "openai_api_base"}, 48 | "openai_api_key": {"env": "openai_api_key"}, 49 | } 50 | 51 | def __init__(self, **kwargs) -> "Config": 52 | super().__init__(**kwargs) 53 | self.configure_autopack() 54 | self.setup_logging() 55 | self.configure_decomposer_model() 56 | 57 | def configure_decomposer_model(self): 58 | if self.decomposer_model == DEFAULT_DECOMPOSER_MODEL: 59 | model_ids = [model["id"] for model in openai.Model.list()["data"]] 60 | if self.decomposer_model not in model_ids: 61 | self.decomposer_model = FALLBACK_DECOMPOSER_MODEL 62 | 63 | def configure_autopack(self, is_global: bool = True): 64 | pack_config = PackConfig( 65 | workspace_path=self.workspace_path, 66 | restrict_code_execution=self.restrict_code_execution, 67 | ) 68 | 69 | self.pack_config = pack_config 70 | 71 | if is_global: 72 | PackConfig.set_global_config(pack_config) 73 | 74 | def setup_logging(self) -> logging.Logger: 75 | os.makedirs("logs", exist_ok=True) 76 | console_handler = logging.StreamHandler() 77 | console_handler.setLevel(self.log_level) 78 | 79 | file_handler = logging.FileHandler("logs/debug.log") 80 | file_handler.setLevel("DEBUG") 81 | file_handler.setFormatter(logging.Formatter(LOG_FORMAT)) 82 | 83 | logging.basicConfig( 84 | level=self.log_level, 85 | format=LOG_FORMAT, 86 | datefmt="%H:%M:%S", 87 | handlers=[ 88 | console_handler, 89 | file_handler, 90 | ], 91 | ) 92 | 93 | coloredlogs.install( 94 | level=self.log_level, 95 | fmt=LOG_FORMAT, 96 | datefmt="%H:%M:%S", 97 | ) 98 | 99 | # OpenAI will log a jsonified version of each request/response to `logger.debug` and we have our own logs 100 | # which are better formatted 101 | openai_logger.propagate = False 102 | 103 | @classmethod 104 | def set_global_config(cls, config_obj: "Config" = None) -> "Config": 105 | """ 106 | Optionally set a global config object that can be used anywhere (You can still attach a separate instance to 107 | each Body) 108 | """ 109 | cls._global_config = config_obj or cls() 110 | return cls._global_config 111 | 112 | @classmethod 113 | def global_config(cls) -> "Config": 114 | return cls._global_config or cls.set_global_config() 115 | -------------------------------------------------------------------------------- /beebot/config/database_file_manager.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | from typing import TYPE_CHECKING, Union 4 | 5 | from autopack.filesystem_emulation.file_manager import FileManager 6 | from autopack.pack_config import PackConfig 7 | 8 | from beebot.execution import Step 9 | from beebot.models.database_models import DocumentModel, DocumentStep 10 | 11 | if TYPE_CHECKING: 12 | from beebot.body import Body 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | IGNORE_FILES = ["poetry.lock", "pyproject.toml", "__pycache__"] 17 | 18 | 19 | class DatabaseFileManager(FileManager): 20 | """ 21 | This class emulates a filesystem in Postgres, storing files in a simple `document` table with a many-to-many 22 | relationship with `execution`. Recommended unless you specifically need to access the documents in the filesystem. 23 | """ 24 | 25 | def __init__( 26 | self, config: PackConfig = PackConfig.global_config(), body: "Body" = None 27 | ): 28 | super().__init__(config) 29 | self.body = body 30 | self.files = {} 31 | 32 | @property 33 | def current_step(self) -> Union[Step, None]: 34 | task = self.body.current_task_execution or self.body.task_executions[-1] 35 | if not task: 36 | return None 37 | try: 38 | return task.steps[-1] 39 | except IndexError: 40 | return None 41 | 42 | # Can't support sync db access without a lot of headaches 43 | def read_file(self, *args, **kwargs): 44 | raise NotImplementedError 45 | 46 | def write_file(self, *args, **kwargs): 47 | raise NotImplementedError 48 | 49 | def delete_file(self, *args, **kwargs): 50 | raise NotImplementedError 51 | 52 | def list_files(self, *args, **kwargs): 53 | raise NotImplementedError 54 | 55 | async def aread_file(self, file_path: str) -> str: 56 | """Reads a file from the virtual file system in RAM. 57 | 58 | Args: 59 | file_path (str): The path to the file to be read. 60 | 61 | Returns: 62 | str: The content of the file. If the file does not exist, returns an error message. 63 | """ 64 | documents = await self.current_step.documents 65 | document = documents.get(file_path) 66 | if document: 67 | return document.content 68 | else: 69 | nonlocal_file = await DocumentModel.get_or_none(name=file_path) 70 | if nonlocal_file: 71 | return nonlocal_file.content 72 | return "Error: File not found" 73 | 74 | async def awrite_file(self, file_path: str, content: str) -> str: 75 | """Writes to a file in the virtual file system in RAM. 76 | 77 | Args: 78 | file_path (str): The path to the file to be written to. 79 | content (str): The content to be written to the file. 80 | 81 | Returns: 82 | str: A success message indicating the file was written. 83 | """ 84 | if not self.current_step: 85 | return "" 86 | 87 | document, _created = await DocumentModel.get_or_create( 88 | name=file_path, content=content 89 | ) 90 | 91 | stale_link = await DocumentStep.filter( 92 | document__name=file_path, step=self.current_step.model_object 93 | ).first() 94 | 95 | if stale_link: 96 | logger.warning(f"Deleting stale link ID {stale_link.id}") 97 | await stale_link.delete() 98 | 99 | await self.current_step.add_document(document) 100 | return f"Successfully wrote {len(content.encode('utf-8'))} bytes to {file_path}" 101 | 102 | async def adelete_file(self, file_path: str) -> str: 103 | """Deletes a file from the virtual file system in RAM. 104 | 105 | Args: 106 | file_path (str): The path to the file to be deleted. 107 | 108 | Returns: 109 | str: A success message indicating the file was deleted. If the file does not exist, returns an error message. 110 | """ 111 | if not self.current_step: 112 | return "" 113 | 114 | document = await DocumentModel.get_or_none(name=file_path) 115 | if not document: 116 | return f"Error: File not found '{file_path}'" 117 | 118 | document_step = await DocumentStep.filter( 119 | document=document, step=self.current_step.model_object 120 | ).first() 121 | 122 | if document_step: 123 | await document.delete() 124 | else: 125 | logger.warning( 126 | f"File {file_path} was supposed to be deleted but it does not exist" 127 | ) 128 | 129 | return f"Successfully deleted file {file_path}." 130 | 131 | async def alist_files(self, dir_path: str) -> str: 132 | """Lists all files in the specified directory in the virtual file system in RAM. 133 | 134 | Args: 135 | dir_path (str): The path to the directory to list files from. 136 | 137 | Returns: 138 | str: A list of all files in the directory. If the directory does not exist, returns an error message. 139 | """ 140 | if not self.current_step: 141 | return "" 142 | 143 | document_steps = await DocumentStep.filter( 144 | step=self.current_step.model_object 145 | ).prefetch_related("document") 146 | 147 | file_paths = [dm.document.name for dm in document_steps] 148 | 149 | files_in_dir = [ 150 | file_path 151 | for file_path in file_paths 152 | if file_path.startswith(dir_path) and file_path not in self.IGNORE_FILES 153 | ] 154 | if files_in_dir: 155 | return "\n".join(files_in_dir) 156 | else: 157 | return f"Error: No such directory {dir_path}." 158 | 159 | async def all_documents(self) -> list[DocumentModel]: 160 | if not self.current_step: 161 | return [] 162 | document_steps = await DocumentStep.filter( 163 | step=self.current_step.model_object 164 | ).prefetch_related("document") 165 | 166 | return [dm.document for dm in document_steps] 167 | 168 | async def load_from_directory(self, directory: str = None): 169 | if not directory: 170 | directory = self.body.config.workspace_path 171 | 172 | for file in os.listdir(directory): 173 | abs_path = os.path.abspath(os.path.join(directory, file.replace("/", "_"))) 174 | if not os.path.isdir(abs_path) and file not in IGNORE_FILES: 175 | with open(abs_path, "r") as f: 176 | await self.awrite_file(file, f.read()) 177 | 178 | async def flush_to_directory(self, directory: str = None): 179 | if not self.current_step: 180 | return 181 | 182 | if not directory: 183 | directory = self.body.config.workspace_path 184 | 185 | all_documents = await self.all_documents() 186 | for document in all_documents: 187 | with open( 188 | os.path.join(directory, document.name.replace("/", "_")), "w+" 189 | ) as f: 190 | f.write(document.content) 191 | -------------------------------------------------------------------------------- /beebot/decider/__init__.py: -------------------------------------------------------------------------------- 1 | from .decider import Decider 2 | 3 | __all__ = ["Decider"] 4 | -------------------------------------------------------------------------------- /beebot/decider/decider.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from json import JSONDecodeError 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from beebot.body.llm import call_llm, LLMResponse 7 | from beebot.body.pack_utils import functions_detail_list 8 | from beebot.decider.deciding_prompt import decider_template 9 | from beebot.models.database_models import Decision, Oversight 10 | 11 | if TYPE_CHECKING: 12 | from beebot.execution.task_execution import TaskExecution 13 | 14 | logger = logging.getLogger(__name__) 15 | 16 | RETRY_LIMIT = 3 17 | 18 | 19 | class Decider: 20 | """ 21 | The Decider is in charge of taking the Plan and deciding the next step 22 | """ 23 | 24 | task_execution: "TaskExecution" 25 | 26 | def __init__(self, task_execution: "TaskExecution"): 27 | self.task_execution = task_execution 28 | 29 | async def decide( 30 | self, oversight: Oversight, disregard_cache: bool = False 31 | ) -> Decision: 32 | """Take a Plan and send it to the LLM, returning it back to the Body""" 33 | # TODO: Get from agent? 34 | prompt_variables = { 35 | "plan": oversight.modified_plan_text, 36 | "task": self.task_execution.instructions, 37 | "variables": self.task_execution.compile_variables(), 38 | "history": await self.task_execution.compile_history(), 39 | "functions": functions_detail_list(self.task_execution.packs.values()), 40 | } 41 | prompt = decider_template().format(**prompt_variables) 42 | 43 | response = await call_llm( 44 | self.task_execution.body, 45 | prompt, 46 | disregard_cache=disregard_cache, 47 | llm=self.task_execution.body.decider_llm, 48 | ) 49 | 50 | logger.info("\n=== Decision received from LLM ===") 51 | if response and response.text: 52 | logger.info(response.text) 53 | logger.info(json.dumps(response.function_call, indent=4)) 54 | 55 | return await interpret_llm_response( 56 | prompt_variables=prompt_variables, response=response 57 | ) 58 | 59 | async def decide_with_retry( 60 | self, oversight: Oversight, retry_count: int = 0 61 | ) -> Decision: 62 | if retry_count: 63 | oversight = Oversight( 64 | prompt_variables=oversight.prompt_variables, 65 | modified_plan_text=oversight.modified_plan_text 66 | + ( 67 | "\n\nWarning: Invalid response received. Please reassess your strategy." 68 | ), 69 | ) 70 | 71 | try: 72 | return await self.decide(oversight, disregard_cache=retry_count > 0) 73 | except ValueError: 74 | logger.warning("Got invalid response from LLM, retrying...") 75 | if retry_count >= RETRY_LIMIT: 76 | raise ValueError(f"Got invalid response {RETRY_LIMIT} times in a row") 77 | return await self.decide_with_retry( 78 | oversight=oversight, retry_count=retry_count + 1 79 | ) 80 | 81 | 82 | async def interpret_llm_response( 83 | prompt_variables: dict[str, str], response: LLMResponse 84 | ) -> Decision: 85 | if response.function_call: 86 | tool_name, tool_args = parse_function_call_args(response.function_call) 87 | 88 | decision = Decision( 89 | reasoning=response.text, 90 | tool_name=tool_name, 91 | tool_args=tool_args, 92 | prompt_variables=prompt_variables, 93 | response=response.text, 94 | ) 95 | await decision.save() 96 | return decision 97 | else: 98 | raise ValueError("No decision supplied") 99 | 100 | 101 | def parse_function_call_args( 102 | function_call_args: dict[str, Any] 103 | ) -> tuple[str, dict[str, Any]]: 104 | if not function_call_args: 105 | raise ValueError("No function given") 106 | 107 | tool_name = function_call_args.get("name") 108 | try: 109 | parsed_tool_args = json.loads(function_call_args.get("arguments")) 110 | return tool_name, parsed_tool_args 111 | except JSONDecodeError: 112 | return tool_name, {"output": function_call_args.get("arguments")} 113 | -------------------------------------------------------------------------------- /beebot/decider/deciding_prompt.py: -------------------------------------------------------------------------------- 1 | TEMPLATE = """You are an Autonomous AI Assistant executor. Your responsibility is to interpret the provided plan and execute the next function. 2 | 3 | # Functions 4 | You have these functions at your disposal: 5 | {functions}. 6 | 7 | # Task 8 | Your original task, given by the human, is: 9 | {task} 10 | 11 | # History 12 | You have a history of functions that the AI Assistant has already executed for this task. Here is the history, in order, starting with the first function executed: 13 | {history} 14 | {variables} 15 | 16 | # Plan 17 | {plan} 18 | 19 | Follow these guidelines: 20 | 1. Study your high-level plan, and understand the next step in it. 21 | 2. Implement the next action by using exactly one of the provided functions. Be sure to fully expand variables and avoid the use of placeholders. 22 | 23 | Proceed with executing the next step from the plan. Use exactly one of the provided functions through the `function_call` parameter of your response.""" 24 | 25 | 26 | def decider_template() -> str: 27 | return TEMPLATE 28 | -------------------------------------------------------------------------------- /beebot/decomposer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/decomposer/__init__.py -------------------------------------------------------------------------------- /beebot/decomposer/decomposer.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | from json import JSONDecodeError 5 | from typing import TYPE_CHECKING 6 | 7 | from pydantic import Field, BaseModel 8 | 9 | from beebot.body.llm import call_llm 10 | from beebot.config.database_file_manager import IGNORE_FILES 11 | from beebot.decomposer.decomposer_prompt import decomposer_prompt 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | if TYPE_CHECKING: 16 | from beebot.body import Body 17 | 18 | 19 | class Subtask(BaseModel): 20 | agent: str 21 | instructions: str 22 | inputs: list[str] = Field(default_factory=list) 23 | outputs: list[str] = Field(default_factory=list) 24 | complete: bool = False 25 | 26 | 27 | class Decomposer: 28 | body: "Body" 29 | task: str 30 | subtasks: list[Subtask] 31 | 32 | def __init__(self, body: "Body"): 33 | self.body = body 34 | self.task = body.task 35 | self.subtasks = [] 36 | 37 | async def decompose(self) -> list[Subtask]: 38 | from beebot.agents import BaseAgent 39 | 40 | prompt_template = decomposer_prompt() 41 | agents = [agent for agent in BaseAgent.__subclasses__() if agent.DESCRIPTION] 42 | agent_list = "- " + "\n- ".join([agent.DESCRIPTION for agent in agents]) 43 | prompt = prompt_template.format( 44 | agent_list=agent_list, task=self.task, files=self.starting_files() 45 | ) 46 | 47 | logger.info("\n=== Task Decomposition given to LLM ===") 48 | logger.info(prompt) 49 | 50 | response = await call_llm( 51 | self.body, 52 | prompt, 53 | include_functions=False, 54 | function_call="none", 55 | llm=self.body.decomposer_llm, 56 | ) 57 | logger.info("\n=== Task Decomposition received from LLM ===") 58 | logger.info(response.text) 59 | 60 | try: 61 | parsed_response = json.loads(response.text) 62 | except JSONDecodeError as e: 63 | logger.error(f"Could not decode response {e}: {response}") 64 | # TODO: Handle error better 65 | raise e 66 | 67 | self.subtasks = [Subtask(**assignment) for assignment in parsed_response] 68 | return self.subtasks 69 | 70 | def starting_files(self) -> str: 71 | directory = self.body.config.workspace_path 72 | 73 | file_list = [] 74 | for file in os.listdir(directory): 75 | abs_path = os.path.abspath(os.path.join(directory, file.replace("/", "_"))) 76 | if not os.path.isdir(abs_path) and file not in IGNORE_FILES: 77 | file_list.append(f"- {file}") 78 | 79 | if not file_list: 80 | return "" 81 | 82 | file_list.sort() 83 | file_list_output = "\n".join(file_list) 84 | return ( 85 | f"**Files**: Each subtask may have access to any of the following files if included in its inputs:\n" 86 | f"{file_list_output}" 87 | ) 88 | -------------------------------------------------------------------------------- /beebot/decomposer/decomposer_prompt.py: -------------------------------------------------------------------------------- 1 | TEMPLATE = """ 2 | **Role**: You are a subtask analyzer AI, and your goal is to analyze a human-provided task into one or more subtasks for specialized AI Agents. Your analysis must be exact and must only include the actions necessary to fulfill the specific requirements stated in the human task. Avoid over-complicating simple tasks. 3 | 4 | **Note**: Follow the human task to the letter. Any deviation or addition outside the task will increase costs. The human task may vary in complexity, so tailor the number of subtask(s) accordingly. 5 | 6 | **Human Task**: `{task}` 7 | 8 | **Agents**: 9 | Agents are Autonomous AI Entities. They will strictly follow your instructions, so be clear and avoid actions that aren't required by the human task. 10 | 11 | The following agents are specialized AI entities, each excelling in a certain area. 12 | {agent_list} 13 | 14 | In addition to these specialized agents, there is a Generalist Agent which can handle a wide variety of subtasks. If a subtask doesn't fit in any area of expertise of the specialized agents, assign it to the Generalist Agent. 15 | 16 | {files} 17 | 18 | **Guidelines**: 19 | 1. **Analyze the Task**: Carefully dissect the human task, identifying the exact requirements without adding or inferring extra actions. 20 | 2. **Develop Instructions**: Create detailed step-by-step instructions for each subtask. To ensure the AI Agent is able to correctly determine when the task is complete, clearly stipulate all required outcomes and establish concrete exit conditions. Each subtask is executed in isolation, without context. If data collected in one subtask must be used in a future subtask, include instructions to export that data as a global variable. Be sure to explicitly include instructions for input files, output files, and variable exports. 21 | 3. **Assign Subtasks**: Detail each subtask and assign it to the most appropriate agent, specifying the input filenames, the instructions for the agent, and the expected output filenames. Do not include subtasks that simply mark task completion or have no specific actions associated with them. 22 | 23 | **Response Format**: 24 | Responses should be valid JSON and should not include any other explanatory text. Example: 25 | [ 26 | {{"agent": "Research Agent", "inputs": ["my_topics.txt"], "outputs": [], "instructions": "Read the file my_topics.txt. Analyze the topics and export your analysis to the `topic_analysis` global variable."}}, 27 | {{"agent": "Generalist Agent", "inputs": [], "outputs": ["topic_summary.txt"], "instructions": "Write the contents of the global variable `topic_analysis` to the file topic_summary.txt"}} 28 | ] 29 | """ 30 | 31 | 32 | def decomposer_prompt() -> str: 33 | return TEMPLATE 34 | -------------------------------------------------------------------------------- /beebot/execution/__init__.py: -------------------------------------------------------------------------------- 1 | from .step import Step 2 | 3 | __all__ = ["Step"] 4 | -------------------------------------------------------------------------------- /beebot/execution/background_process.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import selectors 3 | import subprocess 4 | from threading import Thread 5 | from typing import Optional, TYPE_CHECKING, Union 6 | 7 | if TYPE_CHECKING: 8 | from beebot.body import Body 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class BackgroundProcess: 14 | cmd: list[str] 15 | process: Optional[subprocess.Popen[str]] = None 16 | daemonize: bool = False 17 | output_thread: Thread = None 18 | stdout: str = "" 19 | stderr: str = "" 20 | 21 | def __init__( 22 | self, 23 | body: "Body", 24 | cmd: list[str], 25 | daemonize: bool = False, 26 | ): 27 | self.body = body 28 | self.cmd = cmd 29 | self.daemonize = daemonize 30 | self.process = None 31 | 32 | def run(self): 33 | process = subprocess.Popen( 34 | self.cmd, 35 | stdout=subprocess.PIPE, 36 | stderr=subprocess.PIPE, 37 | universal_newlines=True, 38 | cwd=self.body.config.workspace_path, 39 | start_new_session=self.daemonize, 40 | ) 41 | 42 | self.body.processes[process.pid] = self 43 | self.process = process 44 | 45 | # Create a thread for reading output from process without blocking 46 | self.output_thread = Thread(target=self.stdout_reader) 47 | self.output_thread.start() 48 | return process 49 | 50 | @property 51 | def pid(self) -> Union[int, None]: 52 | if self.process: 53 | return self.process.pid 54 | return None 55 | 56 | def poll(self): 57 | return self.process.poll() 58 | 59 | def kill(self): 60 | self.process.kill() 61 | 62 | @property 63 | def returncode(self) -> int: 64 | return self.process.returncode 65 | 66 | def stdout_reader(self): 67 | def read_stdout(stream): 68 | self.stdout += "\n".join(stream.readlines()) 69 | logger.info(f"STDOUT: {self.stdout}") 70 | 71 | def read_stderr(stream): 72 | self.stderr += "\n".join(stream.readlines()) 73 | logger.info(f"STDERR: {self.stderr}") 74 | 75 | stdout_selector = selectors.DefaultSelector() 76 | stdout_selector.register(self.process.stdout, selectors.EVENT_READ, read_stdout) 77 | 78 | stderr_selector = selectors.DefaultSelector() 79 | stderr_selector.register(self.process.stderr, selectors.EVENT_READ, read_stderr) 80 | 81 | while self.process.poll() is None: 82 | for selector_events in [stdout_selector.select(), stderr_selector.select()]: 83 | for key, _ in selector_events: 84 | callback = key.data 85 | callback(key.fileobj) 86 | 87 | def stderr_reader(self, process): 88 | pass 89 | -------------------------------------------------------------------------------- /beebot/execution/executor.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | from typing import TYPE_CHECKING 4 | 5 | from pydantic import ValidationError 6 | 7 | from beebot.models.database_models import Decision, Observation 8 | 9 | if TYPE_CHECKING: 10 | from beebot.execution.task_execution import TaskExecution 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Executor: 16 | def __init__(self, task_execution: "TaskExecution"): 17 | self.task_execution = task_execution 18 | 19 | async def execute(self, decision: Decision) -> Observation: 20 | """Get pack from tool name. call it""" 21 | pack = self.task_execution.packs.get(decision.tool_name) 22 | if not pack: 23 | return Observation( 24 | success=False, 25 | error_reason=f"Invalid tool name received: {decision.tool_name}. It may be invalid or may not be " 26 | f"installed.", 27 | ) 28 | 29 | tool_args = decision.tool_args or {} 30 | try: 31 | result = await pack.arun(**tool_args) 32 | logger.info("\n=== Execution observation ===") 33 | logger.info(result) 34 | return Observation(response=result) 35 | except ValidationError as e: 36 | logger.error( 37 | f"Error on execution of {decision.tool_name}: {json.dumps(e.errors())}" 38 | ) 39 | return Observation(response=f"Error: {json.dumps(e.errors())}") 40 | except (SystemExit, KeyboardInterrupt): 41 | raise 42 | except BaseException as e: 43 | logger.error(f"Error on execution of {decision.tool_name}: {e}") 44 | return Observation( 45 | response=f"Exception: {e}", 46 | success=False, 47 | # TODO: Improve error_reason 48 | error_reason=str(e), 49 | ) 50 | -------------------------------------------------------------------------------- /beebot/execution/step.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from typing import TYPE_CHECKING 3 | 4 | from beebot.models.database_models import ( 5 | StepModel, 6 | Plan, 7 | Decision, 8 | Observation, 9 | Oversight, 10 | DocumentModel, 11 | DocumentStep, 12 | ) 13 | 14 | if TYPE_CHECKING: 15 | from beebot.execution.task_execution import TaskExecution 16 | 17 | 18 | @dataclass 19 | class Step: 20 | task_execution: "TaskExecution" = None 21 | model_object: StepModel = None 22 | oversight: Oversight = None 23 | decision: Decision = None 24 | observation: Observation = None 25 | plan: Plan = None 26 | reversible: bool = True 27 | 28 | @property 29 | async def documents(self) -> dict[str, DocumentModel]: 30 | documents = {} 31 | if not self.model_object: 32 | return documents 33 | 34 | document_steps = await DocumentStep.filter( 35 | step=self.model_object 36 | ).prefetch_related("document") 37 | for document_step in document_steps: 38 | documents[document_step.document.name] = document_step.document 39 | 40 | return documents 41 | 42 | async def add_document(self, document: DocumentModel): 43 | await DocumentStep.get_or_create(document=document, step=self.model_object) 44 | 45 | async def save(self): 46 | if not self.model_object: 47 | self.model_object = StepModel( 48 | task_execution=self.task_execution.model_object, 49 | oversight=self.oversight, 50 | decision=self.decision, 51 | observation=self.observation, 52 | plan=self.plan, 53 | ) 54 | else: 55 | self.model_object.oversight = self.oversight 56 | self.model_object.decision = self.decision 57 | self.model_object.observation = self.observation 58 | self.model_object.plan = self.plan 59 | 60 | await self.model_object.save() 61 | 62 | @classmethod 63 | async def from_model(cls, step_model: StepModel): 64 | kwargs = {"model_object": step_model, "reversible": False} 65 | oversight = await step_model.oversight 66 | decision = await step_model.decision 67 | observation = await step_model.observation 68 | plan = await step_model.plan 69 | 70 | if oversight: 71 | kwargs["oversight"] = oversight 72 | if decision: 73 | kwargs["decision"] = decision 74 | if observation: 75 | kwargs["observation"] = observation 76 | if plan: 77 | kwargs["plan"] = plan 78 | 79 | step = cls(**kwargs) 80 | 81 | return step 82 | -------------------------------------------------------------------------------- /beebot/execution/task_execution.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING, Union 3 | 4 | from autopack import Pack 5 | from pydantic import ValidationError 6 | 7 | from beebot.body.pack_utils import llm_wrapper 8 | from beebot.decider import Decider 9 | from beebot.execution import Step 10 | from beebot.execution.executor import Executor 11 | from beebot.execution.task_state_machine import TaskStateMachine 12 | from beebot.models.database_models import ( 13 | Plan, 14 | Observation, 15 | TaskExecutionModel, 16 | Oversight, 17 | Decision, 18 | ) 19 | from beebot.overseer.overseer import Overseer 20 | from beebot.planner import Planner 21 | 22 | if TYPE_CHECKING: 23 | from beebot.body import Body 24 | from beebot.agents.base_agent import BaseAgent 25 | 26 | logger = logging.getLogger(__name__) 27 | 28 | RETRY_LIMIT = 3 29 | 30 | 31 | class TaskExecution: 32 | """Represents one "branch" of execution that beebot has made during execution of a task""" 33 | 34 | body: "Body" 35 | agent: "BaseAgent" 36 | model_object: TaskExecutionModel = None 37 | state: TaskStateMachine 38 | packs: dict[str, Pack] = None 39 | steps: list[Step] 40 | agent_name: str = "" 41 | instructions: str = "" 42 | inputs: list[str] 43 | outputs: list[str] 44 | complete: bool = False 45 | 46 | # NOTE! Map of value to list of names 47 | variables: dict[str, list[str]] 48 | 49 | def __init__( 50 | self, 51 | body: "Body", 52 | model_object: TaskExecutionModel = None, 53 | agent_name: str = "", 54 | instructions: str = "", 55 | inputs: list[str] = None, 56 | outputs: list[str] = None, 57 | complete: bool = False, 58 | ): 59 | self.state = TaskStateMachine(self) 60 | self.body = body 61 | self.steps = [] 62 | self.model_object = model_object 63 | self.packs = {} 64 | self.variables = {} 65 | 66 | if model_object: 67 | self.agent_name = model_object.agent 68 | self.instructions = model_object.instructions 69 | self.inputs = model_object.inputs 70 | self.outputs = model_object.outputs 71 | self.complete = model_object.complete 72 | else: 73 | self.agent_name = agent_name 74 | self.instructions = instructions or "" 75 | self.inputs = inputs or [] 76 | self.outputs = outputs or [] 77 | self.complete = complete 78 | 79 | from beebot.agents.base_agent import BaseAgent 80 | 81 | agent_classes = [agent for agent in BaseAgent.__subclasses__()] 82 | self.agent = BaseAgent(self) 83 | for agent_class in agent_classes: 84 | if self.agent_name == agent_class.NAME: 85 | self.agent = agent_class(self) 86 | 87 | @property 88 | def current_step(self): 89 | return self.steps[-1] if self.steps else None 90 | 91 | @classmethod 92 | async def from_model(cls, body: "Body", task_execution_model: TaskExecutionModel): 93 | task = cls(body, model_object=task_execution_model) 94 | 95 | task.state.current_state = TaskStateMachine.states_map[ 96 | task_execution_model.state 97 | ] 98 | 99 | for step_model in await task_execution_model.steps.all(): 100 | task.steps.append(await Step.from_model(step_model)) 101 | 102 | if task.current_step and task.current_step.plan: 103 | await task.create_new_step() 104 | 105 | await task.get_packs() 106 | return task 107 | 108 | async def create_new_step(self) -> Step: 109 | await self.save() 110 | 111 | old_step = self.current_step 112 | 113 | if old_step: 114 | plan = old_step.plan 115 | # Current step has not finished. TODO: Is this the right thing to do? 116 | if not plan: 117 | return old_step 118 | 119 | oversight = Oversight( 120 | original_plan_text=plan.plan_text, modified_plan_text=plan.plan_text 121 | ) 122 | await oversight.save() 123 | 124 | new_incomplete_step = Step(task_execution=self, oversight=oversight) 125 | else: 126 | new_incomplete_step = Step(task_execution=self) 127 | 128 | await new_incomplete_step.save() 129 | self.steps.append(new_incomplete_step) 130 | await self.save() 131 | # Create links from previous documents to this execution 132 | 133 | if old_step: 134 | previous_documents = await old_step.documents 135 | for document in previous_documents.values(): 136 | await new_incomplete_step.add_document(document) 137 | else: 138 | await self.create_initial_oversight() 139 | 140 | return new_incomplete_step 141 | 142 | async def cycle(self) -> Union[Step, None]: 143 | if self.complete: 144 | return None 145 | 146 | if not self.current_step: 147 | await self.create_new_step() 148 | 149 | if not self.state.current_state == TaskStateMachine.oversight: 150 | self.state.oversee() 151 | 152 | await self.get_packs() 153 | oversight = self.current_step.oversight 154 | 155 | self.state.decide() 156 | await self.save() 157 | 158 | decision = await self.decide(oversight) 159 | await self.execute(decision) 160 | 161 | return_step = self.current_step 162 | if not self.complete: 163 | new_plan = await self.plan() 164 | await self.add_plan(new_plan) 165 | await self.finish_step() 166 | 167 | await self.save() 168 | return return_step 169 | 170 | async def add_oversight(self, oversight: Oversight): 171 | self.current_step.oversight = oversight 172 | await self.current_step.save() 173 | 174 | async def add_decision(self, decision: str): 175 | self.current_step.decision = decision 176 | await self.current_step.save() 177 | 178 | async def add_observation(self, observation: Observation): 179 | step = self.current_step 180 | variable_name = f"{step.decision.tool_name}_{len(self.steps)}" 181 | if observation.response in self.variables: 182 | self.variables[observation.response].append(variable_name) 183 | else: 184 | self.variables[observation.response] = [variable_name] 185 | step.observation = observation 186 | await step.save() 187 | 188 | async def add_plan(self, plan: Plan): 189 | self.current_step.plan = plan 190 | await self.current_step.save() 191 | 192 | async def finish_step(self) -> Step: 193 | completed_step = self.current_step 194 | await self.create_new_step() 195 | return completed_step 196 | 197 | async def save(self): 198 | if not self.model_object: 199 | path_model = TaskExecutionModel( 200 | body=self.body.model_object, 201 | agent=self.agent_name, 202 | instructions=self.instructions, 203 | inputs=self.inputs, 204 | outputs=self.outputs, 205 | complete=self.complete, 206 | ) 207 | await path_model.save() 208 | self.model_object = path_model 209 | 210 | for step in self.steps: 211 | await step.save() 212 | 213 | async def get_packs(self) -> list[Pack]: 214 | for pack_class in self.agent.PACKS: 215 | from beebot.packs.system_base_pack import SystemBasePack 216 | 217 | if pack_class in SystemBasePack.__subclasses__(): 218 | pack = pack_class(body=self.body) 219 | else: 220 | llm = llm_wrapper(self.body) 221 | pack = pack_class(llm=llm, allm=llm) 222 | self.packs[pack.name] = pack 223 | return self.packs 224 | 225 | async def execute(self, decision: Decision, retry_count: int = 0) -> Observation: 226 | """Execute a Decision and keep track of state""" 227 | try: 228 | result = await Executor(self).execute(decision=decision) 229 | await self.add_observation(result) 230 | return result 231 | except ValidationError as e: 232 | # It's likely the AI just sent bad arguments, try again. 233 | logger.warning( 234 | f"Invalid arguments received: {e}. {decision.tool_name}({decision.tool_args}" 235 | ) 236 | if retry_count >= RETRY_LIMIT: 237 | return 238 | return await self.execute(decision, retry_count + 1) 239 | finally: 240 | if not self.complete: 241 | self.state.plan() 242 | await self.save() 243 | 244 | async def decide(self, oversight: Oversight = None) -> Decision: 245 | """Execute an action and keep track of state""" 246 | try: 247 | decision = await Decider(self).decide_with_retry(oversight=oversight) 248 | await self.add_decision(decision) 249 | 250 | return decision 251 | finally: 252 | self.state.execute() 253 | await self.save() 254 | 255 | async def plan(self) -> Plan: 256 | """Take the current task and history and develop a plan""" 257 | try: 258 | plan = await Planner(self).plan() 259 | await self.add_plan(plan) 260 | return plan 261 | finally: 262 | if not self.complete: 263 | self.state.oversee() 264 | await self.save() 265 | 266 | async def create_initial_oversight(self) -> Oversight: 267 | oversight = await Overseer(self).initial_oversight() 268 | await self.add_oversight(oversight) 269 | return oversight 270 | 271 | async def compile_history(self) -> str: 272 | return await self.agent.compile_history() 273 | 274 | def compile_variables(self) -> str: 275 | return self.agent.compile_variables() 276 | -------------------------------------------------------------------------------- /beebot/execution/task_state_machine.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from statemachine import StateMachine, State 4 | 5 | if TYPE_CHECKING: 6 | from beebot.execution.task_execution import TaskExecution 7 | 8 | 9 | class TaskStateMachine(StateMachine): 10 | waiting = State(initial=True) 11 | planning = State() 12 | oversight = State() 13 | deciding = State() 14 | executing = State() 15 | done = State(final=True) 16 | 17 | execute = deciding.to(executing) 18 | plan = executing.to(planning) 19 | oversee = planning.to(oversight) | waiting.to(oversight) 20 | decide = oversight.to(deciding) 21 | finish = executing.to(done) 22 | 23 | def __init__(self, task_execution: "TaskExecution"): 24 | self.task_execution = task_execution 25 | super().__init__() 26 | -------------------------------------------------------------------------------- /beebot/executor/__init__.py: -------------------------------------------------------------------------------- 1 | from beebot.execution.executor import Executor 2 | 3 | __all__ = ["Executor"] 4 | -------------------------------------------------------------------------------- /beebot/initiator/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/initiator/__init__.py -------------------------------------------------------------------------------- /beebot/initiator/api.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | from dotenv import load_dotenv 5 | from fastapi import FastAPI 6 | from starlette.middleware.cors import CORSMiddleware 7 | 8 | from beebot.api.routes import ( 9 | create_agent_task, 10 | execute_agent_task_step, 11 | agent_task_ids, 12 | get_agent_task, 13 | list_agent_task_steps, 14 | get_agent_task_step, 15 | ) 16 | from beebot.api.websocket import websocket_endpoint 17 | from beebot.config import Config 18 | 19 | logger = logging.getLogger(__name__) 20 | 21 | ORIGINS = [ 22 | "http://localhost:3000", 23 | ] 24 | 25 | 26 | def create_app() -> FastAPI: 27 | load_dotenv() 28 | os.environ["BEEBOT_HARD_EXIT"] = "False" 29 | config = Config.global_config() 30 | config.setup_logging() 31 | 32 | app = FastAPI( 33 | title="BeeBot Agent Communication Protocol", 34 | description="", 35 | version="v1", 36 | ) 37 | app.add_websocket_route("/notifications", websocket_endpoint) 38 | app.add_route("/agent/tasks", create_agent_task, methods=["POST"]) 39 | app.add_route( 40 | "/agent/tasks/{task_id}/steps", execute_agent_task_step, methods=["POST"] 41 | ) 42 | app.add_route("/agent/tasks", agent_task_ids) 43 | app.add_route("/agent/tasks/{task_id}", get_agent_task) 44 | app.add_route("/agent/tasks/{task_id}/steps", list_agent_task_steps) 45 | app.add_route("/agent/tasks/{task_id}/steps/{step_id}", get_agent_task_step) 46 | 47 | app.add_middleware( 48 | CORSMiddleware, 49 | allow_origins=ORIGINS, 50 | allow_credentials=True, 51 | allow_methods=["*"], 52 | allow_headers=["*"], 53 | ) 54 | 55 | return app 56 | -------------------------------------------------------------------------------- /beebot/initiator/benchmark_entrypoint.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import sys 3 | 4 | from dotenv import load_dotenv 5 | 6 | from beebot.body import Body 7 | from beebot.config import Config 8 | from beebot.models.database_models import initialize_db 9 | 10 | 11 | async def run_specific_agent(task: str) -> None: 12 | load_dotenv() 13 | 14 | config = Config.global_config() 15 | config.setup_logging() 16 | await initialize_db(config.database_url) 17 | 18 | body = Body(task=task, config=config) 19 | await body.setup() 20 | while output := await body.cycle(): 21 | if output.observation: 22 | print(output.observation.response) 23 | 24 | 25 | if __name__ == "__main__": 26 | if len(sys.argv) != 2: 27 | print("Usage: python script.py ") 28 | sys.exit(1) 29 | task_arg = sys.argv[-1] 30 | asyncio.run(run_specific_agent(task_arg)) 31 | -------------------------------------------------------------------------------- /beebot/initiator/cli.py: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env python 2 | import argparse 3 | import asyncio 4 | 5 | from dotenv import load_dotenv 6 | 7 | from beebot.body import Body 8 | from beebot.config import Config 9 | from beebot.models.database_models import initialize_db 10 | 11 | 12 | def parse_args(): 13 | parser = argparse.ArgumentParser(description="BeeBot CLI tool") 14 | 15 | parser.add_argument( 16 | "-t", 17 | "--task", 18 | help="Run a specific task, wrapped in quotes", 19 | ) 20 | 21 | return parser.parse_args() 22 | 23 | 24 | async def main(): 25 | load_dotenv() 26 | parsed_args = parse_args() 27 | if parsed_args.task: 28 | task = parsed_args.task 29 | else: 30 | print("What would you like me to do?") 31 | print("> ", end="") 32 | task = input() 33 | 34 | config = Config.global_config() 35 | config.setup_logging() 36 | await initialize_db(config.database_url) 37 | 38 | body = Body(task=task, config=config) 39 | await body.setup() 40 | while output := await body.cycle(): 41 | if output.observation: 42 | print("\n=== Cycle Output ===") 43 | print(output.observation.response) 44 | 45 | 46 | if __name__ == "__main__": 47 | asyncio.run(main()) 48 | -------------------------------------------------------------------------------- /beebot/models/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "BodyModel", 3 | "DocumentModel", 4 | "TaskExecutionModel", 5 | "StepModel", 6 | "Oversight", 7 | "Plan", 8 | "Decision", 9 | "Observation", 10 | "DocumentStep", 11 | ] 12 | 13 | from .database_models import ( 14 | BodyModel, 15 | DocumentModel, 16 | TaskExecutionModel, 17 | StepModel, 18 | Oversight, 19 | Plan, 20 | Decision, 21 | Observation, 22 | DocumentStep, 23 | ) 24 | -------------------------------------------------------------------------------- /beebot/models/database_models.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | from tortoise import fields, Tortoise 4 | from tortoise.fields import JSONField, BooleanField 5 | from tortoise.models import Model 6 | from yoyo import get_backend, read_migrations 7 | 8 | 9 | class BaseModel(Model): 10 | id = fields.IntField(pk=True) 11 | created_at = fields.DatetimeField(auto_now_add=True) 12 | updated_at = fields.DatetimeField(auto_now=True) 13 | 14 | def json(self): 15 | json_dict = {k: v for k, v in self.__dict__.items() if not k.startswith("_")} 16 | json_dict.pop("created_at") 17 | json_dict.pop("updated_at") 18 | return json.dumps(json_dict) 19 | 20 | class Meta: 21 | abstract = True 22 | 23 | 24 | class BodyModel(BaseModel): 25 | INCLUSIVE_PREFETCH = "task_executions__steps__document_steps__document" 26 | 27 | task = fields.TextField() 28 | 29 | class Meta: 30 | table = "body" 31 | 32 | 33 | class TaskExecutionModel(BaseModel): 34 | body = fields.ForeignKeyField("models.BodyModel", related_name="task_executions") 35 | agent = fields.TextField() 36 | state = fields.TextField(default="waiting") 37 | instructions = fields.TextField() 38 | inputs = JSONField(default=list) 39 | outputs = JSONField(default=list) 40 | complete = BooleanField(default=False) 41 | variables = JSONField(default=dict) 42 | 43 | class Meta: 44 | table = "task_execution" 45 | 46 | 47 | class StepModel(BaseModel): 48 | task_execution = fields.ForeignKeyField( 49 | "models.TaskExecutionModel", related_name="steps" 50 | ) 51 | plan = fields.ForeignKeyField("models.Plan", related_name="steps", null=True) 52 | decision = fields.ForeignKeyField( 53 | "models.Decision", related_name="steps", null=True 54 | ) 55 | observation = fields.ForeignKeyField( 56 | "models.Observation", related_name="steps", null=True 57 | ) 58 | oversight = fields.ForeignKeyField( 59 | "models.Oversight", related_name="steps", null=True 60 | ) 61 | 62 | class Meta: 63 | table = "step" 64 | 65 | 66 | class Oversight(BaseModel): 67 | original_plan_text = fields.TextField() 68 | modified_plan_text = fields.TextField() 69 | modifications = JSONField(default=dict) 70 | prompt_variables = JSONField(default=dict) 71 | llm_response = fields.TextField(default="") 72 | 73 | class Meta: 74 | table = "oversight" 75 | 76 | 77 | class Decision(BaseModel): 78 | tool_name = fields.TextField() 79 | tool_args = JSONField(default=dict) 80 | prompt_variables = JSONField(default=dict) 81 | llm_response = fields.TextField(default="") 82 | 83 | class Meta: 84 | table = "decision" 85 | 86 | 87 | class Observation(BaseModel): 88 | response = fields.TextField(null=True) 89 | error_reason = fields.TextField(null=True) 90 | success = fields.BooleanField(default=True) 91 | 92 | class Meta: 93 | table = "observation" 94 | 95 | 96 | class Plan(BaseModel): 97 | plan_text = fields.TextField() 98 | prompt_variables = JSONField(default=dict) 99 | llm_response = fields.TextField(default="") 100 | 101 | class Meta: 102 | table = "plan" 103 | 104 | 105 | class DocumentModel(BaseModel): 106 | name = fields.TextField() 107 | content = fields.TextField() 108 | 109 | class Meta: 110 | table = "document" 111 | 112 | 113 | class DocumentStep(BaseModel): 114 | step = fields.ForeignKeyField("models.StepModel", related_name="document_steps") 115 | document = fields.ForeignKeyField( 116 | "models.DocumentModel", related_name="document_steps" 117 | ) 118 | 119 | class Meta: 120 | table = "document_step" 121 | 122 | 123 | def apply_migrations(db_url: str): 124 | """Apply any outstanding migrations""" 125 | backend = get_backend(db_url) 126 | backend.init_database() 127 | migrations = read_migrations("migrations") 128 | 129 | with backend.lock(): 130 | backend.apply_migrations(backend.to_apply(migrations)) 131 | 132 | 133 | async def initialize_db(db_url: str): 134 | # Don't re-initialize an already initialized database 135 | if Tortoise.describe_models(): 136 | return 137 | 138 | await Tortoise.init( 139 | db_url=db_url, 140 | modules={"models": ["beebot.models.database_models"]}, 141 | use_tz=True, 142 | ) 143 | if db_url == "sqlite://:memory:": 144 | await Tortoise.generate_schemas() 145 | else: 146 | apply_migrations(db_url) 147 | -------------------------------------------------------------------------------- /beebot/overseer/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/overseer/__init__.py -------------------------------------------------------------------------------- /beebot/overseer/overseeing_prompt.py: -------------------------------------------------------------------------------- 1 | TEMPLATE = """ 2 | """ 3 | 4 | 5 | def overseeing_prompt() -> str: 6 | return TEMPLATE 7 | -------------------------------------------------------------------------------- /beebot/overseer/overseer.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING 3 | 4 | from beebot.body.llm import call_llm 5 | from beebot.models import Oversight 6 | 7 | if TYPE_CHECKING: 8 | from beebot.execution.task_execution import TaskExecution 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class Overseer: 14 | """This doesn't really do anything right now, but in the future this will be where the human has a chance to modify 15 | plans from the previous step before executing this step""" 16 | 17 | task_execution: "TaskExecution" 18 | 19 | def __init__(self, task_execution: "TaskExecution"): 20 | self.task_execution = task_execution 21 | 22 | async def initial_oversight(self) -> Oversight: 23 | logger.info("\n=== Initial Plan Request ===") 24 | ( 25 | prompt, 26 | prompt_variables, 27 | ) = await self.task_execution.agent.planning_prompt() 28 | logger.info(prompt) 29 | 30 | response = await call_llm( 31 | self.task_execution.body, 32 | message=prompt, 33 | function_call="none", 34 | ) 35 | 36 | logger.info("\n=== Initial Plan Created ===") 37 | logger.info(response.text) 38 | 39 | oversight = Oversight( 40 | prompt_variables=prompt_variables, 41 | original_plan_text=response.text, 42 | modified_plan_text=response.text, 43 | llm_response=response.text, 44 | ) 45 | await oversight.save() 46 | return oversight 47 | -------------------------------------------------------------------------------- /beebot/packs/README.md: -------------------------------------------------------------------------------- 1 | # official 2 | Official Packs for AutoPack 3 | -------------------------------------------------------------------------------- /beebot/packs/__init__.py: -------------------------------------------------------------------------------- 1 | __all__ = [ 2 | "CreateDraft", 3 | "DelegateTask", 4 | "ExecutePythonFile", 5 | "ExecutePythonFileInBackground", 6 | "Exit", 7 | "GetMessage", 8 | "GetProcessStatus", 9 | "GetThread", 10 | "GetWebsiteTextContent", 11 | "InstallPythonPackage", 12 | "KillProcess", 13 | "ListProcesses", 14 | "SendMessage", 15 | "ExportVariable", 16 | ] 17 | 18 | from beebot.packs.delegate_task import DelegateTask 19 | from beebot.packs.execute_python_file import ExecutePythonFile 20 | from beebot.packs.execute_python_file_in_background import ExecutePythonFileInBackground 21 | from beebot.packs.exit import Exit 22 | from beebot.packs.export_variable import ExportVariable 23 | from beebot.packs.get_process_status import GetProcessStatus 24 | from beebot.packs.get_website_text_content import GetWebsiteTextContent 25 | from beebot.packs.gmail import CreateDraft, GetMessage, GetThread, SendMessage 26 | from beebot.packs.install_python_package import InstallPythonPackage 27 | from beebot.packs.kill_process import KillProcess 28 | from beebot.packs.list_processes import ListProcesses 29 | -------------------------------------------------------------------------------- /beebot/packs/delegate_task.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from beebot.packs.system_base_pack import SystemBasePack 4 | 5 | PACK_NAME = "delegate_task" 6 | PACK_DESCRIPTION = ( 7 | "Delegate a complex task to a subordinate agent and return the output." 8 | ) 9 | 10 | 11 | class DelegateTaskArgs(BaseModel): 12 | task: str = Field(..., description="The task to be accomplished by the agent") 13 | 14 | 15 | class DelegateTask(SystemBasePack): 16 | name = PACK_NAME 17 | description = PACK_DESCRIPTION 18 | args_schema = DelegateTaskArgs 19 | categories = ["Delegation"] 20 | 21 | reversible = False 22 | 23 | async def _arun(self, task: str) -> str: 24 | from beebot.body import Body 25 | 26 | try: 27 | subagent = Body(task) 28 | await subagent.setup() 29 | subagent_output = [] 30 | while output := await subagent.cycle(): 31 | subagent_output.append(output.observation.response) 32 | 33 | return "\n".join(subagent_output) 34 | 35 | except (SystemExit, KeyboardInterrupt): 36 | raise 37 | except BaseException as e: 38 | return f"Error: {e}" 39 | -------------------------------------------------------------------------------- /beebot/packs/disk_usage/__init__.py: -------------------------------------------------------------------------------- 1 | from .disk_usage import DiskUsage 2 | 3 | __all__ = ["DiskUsage"] 4 | -------------------------------------------------------------------------------- /beebot/packs/disk_usage/disk_usage.py: -------------------------------------------------------------------------------- 1 | import psutil 2 | from autopack import Pack 3 | from pydantic import BaseModel 4 | 5 | 6 | class DiskUsageArgsSchema(BaseModel): 7 | pass 8 | 9 | 10 | class DiskUsage(Pack): 11 | name = "disk_usage" 12 | description = "Get disk usage information for this computer." 13 | dependencies = ["psutil"] 14 | args_schema = DiskUsageArgsSchema 15 | categories = ["System Info"] 16 | 17 | def _run(self): 18 | # Currently we will only support root directory 19 | usage = psutil.disk_usage("/") 20 | used = round(usage.used / 1024 / 1024 / 1024, 2) 21 | total = round(usage.total / 1024 / 1024 / 1024, 2) 22 | free = round(usage.free / 1024 / 1024 / 1024, 2) 23 | 24 | return f"""Total: {total} GB. Used: {used} GB. Available: {free} GB". Percent Used: {usage.percent * 100}%""" 25 | 26 | async def _arun(self): 27 | return self.run() 28 | -------------------------------------------------------------------------------- /beebot/packs/disk_usage/test_disk_usage.py: -------------------------------------------------------------------------------- 1 | from disk_usage import DiskUsage 2 | 3 | 4 | def test_disk_usage(): 5 | usage = DiskUsage().run() 6 | 7 | assert "Total:" in usage 8 | assert "Used:" in usage 9 | assert "Available:" in usage 10 | assert "Percent Used:" in usage 11 | -------------------------------------------------------------------------------- /beebot/packs/execute_python_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import shlex 3 | import subprocess 4 | import time 5 | from typing import Optional 6 | 7 | from pydantic import BaseModel, Field 8 | 9 | from beebot.body.pack_utils import init_workspace_poetry 10 | from beebot.packs.system_base_pack import SystemBasePack 11 | from beebot.utils import restrict_path 12 | 13 | PACK_NAME = "execute_python_file" 14 | 15 | # IMPORTANT NOTE: This does NOT actually restrict the execution environment, it just nudges the AI to avoid doing 16 | # those things. 17 | PACK_DESCRIPTION = ( 18 | "Executes a Python file in a restricted environment, prohibiting shell execution and filesystem access. Returns " 19 | "the output of the execution as a string. Ensure file adherence to restrictions and availability in the specified " 20 | "path. (Packages managed by Poetry.)" 21 | ) 22 | 23 | 24 | class TimedOutSubprocess: 25 | """A convenience class to allow creating a subprocess with a timeout while capturing its output""" 26 | 27 | cmd: list[str] 28 | process: Optional[subprocess.Popen[str]] 29 | 30 | def __init__(self, cmd: list[str]) -> None: 31 | self.cmd = cmd 32 | self.process = None 33 | 34 | def run(self, timeout: int, **kwargs) -> None: 35 | self.process = subprocess.Popen(self.cmd, universal_newlines=True, **kwargs) 36 | 37 | start_time = time.time() 38 | while self.process.poll() is None: # None means the process hasn't finished 39 | if time.time() - start_time > timeout: 40 | self.process.terminate() # Kill the process 41 | break 42 | 43 | time.sleep(1) 44 | 45 | if self.process.returncode is not None and self.process.returncode != 0: 46 | print(f"The agent exited with return code {self.process.returncode}") 47 | 48 | output, error = self.process.communicate() 49 | return "\n".join([output.strip(), error.strip()]).strip() 50 | 51 | 52 | class ExecutePythonFileArgs(BaseModel): 53 | file_path: str = Field( 54 | ..., 55 | description="Specifies the path to the Python file previously saved on disk.", 56 | ) 57 | python_args: str = Field( 58 | description="Arguments to be passed when executing the file", default="" 59 | ) 60 | 61 | 62 | class ExecutePythonFile(SystemBasePack): 63 | name = PACK_NAME 64 | description = PACK_DESCRIPTION 65 | args_schema = ExecutePythonFileArgs 66 | categories = ["Programming"] 67 | depends_on = ["install_python_package", "write_python_code"] 68 | 69 | async def _arun(self, file_path: str, python_args: str = "") -> str: 70 | if self.body.config.restrict_code_execution: 71 | return "Error: Executing Python code is not allowed" 72 | 73 | await self.body.file_manager.flush_to_directory() 74 | file_path = os.path.join(self.body.config.workspace_path, file_path) 75 | try: 76 | abs_path = restrict_path(file_path, self.body.config.workspace_path) 77 | if not abs_path: 78 | return ( 79 | f"Error: File {file_path} does not exist. You must create it first." 80 | ) 81 | 82 | init_workspace_poetry(self.config.workspace_path) 83 | args_list = shlex.split(python_args) 84 | cmd = ["poetry", "run", "python", abs_path, *args_list] 85 | process = TimedOutSubprocess(cmd) 86 | process.run( 87 | timeout=self.body.config.process_timeout, 88 | stdout=subprocess.PIPE, 89 | stderr=subprocess.PIPE, 90 | cwd=self.body.config.workspace_path, 91 | ) 92 | os_subprocess = process.process 93 | output, error = os_subprocess.communicate() 94 | 95 | await self.body.file_manager.load_from_directory() 96 | 97 | if os_subprocess.returncode: 98 | return f"Execution failed with exit code {os_subprocess.returncode}. Output: {output}. {error}" 99 | 100 | if output: 101 | return f"Execution complete. Output: {output}" 102 | 103 | return "Execution complete." 104 | 105 | except (SystemExit, KeyboardInterrupt): 106 | raise 107 | except BaseException as e: 108 | return f"Error: {e}" 109 | -------------------------------------------------------------------------------- /beebot/packs/execute_python_file_in_background.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | import shlex 4 | import time 5 | 6 | from pydantic import BaseModel, Field 7 | 8 | from beebot.body.pack_utils import init_workspace_poetry 9 | from beebot.execution.background_process import BackgroundProcess 10 | from beebot.packs.system_base_pack import SystemBasePack 11 | from beebot.utils import restrict_path 12 | 13 | logger = logging.getLogger(__name__) 14 | 15 | PACK_NAME = "execute_python_file_in_background" 16 | 17 | # IMPORTANT NOTE: This does NOT actually restrict the execution environment, it just nudges the AI to avoid doing 18 | # those things. 19 | PACK_DESCRIPTION = ( 20 | "Executes a Python file in a restricted environment, prohibiting shell execution and filesystem access. Executes " 21 | "the code in the background or as a daemon process and returns immediately. Make sure the Python file adheres to" 22 | "the restrictions of the environment and is available in the specified file path. (Packages managed by Poetry.)" 23 | ) 24 | 25 | 26 | class ExecutePythonFileInBackgroundArgs(BaseModel): 27 | file_path: str = Field( 28 | ..., 29 | description="Specifies the path to the Python file previously saved on disk.", 30 | ) 31 | python_args: str = Field( 32 | description="Arguments to be passed when executing the file", default="" 33 | ) 34 | daemonize: bool = Field( 35 | description="Daemonize the process, detaching it from the current process.", 36 | default=False, 37 | ) 38 | 39 | 40 | class ExecutePythonFileInBackground(SystemBasePack): 41 | name = PACK_NAME 42 | description = PACK_DESCRIPTION 43 | args_schema = ExecutePythonFileInBackgroundArgs 44 | depends_on = [ 45 | "write_python_code", 46 | "install_python_package", 47 | "get_process_status", 48 | "list_processes", 49 | "kill_process", 50 | ] 51 | categories = ["Programming"] 52 | 53 | def _run( 54 | self, file_path: str, python_args: str = "", daemonize: bool = False 55 | ) -> str: 56 | if self.body.config.restrict_code_execution: 57 | return "Error: Executing Python code is not allowed" 58 | 59 | file_path = os.path.join(self.body.config.workspace_path, file_path) 60 | if not os.path.exists(file_path): 61 | return f"Error: File {file_path} does not exist. You must create it first." 62 | 63 | abs_path = restrict_path(file_path, self.body.config.workspace_path) 64 | if not abs_path: 65 | return f"Error: File {file_path} does not exist. You must create it first." 66 | 67 | init_workspace_poetry(self.config.workspace_path) 68 | args_list = shlex.split(python_args) 69 | cmd = ["poetry", "run", "python", abs_path, *args_list] 70 | process = BackgroundProcess(body=self.body, cmd=cmd, daemonize=daemonize) 71 | process.run() 72 | 73 | time.sleep(0.2) 74 | if process.poll() is not None: 75 | return f"Process {process.pid} started, but failed. Output: {process.stdout}. {process.stderr}" 76 | 77 | return ( 78 | f"Process started. It has been assigned PID {process.pid}. Use this when calling " 79 | f"`get_process_status`." 80 | ) 81 | 82 | async def _arun(self, *args, **kwargs) -> str: 83 | await self.body.file_manager.flush_to_directory() 84 | return self._run(*args, **kwargs) 85 | -------------------------------------------------------------------------------- /beebot/packs/exit.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from beebot.packs.system_base_pack import SystemBasePack 6 | 7 | PACK_NAME = "exit" 8 | PACK_DESCRIPTION = "Exits the program, signalling that all tasks have bene completed and all goals have been met." 9 | 10 | logger = logging.getLogger(__name__) 11 | 12 | 13 | class ExitArgs(BaseModel): 14 | success: bool = Field(description="Success", default=True) 15 | conclusion: str = Field( 16 | description="Reflect on the task execution process.", default="" 17 | ) 18 | 19 | 20 | class Exit(SystemBasePack): 21 | class Meta: 22 | name = PACK_NAME 23 | 24 | name = Meta.name 25 | description = PACK_DESCRIPTION 26 | args_schema = ExitArgs 27 | categories = ["System"] 28 | 29 | def _run(self, *args, **kwargs) -> str: 30 | raise NotImplementedError 31 | 32 | async def _arun(self, success: bool = True, conclusion: str = "") -> str: 33 | task_execution = self.body.current_task_execution 34 | task_execution.state.finish() 35 | task_execution.complete = True 36 | await task_execution.save() 37 | if success: 38 | logger.info("\n=== Task completed ===") 39 | else: 40 | logger.info("\n=== Task failed ===") 41 | 42 | logger.info(conclusion) 43 | 44 | return "Exited" 45 | -------------------------------------------------------------------------------- /beebot/packs/export_variable.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from beebot.packs.system_base_pack import SystemBasePack 4 | 5 | 6 | class ExportVariableArgs(BaseModel): 7 | name: str 8 | value: str 9 | 10 | 11 | class ExportVariable(SystemBasePack): 12 | name = "export_variable" 13 | description = ( 14 | "Set and export a variable to make it globally available to other subtasks" 15 | ) 16 | args_schema = ExportVariableArgs 17 | categories = [] 18 | 19 | def _run(self, name: str, value: str) -> str: 20 | self.body.global_variables[name] = value 21 | return "" 22 | -------------------------------------------------------------------------------- /beebot/packs/extract_information_from_webpage/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/packs/extract_information_from_webpage/__init__.py -------------------------------------------------------------------------------- /beebot/packs/extract_information_from_webpage/extract_information_from_webpage.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from autopack import Pack 4 | from autopack.utils import call_llm, acall_llm 5 | from bs4 import BeautifulSoup 6 | from playwright.async_api import async_playwright 7 | from playwright.sync_api import PlaywrightContextManager 8 | from pydantic import BaseModel, Field 9 | 10 | PACK_DESCRIPTION = "Extracts specific information from a webpage's content." 11 | 12 | PROMPT_TEMPLATE = """Please provide a summary of the following content, which was gathered from the website {url}: 13 | {content} 14 | """ 15 | 16 | QUESTION_PROMPT_TEMPLATE = """You are a language model tasked with answering a specific question based on the given content from the website {url}. Please provide an answer to the following question: 17 | 18 | Question: {question} 19 | 20 | Content: 21 | {content} 22 | 23 | Answer: 24 | """ 25 | 26 | 27 | class ExtractInformationFromWebpageArgs(BaseModel): 28 | url: str = Field(..., description="The URL of the webpage to analyze.") 29 | information: str = Field( 30 | description="The type of information to extract.", 31 | default="", 32 | ) 33 | 34 | 35 | class ExtractInformationFromWebpage(Pack): 36 | name = "extract_information_from_webpage" 37 | description = PACK_DESCRIPTION 38 | args_schema = ExtractInformationFromWebpageArgs 39 | categories = ["Web"] 40 | dependencies = ["playwright", "beautifulsoup4"] 41 | 42 | def __init__(self, **kwargs): 43 | super().__init__(**kwargs) 44 | # FIXME: Create an installer type system 45 | subprocess.run( 46 | ["playwright", "install"], stdout=subprocess.PIPE, stderr=subprocess.PIPE 47 | ) 48 | 49 | def _run(self, url: str, information: str = "") -> str: 50 | playwright = PlaywrightContextManager().start() 51 | browser = playwright.chromium.launch() 52 | try: 53 | page = browser.new_page() 54 | 55 | page.goto(url) 56 | html = page.content() 57 | finally: 58 | browser.close() 59 | playwright.stop() 60 | 61 | soup = BeautifulSoup(html, "html.parser") 62 | body_element = soup.find("body") 63 | 64 | if body_element: 65 | text = body_element.get_text(separator="\n")[:8000] 66 | else: 67 | return "Error: Could not summarize URL." 68 | 69 | if information: 70 | prompt = QUESTION_PROMPT_TEMPLATE.format( 71 | content=text, question=information, url=url 72 | ) 73 | else: 74 | prompt = PROMPT_TEMPLATE.format(content=text, url=url) 75 | 76 | response = call_llm(prompt, self.llm) 77 | return response 78 | 79 | async def _arun(self, url: str, information: str = "") -> str: 80 | async with async_playwright() as playwright: 81 | browser = await playwright.chromium.launch() 82 | page = await browser.new_page() 83 | 84 | await page.goto(url) 85 | html = await page.content() 86 | 87 | soup = BeautifulSoup(html, "html.parser") 88 | body_element = soup.find("body") 89 | 90 | if body_element: 91 | text = body_element.get_text(separator="\n")[:8000] 92 | else: 93 | return "Error: Could not summarize URL." 94 | 95 | if information: 96 | prompt = QUESTION_PROMPT_TEMPLATE.format( 97 | content=text, question=information, url=url 98 | ) 99 | else: 100 | prompt = PROMPT_TEMPLATE.format(content=text, url=url) 101 | 102 | response = await acall_llm(prompt, self.allm) 103 | 104 | return response 105 | -------------------------------------------------------------------------------- /beebot/packs/extract_information_from_webpage/test_extract_information_from_webpage.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from extract_information_from_webpage.extract_information_from_webpage import ( 4 | ExtractInformationFromWebpage, 5 | ) 6 | 7 | 8 | def mock_llm(text_in: str): 9 | return text_in.upper() 10 | 11 | 12 | async def mock_allm(text_in: str): 13 | return text_in.upper() 14 | 15 | 16 | def test_analyze_webpage_content_sync_no_information(): 17 | pack = ExtractInformationFromWebpage(llm=mock_llm) 18 | results = pack.run(url="https://www.bbc.com/news") 19 | assert "SPECIFIC QUESTION" not in results 20 | assert "PROVIDE A SUMMARY" in results 21 | assert "BBC" in results 22 | 23 | 24 | def test_analyze_webpage_content_sync_information(): 25 | pack = ExtractInformationFromWebpage(llm=mock_llm) 26 | results = pack.run( 27 | url="https://www.bbc.com/news", information="What are the top headlines?" 28 | ) 29 | assert "SPECIFIC QUESTION" in results 30 | assert "BBC" in results 31 | 32 | 33 | @pytest.mark.asyncio 34 | async def test_analyze_webpage_content_async_no_information(): 35 | pack = ExtractInformationFromWebpage(allm=mock_allm) 36 | results = await pack.arun(url="https://www.bbc.com/news") 37 | assert "SPECIFIC QUESTION" not in results 38 | assert "PROVIDE A SUMMARY" in results 39 | assert "BBC" in results 40 | 41 | 42 | @pytest.mark.asyncio 43 | async def test_analyze_webpage_content_async_information(): 44 | pack = ExtractInformationFromWebpage(allm=mock_allm) 45 | results = await pack.arun( 46 | url="https://www.cambridge.org/core/journals/business-and-politics/article/future-of-ai-is-in-the-states-the" 47 | "-case-of-autonomous-vehicle-policies/D6F0B764A976C2A934D517AE1D781195", 48 | information="key findings", 49 | ) 50 | assert "KEY FINDINGS" in results 51 | assert "CAMBRIDGE" in results 52 | -------------------------------------------------------------------------------- /beebot/packs/filesystem/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/packs/filesystem/__init__.py -------------------------------------------------------------------------------- /beebot/packs/filesystem/delete_file.py: -------------------------------------------------------------------------------- 1 | from autopack import Pack 2 | from pydantic import BaseModel, Field 3 | 4 | 5 | class DeleteFileArgs(BaseModel): 6 | filename: str = Field(..., description="The basename of the file to be deleted") 7 | 8 | 9 | class DeleteFile(Pack): 10 | name = "delete_file" 11 | description = "Deletes a file from disk." 12 | args_schema = DeleteFileArgs 13 | categories = ["Files"] 14 | 15 | def _run(self, filename: str) -> str: 16 | return self.filesystem_manager.delete_file(filename) 17 | 18 | async def _arun(self, filename: str) -> str: 19 | return self.filesystem_manager.adelete_file(filename) 20 | -------------------------------------------------------------------------------- /beebot/packs/filesystem/list_files.py: -------------------------------------------------------------------------------- 1 | from autopack import Pack 2 | from pydantic import BaseModel, Field 3 | 4 | # A few Packs will use poetry inside of the workspace, and the AI gets hella confused when these files are present. 5 | IGNORE_FILES = ["pyproject.toml", "poetry.lock"] 6 | 7 | 8 | class ListFilesArgs(BaseModel): 9 | path: str = Field(description="The directory to list the files of") 10 | 11 | 12 | class ListFiles(Pack): 13 | name = "list_files" 14 | description = "Provides a list of all accessible files in a given path." 15 | args_schema = ListFilesArgs 16 | categories = ["Files"] 17 | 18 | def _run(self, path: str): 19 | return self.filesystem_manager.list_files(path) 20 | 21 | async def _arun(self, path: str) -> str: 22 | return await self.filesystem_manager.alist_files(path) 23 | -------------------------------------------------------------------------------- /beebot/packs/filesystem/read_file.py: -------------------------------------------------------------------------------- 1 | from autopack import Pack 2 | from pydantic import BaseModel, Field 3 | 4 | 5 | class ReadFileArgs(BaseModel): 6 | filename: str = Field( 7 | ..., 8 | description="The name of the file to be read.", 9 | ) 10 | 11 | 12 | class ReadFile(Pack): 13 | name = "read_file" 14 | description = "Reads and returns the content of a specified file from the disk." 15 | args_schema = ReadFileArgs 16 | categories = ["Files"] 17 | 18 | def _run(self, filename: str) -> str: 19 | return self.filesystem_manager.read_file(filename) 20 | 21 | async def _arun(self, filename: str) -> str: 22 | return await self.filesystem_manager.aread_file(filename) 23 | -------------------------------------------------------------------------------- /beebot/packs/filesystem/test_delete_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from filesystem.delete_file import DeleteFile 4 | 5 | 6 | def test_read_file(): 7 | path = "some_file_to_delete.txt" 8 | with open(os.path.join("workspace", path), "w+") as f: 9 | f.write("some string") 10 | 11 | DeleteFile().run(filename=path) 12 | 13 | assert not os.path.exists(path) 14 | -------------------------------------------------------------------------------- /beebot/packs/filesystem/test_list_files.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from filesystem.list_files import ListFiles 4 | 5 | 6 | def test_list_files(): 7 | paths = ["a.txt", "b.txt", "c.txt"] 8 | for path in paths: 9 | with open(os.path.join("workspace", path), "w+") as f: 10 | f.write("some string") 11 | 12 | file_list = ListFiles().run(path=".") 13 | 14 | for path in paths: 15 | assert path in file_list 16 | -------------------------------------------------------------------------------- /beebot/packs/filesystem/test_read_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from filesystem.read_file import ReadFile 4 | 5 | 6 | def test_read_file(): 7 | with open(os.path.join("workspace", "some_file.txt"), "w+") as f: 8 | f.write("some string") 9 | 10 | assert ReadFile().run(filename="some_file.txt") == "some string" 11 | -------------------------------------------------------------------------------- /beebot/packs/filesystem/test_write_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from filesystem.write_file import WriteFile 4 | 5 | 6 | def test_write_file(): 7 | WriteFile().run(filename="some_file_to_write.txt", text_content="some string") 8 | 9 | with open(os.path.join("workspace", "some_file_to_write.txt"), "r") as f: 10 | assert f.read() == "some string" 11 | -------------------------------------------------------------------------------- /beebot/packs/filesystem/write_file.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | from autopack import Pack 4 | from pydantic import BaseModel, Field 5 | 6 | PACK_DESCRIPTION = ( 7 | "Allows you to write specified text content to a file, creating a new file or overwriting an existing one as " 8 | "necessary." 9 | ) 10 | 11 | logger = logging.getLogger(__name__) 12 | 13 | 14 | class WriteFileArgs(BaseModel): 15 | filename: str = Field( 16 | ..., 17 | description="Specifies the name of the file to which the content will be written.", 18 | ) 19 | text_content: str = Field( 20 | ..., 21 | description="The content that will be written to the specified file.", 22 | ) 23 | 24 | 25 | class WriteFile(Pack): 26 | name = "write_file" 27 | description = PACK_DESCRIPTION 28 | args_schema = WriteFileArgs 29 | categories = ["Files"] 30 | 31 | # TODO: This can be reversible for some, but not all, file manager types 32 | reversible = False 33 | 34 | def _run(self, filename: str, text_content: str): 35 | return self.filesystem_manager.write_file(filename, text_content) 36 | 37 | async def _arun(self, filename: str, text_content: str) -> str: 38 | return await self.config.filesystem_manager.awrite_file(filename, text_content) 39 | -------------------------------------------------------------------------------- /beebot/packs/filesystem_utils.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | 4 | def restrict_path(file_path: str, workspace_dir: str): 5 | absolute_path = os.path.abspath(file_path) 6 | relative_path = os.path.relpath(absolute_path, workspace_dir) 7 | 8 | if relative_path.startswith("..") or "/../" in relative_path: 9 | return None 10 | 11 | return absolute_path 12 | -------------------------------------------------------------------------------- /beebot/packs/get_process_status.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from beebot.packs.system_base_pack import SystemBasePack 4 | 5 | PACK_NAME = "get_process_status" 6 | PACK_DESCRIPTION = ( 7 | "Retrieves the status of the background process with the given pid. Returns the process output if it has " 8 | "finished." 9 | ) 10 | 11 | 12 | class GetProcessStatusArgs(BaseModel): 13 | pid: str = Field(..., description="The Process ID") 14 | 15 | 16 | class GetProcessStatus(SystemBasePack): 17 | name = PACK_NAME 18 | description = PACK_DESCRIPTION 19 | args_schema = GetProcessStatusArgs 20 | categories = ["Multiprocess"] 21 | 22 | def _run(self, pid: str) -> str: 23 | process = self.body.processes.get(int(pid)) 24 | if not process: 25 | return f"Error: Process {pid} does not exist" 26 | 27 | status = process.poll() 28 | if status is None: 29 | return f"Process {pid} is running" 30 | 31 | return_code = process.returncode 32 | 33 | success_string = "successful" if return_code == 0 else "unsuccessful" 34 | return ( 35 | f"The process has completed. Its exit code indicates it was {success_string}. Output: {process.stdout}. " 36 | f"{process.stderr}" 37 | ) 38 | -------------------------------------------------------------------------------- /beebot/packs/get_webpage_html_content/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/packs/get_webpage_html_content/__init__.py -------------------------------------------------------------------------------- /beebot/packs/get_webpage_html_content/get_webpage_html_content.py: -------------------------------------------------------------------------------- 1 | import aiohttp 2 | import requests 3 | from autopack import Pack 4 | from pydantic import BaseModel, Field 5 | 6 | PACK_DESCRIPTION = ( 7 | "Retrieves the raw HTML content of a specified webpage. It is useful when you specifically require access to the " 8 | "raw HTML of a webpage, and are not interested in its text contents." 9 | ) 10 | 11 | 12 | class GetHtmlContentArgs(BaseModel): 13 | url: str = Field( 14 | ..., 15 | description="The URL of the webpage from which to retrieve the HTML content.", 16 | ) 17 | 18 | 19 | class GetWebpageHtmlContent(Pack): 20 | name = "get_webpage_html_content" 21 | description = PACK_DESCRIPTION 22 | args_schema = GetHtmlContentArgs 23 | categories = ["Web"] 24 | dependencies = ["requests", "aiohttp"] 25 | 26 | filter_threshold: int = Field( 27 | default=0, 28 | description="If given a non-zero value will return only the first N characters", 29 | ) 30 | 31 | def _run(self, url: str) -> str: 32 | response = requests.get(url) 33 | if self.filter_threshold: 34 | return response.text[: self.filter_threshold] 35 | return response.text 36 | 37 | async def _arun(self, url: str) -> str: 38 | async with aiohttp.ClientSession() as session: 39 | async with session.get(url) as response: 40 | text = await response.text() 41 | if self.filter_threshold: 42 | return text[: self.filter_threshold] 43 | return text 44 | -------------------------------------------------------------------------------- /beebot/packs/get_webpage_html_content/test_get_webpage_html_content.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | 3 | from get_webpage_html_content.get_webpage_html_content import GetWebpageHtmlContent 4 | 5 | 6 | def test_webpage_html_content_with_filter(): 7 | # Just a random site with a lot of text 8 | url = "https://en.wikipedia.org/wiki/World_War_II" 9 | pack = GetWebpageHtmlContent(filter_threshold=2000) 10 | response = pack.run(url=url) 11 | assert len(response) == 2000 12 | 13 | 14 | def test_webpage_html_content_without_filter(): 15 | # Just a random site with a lot of text 16 | url = "https://en.wikipedia.org/wiki/World_War_II" 17 | pack = GetWebpageHtmlContent() 18 | response = pack.run(url=url) 19 | assert len(response) > 2000 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_aget_webpage_html_content_with_filter(): 24 | # Just a random site with a lot of text 25 | url = "https://en.wikipedia.org/wiki/World_War_II" 26 | pack = GetWebpageHtmlContent(filter_threshold=2000) 27 | response = await pack.arun(url=url) 28 | assert len(response) == 2000 29 | 30 | 31 | @pytest.mark.asyncio 32 | async def test_aget_webpage_html_content_without_filter(): 33 | # Just a random site with a lot of text 34 | url = "https://en.wikipedia.org/wiki/World_War_II" 35 | pack = GetWebpageHtmlContent() 36 | response = await pack.arun(url=url) 37 | assert len(response) > 2000 38 | -------------------------------------------------------------------------------- /beebot/packs/get_website_text_content.py: -------------------------------------------------------------------------------- 1 | import re 2 | 3 | import requests 4 | from bs4 import BeautifulSoup 5 | from pydantic import BaseModel, Field 6 | 7 | from beebot.packs.system_base_pack import SystemBasePack 8 | from beebot.tool_filters.filter_long_documents import filter_long_documents 9 | 10 | PACK_NAME = "get_website_content" 11 | PACK_DESCRIPTION = ( 12 | "Extracts the text content from the HTML of a specified webpage. It is useful when you want to obtain the textual" 13 | "information from a webpage without the need for in-depth analysis." 14 | ) 15 | 16 | 17 | class GetWebsiteTextContentArgs(BaseModel): 18 | url: str = Field( 19 | ..., 20 | description="The URL of the webpage from which to retrieve the text content.", 21 | ) 22 | 23 | 24 | class GetWebsiteTextContent(SystemBasePack): 25 | name = PACK_NAME 26 | description = PACK_DESCRIPTION 27 | args_schema = GetWebsiteTextContentArgs 28 | categories = ["Web"] 29 | 30 | async def _arun(self, url: str) -> str: 31 | response = requests.get(url) 32 | soup = BeautifulSoup(response.text, "html.parser") 33 | stripped_text = re.sub(r"\s+", " ", soup.get_text().strip()) 34 | return await filter_long_documents(self.body, stripped_text) 35 | -------------------------------------------------------------------------------- /beebot/packs/gmail.py: -------------------------------------------------------------------------------- 1 | from langchain.tools.gmail.create_draft import ( 2 | GmailCreateDraft as CreateDraftTool, 3 | ) 4 | from langchain.tools.gmail.get_message import ( 5 | GmailGetMessage as GetMessageTool, 6 | SearchArgsSchema, 7 | ) 8 | from langchain.tools.gmail.get_thread import ( 9 | GmailGetThread as GetThreadTool, 10 | GetThreadSchema, 11 | ) 12 | from langchain.tools.gmail.search import GmailSearch as SearchTool 13 | from langchain.tools.gmail.send_message import ( 14 | GmailSendMessage as SendMessageTool, 15 | ) 16 | from langchain.tools.gmail.utils import get_gmail_credentials, build_resource_service 17 | from pydantic import BaseModel, Field 18 | 19 | from beebot.config import Config 20 | from beebot.packs.system_base_pack import SystemBasePack 21 | 22 | 23 | # For all of these packs, see: https://developers.google.com/gmail/api/quickstart/python for how to authenticate 24 | 25 | 26 | def credentials(config: Config): 27 | return get_gmail_credentials( 28 | scopes=["https://mail.google.com/"], 29 | client_secrets_file=config.gmail_credentials_file, 30 | ) 31 | 32 | 33 | def api_resource(config: Config): 34 | return build_resource_service(credentials=credentials(config)) 35 | 36 | 37 | class MessageSchema(BaseModel): 38 | """The LangChain schema doesn't convert to OpenAI functions""" 39 | 40 | message: str = Field( 41 | ..., 42 | description="The content to include in the message.", 43 | ) 44 | to: str = Field( 45 | ..., 46 | description="The comma-separated list of recipients.", 47 | ) 48 | subject: str = Field( 49 | ..., 50 | description="The subject of the message.", 51 | ) 52 | cc: str = Field( 53 | None, 54 | description="The comma-separated list of CC recipients.", 55 | ) 56 | bcc: str = Field( 57 | None, 58 | description="The comma-separated list of BCC recipients.", 59 | ) 60 | 61 | 62 | class CreateDraft(SystemBasePack): 63 | name = "gmail_create_draft" 64 | description = "Use Gmail to create a draft email inside of Gmail." 65 | args_schema = MessageSchema 66 | categories = ["Email"] 67 | 68 | def _run(self, *args, **kwargs): 69 | if to_value := kwargs.get("to"): 70 | kwargs["to"] = to_value.split(",") 71 | if to_value := kwargs.get("cc"): 72 | kwargs["cc"] = to_value.split(",") 73 | if to_value := kwargs.get("bcc"): 74 | kwargs["bcc"] = to_value.split(",") 75 | 76 | tool = CreateDraftTool(api_resource=api_resource(self.body.config)) 77 | return tool._run(*args, **kwargs) 78 | 79 | 80 | class GetMessage(SystemBasePack): 81 | name = "gmail_get_message" 82 | description = "Get a Gmail message" 83 | args_schema = SearchArgsSchema 84 | categories = ["Email"] 85 | depends_on = ["gmail_get_thread", "gmail_search"] 86 | 87 | def _run(self, *args, **kwargs): 88 | tool = GetMessageTool(api_resource=api_resource(self.body.config)) 89 | return tool._run(*args, **kwargs) 90 | 91 | 92 | class GetThread(SystemBasePack): 93 | name = "gmail_get_thread" 94 | description = "Get a Gmail thread" 95 | args_schema = GetThreadSchema 96 | categories = ["Email"] 97 | depends_on = ["gmail_get_message", "gmail_search"] 98 | 99 | def _run(self, *args, **kwargs): 100 | tool = GetThreadTool(api_resource=api_resource(self.body.config)) 101 | return tool._run(*args, **kwargs) 102 | 103 | 104 | class Search(SystemBasePack): 105 | name = "gmail_search" 106 | description = "Search for Gmail messages and threads" 107 | args_schema = SearchArgsSchema 108 | categories = ["Email"] 109 | depends_on = ["gmail_get_thread", "gmail_get_message"] 110 | 111 | def _run(self, *args, **kwargs): 112 | tool = SearchTool(api_resource=api_resource(self.body.config)) 113 | return tool._run(*args, **kwargs) 114 | 115 | 116 | class SendMessage(SystemBasePack): 117 | name = "gmail_send_message" 118 | description = "Send an email with Gmail" 119 | args_schema = MessageSchema 120 | categories = ["Email"] 121 | 122 | reversible = False 123 | 124 | def _run(self, *args, **kwargs): 125 | if to_value := kwargs.get("to"): 126 | kwargs["to"] = to_value.split(",") 127 | if to_value := kwargs.get("cc"): 128 | kwargs["cc"] = to_value.split(",") 129 | if to_value := kwargs.get("bcc"): 130 | kwargs["bcc"] = to_value.split(",") 131 | 132 | tool = SendMessageTool(api_resource=api_resource(self.body.config)) 133 | return tool._run(*args, **kwargs) 134 | -------------------------------------------------------------------------------- /beebot/packs/google_search/__init__.py: -------------------------------------------------------------------------------- 1 | from .google_search import GoogleSearch 2 | 3 | __all__ = ["GoogleSearch"] 4 | -------------------------------------------------------------------------------- /beebot/packs/google_search/google_search.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from autopack import Pack 4 | from langchain import GoogleSerperAPIWrapper 5 | from pydantic import BaseModel, Field 6 | 7 | PACK_DESCRIPTION = ( 8 | "Search Google for websites matching a given query. Useful for when you need to answer questions " 9 | "about current events." 10 | ) 11 | 12 | 13 | class GoogleSearchArgs(BaseModel): 14 | query: str = Field(..., description="The query string") 15 | 16 | 17 | class GoogleSearch(Pack): 18 | name = "google_search" 19 | description = PACK_DESCRIPTION 20 | args_schema = GoogleSearchArgs 21 | categories = ["Web"] 22 | 23 | def _run(self, query: str) -> str: 24 | if not os.environ.get("SERPER_API_KEY"): 25 | return f"Google Search is not supported as the SERPER_API_KEY environment variable is not set" 26 | try: 27 | return format_results(GoogleSerperAPIWrapper().results(query).get("organic", [])) 28 | 29 | except Exception as e: 30 | return f"Error: {e}" 31 | 32 | async def _arun(self, query: str) -> str: 33 | if not os.environ.get("SERPER_API_KEY"): 34 | return f"Google Search is not supported as the SERPER_API_KEY environment variable is not set" 35 | try: 36 | query_results = await GoogleSerperAPIWrapper().aresults(query) 37 | return format_results(query_results.get("organic", [])) 38 | 39 | except Exception as e: 40 | return f"Error: {e}" 41 | 42 | 43 | def format_results(results: list[dict[str, str]]) -> str: 44 | formatted_results = [] 45 | for result in results: 46 | formatted_results.append(f"{result.get('link')}: {result.get('snippet')}") 47 | 48 | return f"Your search results are: {' | '.join(formatted_results)}" 49 | -------------------------------------------------------------------------------- /beebot/packs/google_search/test_google_search.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import patch 3 | 4 | import pytest 5 | 6 | from google_search import GoogleSearch 7 | 8 | 9 | def test_search_no_api_key(): 10 | results = GoogleSearch().run(query="bbc news website") 11 | assert "not supported" in results 12 | 13 | 14 | def test_search_sync(): 15 | os.environ["SERPER_API_KEY"] = "1234" 16 | with patch("langchain.GoogleSerperAPIWrapper.results") as mock_load: 17 | mock_load.return_value = { 18 | "organic": [ 19 | { 20 | "link": "https://www.bbc.com/news", 21 | "snippet": "Visit BBC News for up-to-the-minute news, breaking news, video, audio and feature " 22 | "stories. BBC News provides trusted World and UK news as well as local and ...", 23 | }, 24 | ] 25 | } 26 | results = GoogleSearch().run(query="bbc news website") 27 | 28 | assert "https://www.bbc.com/news" in results 29 | assert "breaking news" in results 30 | 31 | 32 | @pytest.mark.asyncio 33 | async def test_search_async(): 34 | async def async_results(self, query): 35 | return { 36 | "organic": [ 37 | { 38 | "link": "https://www.bbc.com/news", 39 | "snippet": "Visit BBC News for up-to-the-minute news, breaking news, video, audio and feature " 40 | "stories. BBC News provides trusted World and UK news as well as local and ...", 41 | }, 42 | ] 43 | } 44 | 45 | os.environ["SERPER_API_KEY"] = "1234" 46 | with patch( 47 | "langchain.GoogleSerperAPIWrapper.aresults", new=async_results 48 | ) as mock_load: 49 | results = await GoogleSearch().arun(query="bbc news website") 50 | 51 | assert "https://www.bbc.com/news" in results 52 | assert "breaking news" in results 53 | -------------------------------------------------------------------------------- /beebot/packs/http_request/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/packs/http_request/__init__.py -------------------------------------------------------------------------------- /beebot/packs/http_request/http_request.py: -------------------------------------------------------------------------------- 1 | import json 2 | 3 | import aiohttp 4 | import requests 5 | from autopack import Pack 6 | from pydantic import BaseModel, Field 7 | 8 | PACK_DESCRIPTION = ( 9 | "Makes an HTTP request and returns the raw response. This function should be used for basic GET or " 10 | "POST requests and is not intended for fetching web pages or performing complex operations." 11 | ) 12 | 13 | 14 | class HttpRequestArgs(BaseModel): 15 | url: str = Field(..., description="URL of the resource to request.") 16 | method: str = Field( 17 | description="The HTTP method to use for the request (e.g., GET, POST). Defaults to GET.", 18 | default="GET", 19 | ) 20 | data: str = Field(description="Data to send with the request (for POST requests).", default="") 21 | headers: str = Field( 22 | description="JSON Encoded headers to include in the request.", 23 | default_factory=dict, 24 | ) 25 | 26 | 27 | class HttpRequest(Pack): 28 | name = "http_request" 29 | description = PACK_DESCRIPTION 30 | args_schema = HttpRequestArgs 31 | categories = ["Web"] 32 | 33 | def _run(self, url: str, method: str = "GET", data: str = None, headers: str = None) -> str: 34 | headers_dict = {} 35 | if headers: 36 | headers_dict = json.loads(headers) 37 | response = requests.request(method, url, headers=headers_dict, data=data) 38 | return f"HTTP Response {response.status_code}: {response.content}" 39 | 40 | async def _arun(self, url: str, method: str = "GET", data: str = None, headers: str = None) -> str: 41 | headers_dict = {} 42 | if headers: 43 | headers_dict = json.loads(headers) 44 | 45 | async with aiohttp.ClientSession() as session: 46 | async with session.request(method, url, data=data, headers=headers_dict) as response: 47 | text = await response.text() 48 | return f"HTTP Response {response.status}: {text}" 49 | -------------------------------------------------------------------------------- /beebot/packs/http_request/test_http_request.py: -------------------------------------------------------------------------------- 1 | from html_request.http_request import HttpRequest 2 | 3 | 4 | def test_http_request_get(): 5 | url = "https://google.com" 6 | pack = HttpRequest() 7 | response = pack.run(url=url) 8 | assert "Google Search" in response 9 | 10 | 11 | def test_http_request_get(): 12 | url = "https://google.com" 13 | pack = HttpRequest() 14 | response = pack.run(url=url) 15 | assert "Google Search" in response 16 | -------------------------------------------------------------------------------- /beebot/packs/install_python_package.py: -------------------------------------------------------------------------------- 1 | import subprocess 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | from beebot.body.pack_utils import init_workspace_poetry 6 | from beebot.packs.system_base_pack import SystemBasePack 7 | 8 | """This will use a poetry venv specifically for the beebot workspace. However, all normal caveats apply regarding 9 | downloading and executing remote code.""" 10 | 11 | PACK_NAME = "install_python_package" 12 | 13 | PACK_DESCRIPTION = "Installs Python packages from PyPi using Poetry." 14 | 15 | 16 | class InstallPythonPackageArgs(BaseModel): 17 | package_name: str = Field( 18 | ..., description="The name of the Python package to be installed" 19 | ) 20 | 21 | 22 | class InstallPythonPackage(SystemBasePack): 23 | name = PACK_NAME 24 | description = PACK_DESCRIPTION 25 | args_schema = InstallPythonPackageArgs 26 | categories = ["Programming"] 27 | 28 | def _run(self, package_name: str) -> str: 29 | if self.body.config.restrict_code_execution: 30 | return "Error: Installing Python packages is not allowed" 31 | try: 32 | # Make sure poetry is init'd in the workspace. This errors if it's already init'd so yolo 33 | init_workspace_poetry(self.config.workspace_path) 34 | cmd = ["poetry", "add", package_name] 35 | 36 | process = subprocess.run( 37 | cmd, 38 | universal_newlines=True, 39 | cwd=self.body.config.workspace_path, 40 | stdout=subprocess.PIPE, 41 | stderr=subprocess.PIPE, 42 | ) 43 | if process.returncode: 44 | return f"Error: {process.stdout}. {process.stderr}." 45 | 46 | return f"{package_name} is installed." 47 | except (SystemExit, KeyboardInterrupt): 48 | raise 49 | except BaseException as e: 50 | return f"Error: {e}" 51 | -------------------------------------------------------------------------------- /beebot/packs/kill_process.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel, Field 2 | 3 | from beebot.packs.system_base_pack import SystemBasePack 4 | 5 | PACK_NAME = "kill_process" 6 | PACK_DESCRIPTION = "Terminates the process with the given PID and returns its output" 7 | 8 | 9 | class KillProcessArgs(BaseModel): 10 | pid: str = Field(..., description="The PID") 11 | 12 | 13 | class KillProcess(SystemBasePack): 14 | name = PACK_NAME 15 | description = PACK_DESCRIPTION 16 | args_schema = KillProcessArgs 17 | categories = ["Multiprocess"] 18 | 19 | def _run(self, pid: str) -> dict[str, str]: 20 | # TODO: Support for daemonized processes from previous runs 21 | process = self.body.processes.get(int(pid)) 22 | if not process: 23 | return "Error: Process does not exist." 24 | 25 | status = process.poll() 26 | if status is None: 27 | process.kill() 28 | 29 | return ( 30 | f"The process has been killed. Output: {process.stdout}. {process.stderr}" 31 | ) 32 | -------------------------------------------------------------------------------- /beebot/packs/list_processes.py: -------------------------------------------------------------------------------- 1 | from pydantic import BaseModel 2 | 3 | from beebot.packs.system_base_pack import SystemBasePack 4 | 5 | PACK_NAME = "list_processes" 6 | PACK_DESCRIPTION = "Lists the actively running processes and their status." 7 | 8 | 9 | class ListProcessesArgs(BaseModel): 10 | pass 11 | 12 | 13 | class ListProcesses(SystemBasePack): 14 | name = PACK_NAME 15 | description = PACK_DESCRIPTION 16 | args_schema = ListProcessesArgs 17 | categories = ["Multiprocess"] 18 | 19 | def _run(self) -> str: 20 | running_processes = [ 21 | f"PID {pid}: running." 22 | for (pid, process) in self.body.processes.items() 23 | if process.poll() is None 24 | ] 25 | 26 | if running_processes: 27 | return " ".join(running_processes) 28 | 29 | return "No processes running" 30 | -------------------------------------------------------------------------------- /beebot/packs/os_info/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/packs/os_info/__init__.py -------------------------------------------------------------------------------- /beebot/packs/os_info/os_info.py: -------------------------------------------------------------------------------- 1 | import platform 2 | 3 | from autopack import Pack 4 | from pydantic import BaseModel 5 | 6 | PACK_DESCRIPTION = "Get the name and version of the operating system you are running in." 7 | 8 | 9 | class OSInfoArgs(BaseModel): 10 | pass 11 | 12 | 13 | class OSInfo(Pack): 14 | name = "os_name_and_version" 15 | description = PACK_DESCRIPTION 16 | args_schema = OSInfoArgs 17 | categories = ["System Info"] 18 | 19 | def _run(self) -> str: 20 | return f"OS Name {platform.system()}. OS Version: {platform.release()}." 21 | 22 | async def _arun(self) -> str: 23 | return self._run() 24 | -------------------------------------------------------------------------------- /beebot/packs/os_info/test_os_info.py: -------------------------------------------------------------------------------- 1 | from os_info.os_info import OSInfo 2 | 3 | 4 | def test_os_info(): 5 | info = OSInfo().run() 6 | assert "OS Name" in info 7 | assert "OS Version" in info 8 | -------------------------------------------------------------------------------- /beebot/packs/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "official" 3 | version = "0.1.0" 4 | description = "Offical Packs for AutoPack" 5 | authors = ["Erik Peterson "] 6 | license = "MIT" 7 | readme = "README.md" 8 | 9 | [tool.poetry.dependencies] 10 | python = "^3.10" 11 | autopack-tools = ">=0.3.0" 12 | wikipedia = "^1.4.0" 13 | psutil = "^5.9.5" 14 | wolframalpha = "^5.0.0" 15 | playwright = "^1.36.0" 16 | beautifulsoup4 = "^4.12.2" 17 | 18 | 19 | [tool.poetry.group.dev.dependencies] 20 | black = "^23.7.0" 21 | pytest-asyncio = "^0.21.1" 22 | 23 | [build-system] 24 | requires = ["poetry-core"] 25 | build-backend = "poetry.core.masonry.api" 26 | -------------------------------------------------------------------------------- /beebot/packs/summarization.py: -------------------------------------------------------------------------------- 1 | SUMMARIZATION_TEMPLATE = """Make the following text more concise, ensuring the final output is no more than {filter_threshold} characters long. 2 | 3 | Simplify the language used. Replace long phrases with shorter synonyms, remove unnecessary adverbs and adjectives, or rephrase sentences to make them more concise. 4 | 5 | Remove redundancies, especially those written informally or conversationally, using repetitive information or phrases. 6 | 7 | Flatten text that includes nested hierarchical information that isn't crucial for understanding. 8 | 9 | Extract text content out of HTML tags. 10 | 11 | Retain key details such as file names, IDs, people, places, and important events: 12 | 13 | {long_text}""" 14 | 15 | 16 | def _filter_long_documents(self, document: str) -> str: 17 | # TODO: Configurable limit or like configurable turn it off? 18 | if len(document) > 1000: 19 | summary_prompt = SUMMARIZATION_TEMPLATE.format( 20 | long_text=document[:8000], filter_threshold=self.filter_threshold 21 | ) 22 | summarization = call_llm(summary_prompt, llm=self.llm) 23 | return f"The response was summarized as: {summarization}" 24 | 25 | return document 26 | 27 | 28 | async def afilter_long_documents( 29 | self, 30 | document: str, 31 | llm: Callable[[str], str], 32 | allm: Callable[[str], Awaitable[str]], 33 | ) -> str: 34 | pass 35 | -------------------------------------------------------------------------------- /beebot/packs/summarization_prompt.py: -------------------------------------------------------------------------------- 1 | from langchain.prompts import SystemMessagePromptTemplate 2 | 3 | TEMPLATE = """Make the following text more concise, ensuring the final output is no more than 2000 characters long. 4 | 5 | Simplify the language used. Replace long phrases with shorter synonyms, remove unnecessary adverbs and adjectives, or rephrase sentences to make them more concise. 6 | 7 | Remove redundancies, especially those written informally or conversationally, using repetitive information or phrases. 8 | 9 | Flatten text that includes nested hierarchical information that isn't crucial for understanding. 10 | 11 | Extract text content out of HTML tags. 12 | 13 | Retain key details such as file names, IDs, people, places, and important events: 14 | 15 | {long_text}""" 16 | 17 | 18 | def summarization_prompt_template() -> SystemMessagePromptTemplate: 19 | return SystemMessagePromptTemplate.from_template(TEMPLATE) 20 | -------------------------------------------------------------------------------- /beebot/packs/system_base_pack.py: -------------------------------------------------------------------------------- 1 | from autopack import Pack 2 | from autopack.utils import run_args_from_args_schema 3 | 4 | from beebot.body import Body 5 | from beebot.body.pack_utils import llm_wrapper 6 | 7 | 8 | class SystemBasePack(Pack): 9 | arbitrary_types_allowed = True 10 | 11 | body: Body 12 | 13 | def __init__(self, **kwargs): 14 | llm = llm_wrapper(kwargs.get("body")) 15 | 16 | run_args = {} 17 | if args_schema := kwargs.get("args_schema"): 18 | run_args = run_args_from_args_schema(args_schema) 19 | 20 | super().__init__(llm=llm, allm=llm, run_args=run_args, **kwargs) 21 | 22 | def _run(self, *args, **kwargs): 23 | raise NotImplementedError 24 | 25 | async def _arun(self, *args, **kwargs) -> str: 26 | return self._run(*args, **kwargs) 27 | -------------------------------------------------------------------------------- /beebot/packs/wikipedia_summarize/__init__.py: -------------------------------------------------------------------------------- 1 | from .wikipedia import WikipediaPack 2 | 3 | _all__ = ["WikipediaPack"] 4 | -------------------------------------------------------------------------------- /beebot/packs/wikipedia_summarize/test_wikipedia.py: -------------------------------------------------------------------------------- 1 | from unittest.mock import patch 2 | 3 | import pytest 4 | from langchain.schema import Document 5 | 6 | from wikipedia_summarize import WikipediaPack 7 | 8 | 9 | def test_sync_llm(): 10 | def mock_llm(text_in: str): 11 | return text_in.strip().split("\n")[-1] 12 | 13 | pack = WikipediaPack(llm=mock_llm) 14 | with patch("langchain.WikipediaAPIWrapper.load") as mock_load: 15 | # Set the return value of the mock object 16 | page = Document(page_content="Page content", metadata={"title": "A page"}) 17 | mock_load.return_value = [page] 18 | 19 | assert pack.run(query="some text", question="asdf") == "Page content" 20 | 21 | 22 | @pytest.mark.asyncio 23 | async def test_async_llm(): 24 | async def mock_llm(text_in: str): 25 | return text_in.strip().split("\n")[-1] 26 | 27 | pack = WikipediaPack(allm=mock_llm) 28 | with patch("langchain.WikipediaAPIWrapper.load") as mock_load: 29 | # Set the return value of the mock object 30 | page = Document(page_content="Page content", metadata={"title": "A page"}) 31 | mock_load.return_value = [page] 32 | 33 | assert await pack.arun(query="some text", question="asdf") == "Page content" 34 | -------------------------------------------------------------------------------- /beebot/packs/wikipedia_summarize/wikipedia.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | from typing import Union 4 | 5 | import wikipedia 6 | from autopack.utils import acall_llm 7 | from pydantic import BaseModel, Field 8 | from wikipedia import WikipediaPage 9 | 10 | from beebot.packs.system_base_pack import SystemBasePack 11 | 12 | PACK_DESCRIPTION = ( 13 | "Searches Wikipedia based on a question and then analyzes the results. Enables quick access to factual knowledge. " 14 | "Useful for when you need to answer general questions about people, places, companies, facts, historical events, " 15 | "or other subjects." 16 | ) 17 | 18 | PROMPT_TEMPLATE = """Provide an answer to the following question: {question} 19 | 20 | The following Wikipedia pages may also be used for reference: 21 | {pages} 22 | """ 23 | 24 | logger = logging.getLogger(__name__) 25 | 26 | 27 | class WikipediaArgs(BaseModel): 28 | question_to_ask: str = Field(..., description="A question to ask") 29 | 30 | 31 | def fetch_page(page_title: str) -> Union[WikipediaPage, None]: 32 | try: 33 | return wikipedia.page(title=page_title) 34 | except BaseException as e: 35 | logger.warning(f"Could not fetch wikipedia page with title {page_title}: {e}") 36 | return None 37 | 38 | 39 | async def get_page(page_title: str) -> WikipediaPage: 40 | loop = asyncio.get_event_loop() 41 | return await loop.run_in_executor(None, fetch_page, page_title) 42 | 43 | 44 | async def get_pages(query: str) -> str: 45 | page_titles = wikipedia.search(query, results=5) 46 | pages = await asyncio.gather(*(get_page(title) for title in page_titles)) 47 | 48 | result = [] 49 | for page in pages: 50 | if page: 51 | result.append(f"-- Page: {page.title}\n{page.summary}") 52 | 53 | return "\n".join(result) 54 | 55 | 56 | class WikipediaPack(SystemBasePack): 57 | name = "wikipedia" 58 | description = PACK_DESCRIPTION 59 | args_schema = WikipediaArgs 60 | dependencies = ["wikipedia"] 61 | categories = ["Information"] 62 | 63 | async def _arun( 64 | self, 65 | question_to_ask: str, 66 | ): 67 | try: 68 | pages = await get_pages(question_to_ask) 69 | prompt = PROMPT_TEMPLATE.format(question=question_to_ask, pages=pages) 70 | response = await acall_llm(prompt, self.allm) 71 | return response 72 | except Exception as e: 73 | return f"Error: {e}" 74 | -------------------------------------------------------------------------------- /beebot/packs/wolframalpha_query/__init__.py: -------------------------------------------------------------------------------- 1 | from .wolframalpha_query import WolframAlphaQuery 2 | 3 | __all__ = ["WolframAlphaQuery"] 4 | -------------------------------------------------------------------------------- /beebot/packs/wolframalpha_query/test_wolframalpha_query.py: -------------------------------------------------------------------------------- 1 | import os 2 | from unittest.mock import patch 3 | 4 | from wolframalpha_query import WolframAlphaQuery 5 | 6 | 7 | def test_wolframalpha_no_appid(): 8 | pack = WolframAlphaQuery() 9 | assert "not supported" in pack.run(query="population of united states") 10 | 11 | 12 | def test_wolframalpha_with_appid(): 13 | os.environ["WOLFRAM_ALPHA_APPID"] = "1234" 14 | pack = WolframAlphaQuery() 15 | 16 | with patch("langchain.WolframAlphaAPIWrapper.run") as mock_load: 17 | mock_load.return_value = "331.9 million" 18 | assert "331.9 million" in pack.run(query="population of united states") 19 | -------------------------------------------------------------------------------- /beebot/packs/wolframalpha_query/wolframalpha_query.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from autopack import Pack 4 | from langchain import WolframAlphaAPIWrapper 5 | from pydantic import BaseModel, Field 6 | 7 | PACK_DESCRIPTION = ( 8 | "Query Wolfram Alpha, a computational knowledge engine, to obtain answers to a wide range of factual and " 9 | "computational questions. It leverages Wolfram Alpha's extensive knowledge base to provide detailed and accurate " 10 | "responses. Useful for when you need to answer questions about Math, Science, Technology, and Everyday Life." 11 | ) 12 | 13 | 14 | class WolframAlphaArgs(BaseModel): 15 | query: str = Field( 16 | ..., 17 | description="Specifies the question or topic for which information is sought.", 18 | ) 19 | 20 | 21 | class WolframAlphaQuery(Pack): 22 | name = "wolfram_alpha_query" 23 | description = PACK_DESCRIPTION 24 | args_schema = WolframAlphaArgs 25 | dependencies = ["wolframalpha_query"] 26 | categories = ["Information"] 27 | 28 | def _run(self, query: str) -> list[str]: 29 | if not os.environ.get("WOLFRAM_ALPHA_APPID"): 30 | return f"WolframAlpha is not supported as the WOLFRAM_ALPHA_APPID environment variable is not set" 31 | 32 | return WolframAlphaAPIWrapper().run(query) 33 | 34 | async def _arun(self, query: str) -> list[str]: 35 | return self._run(query) 36 | -------------------------------------------------------------------------------- /beebot/packs/write_python_code/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/packs/write_python_code/__init__.py -------------------------------------------------------------------------------- /beebot/packs/write_python_code/test_write_python_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | from write_python_code.write_python_file import WritePythonCode 4 | 5 | 6 | def test_write_python_file_success(): 7 | pack = WritePythonCode(workspace_path="workspace") 8 | code = "print('Hello world!')" 9 | file_name = "hello_world.py" 10 | result = pack.run(file_name=file_name, code=code) 11 | 12 | assert result == f"Compiled successfully and saved to hello_world.py." 13 | 14 | with open(os.path.join("workspace", file_name), "r") as f: 15 | assert f.read() == code 16 | 17 | 18 | def test_write_python_file_compile_error(): 19 | pack = WritePythonCode(workspace_path="workspace") 20 | code = "asdf!" 21 | file_name = "error.py" 22 | result = pack.run(file_name=file_name, code=code) 23 | 24 | assert "invalid syntax" in result 25 | 26 | assert not os.path.exists(os.path.join("workspace", file_name)) 27 | -------------------------------------------------------------------------------- /beebot/packs/write_python_code/write_python_file.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from autopack import Pack 5 | from pydantic import BaseModel, Field 6 | 7 | from beebot.utils import restrict_path 8 | 9 | # IMPORTANT NOTE: This does NOT actually restrict the execution environment, it just nudges the AI to avoid doing 10 | # those things. 11 | PACK_DESCRIPTION = ( 12 | "Write Python code to a specified file. Will compile the code and check for any syntax errors. If " 13 | "there are no syntax errors, the code will be saved to the specified file. The function returns a " 14 | "string indicating the presence of any syntax errors in the code. However, it does not execute the " 15 | "code." 16 | ) 17 | 18 | 19 | class WritePythonCodeArgs(BaseModel): 20 | file_name: str = Field( 21 | ..., 22 | description="The name of the file to be created or overwritten", 23 | ) 24 | code: str = Field( 25 | ..., 26 | description="The Python code as a string.", 27 | ) 28 | 29 | 30 | class WritePythonCode(Pack): 31 | name = "write_python_code" 32 | description = PACK_DESCRIPTION 33 | args_schema = WritePythonCodeArgs 34 | categories = ["Programming"] 35 | reversible = False 36 | 37 | def check_file(self, file_name, code: str = "") -> str: 38 | file_path = os.path.join(self.config.workspace_path, file_name) 39 | 40 | try: 41 | abs_path = restrict_path(file_path, self.config.workspace_path) 42 | if not abs_path: 43 | return "Error: File not found" 44 | 45 | with open(file_path, "w+") as f: 46 | f.write(code) 47 | 48 | cmd = ["python", "-m", "py_compile", abs_path] 49 | process = subprocess.run( 50 | cmd, 51 | stdout=subprocess.PIPE, 52 | stderr=subprocess.PIPE, 53 | universal_newlines=True, 54 | cwd=self.config.workspace_path, 55 | ) 56 | output = "\n".join([process.stdout.strip(), process.stderr.strip()]).strip() 57 | 58 | if process.returncode: 59 | os.unlink(file_path) 60 | return f"Compile error: {output}." 61 | 62 | return f"Compiled successfully and saved to {file_name}." 63 | 64 | except Exception as e: 65 | os.unlink(file_path) 66 | return f"Error: {e.__class__.__name__} {e}" 67 | 68 | def _run(self, file_name: str, code: str = "") -> str: 69 | result = self.check_file(file_name, code) 70 | self.filesystem_manager.write_file(file_name, code) 71 | return result 72 | 73 | async def _arun(self, file_name: str, code: str = "") -> str: 74 | result = self.check_file(file_name, code) 75 | await self.filesystem_manager.awrite_file(file_name, code) 76 | return result 77 | -------------------------------------------------------------------------------- /beebot/planner/__init__.py: -------------------------------------------------------------------------------- 1 | from .planner import Planner 2 | 3 | __all__ = ["Planner"] 4 | -------------------------------------------------------------------------------- /beebot/planner/planner.py: -------------------------------------------------------------------------------- 1 | import logging 2 | from typing import TYPE_CHECKING 3 | 4 | from langchain.chat_models.base import BaseChatModel 5 | 6 | from beebot.body.llm import call_llm 7 | from beebot.models.database_models import Plan 8 | 9 | if TYPE_CHECKING: 10 | from beebot.execution.task_execution import TaskExecution 11 | 12 | logger = logging.getLogger(__name__) 13 | 14 | 15 | class Planner: 16 | llm: BaseChatModel 17 | 18 | task_execution: "TaskExecution" 19 | 20 | def __init__(self, task_execution: "TaskExecution"): 21 | self.task_execution = task_execution 22 | 23 | async def plan(self) -> Plan: 24 | prompt, prompt_variables = await self.task_execution.agent.planning_prompt() 25 | 26 | logger.info("\n=== Plan Request ===") 27 | logger.info(prompt) 28 | 29 | response = await call_llm( 30 | self.task_execution.body, 31 | message=prompt, 32 | function_call="none", 33 | include_functions=True, 34 | ) 35 | 36 | logger.info("\n=== Plan Created ===") 37 | logger.info(response.text) 38 | 39 | plan = Plan( 40 | prompt_variables=prompt_variables, 41 | plan_text=response.text, 42 | llm_response=response.text, 43 | ) 44 | await plan.save() 45 | return plan 46 | -------------------------------------------------------------------------------- /beebot/planner/planning_prompt.py: -------------------------------------------------------------------------------- 1 | PLANNING_PROMPT_TEMPLATE = """As the AI Task Strategist, your role is to strategize and plan the execution of tasks efficiently and effectively. Avoid redundancy, such as unnecessary immediate verification of actions. You only speak English and do not have the capability to write code. 2 | 3 | # Functions 4 | ## The AI Assistant can call only these functions: 5 | {functions}. 6 | 7 | Once the original task has been completed, instruct the AI Assistant to call the `exit` function with all arguments to indicate the completion of the task. 8 | 9 | # Task 10 | ## Your original task, given by the human, is: 11 | {task} 12 | 13 | {history} 14 | {variables} 15 | {file_list} 16 | 17 | # Instructions 18 | ## Now, devise a concise and adaptable plan to guide the AI Assistant. Follow these guidelines: 19 | 20 | 1. Ensure you interpret the execution history correctly while considering the order of execution. Avoid repetitive actions, e.g. if the same file has been read previously and the content hasn't changed. 21 | 2. Regularly evaluate your progress towards the task goal. This includes checking the current state of the system against the task requirements and adjusting your strategy if necessary. 22 | 3. If an error occurs (like 'File not found'), take a step back and analyze if it's an indicator of the next required action (like creating the file). Avoid getting stuck in loops by not repeating the action that caused the error without modifying the approach. 23 | 4. Recognize when the task has been successfully completed according to the defined goal and exit conditions. If the task has been completed, instruct the AI Assistant to call the `exit` function. 24 | 5. Determine the most efficient next action towards completing the task, considering your current information, requirements, and available functions. 25 | 6. Direct the execution of the immediate next action using exactly one of the callable functions, making sure to skip any redundant actions that are already confirmed by the historical context. 26 | 27 | Provide a concise analysis of the past history, followed by an overview of your plan going forward, and end with one sentence describing the immediate next action to be taken.""" 28 | 29 | 30 | def planning_prompt_template() -> str: 31 | return PLANNING_PROMPT_TEMPLATE 32 | -------------------------------------------------------------------------------- /beebot/tool_filters/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/beebot/tool_filters/__init__.py -------------------------------------------------------------------------------- /beebot/tool_filters/filter_long_documents.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING 2 | 3 | from beebot.body.llm import call_llm 4 | from beebot.packs.summarization_prompt import summarization_prompt_template 5 | 6 | if TYPE_CHECKING: 7 | from beebot.body import Body 8 | 9 | 10 | async def filter_long_documents(body: "Body", document: str) -> str: 11 | # TODO: Configurable limit or like configurable turn it off? 12 | if len(document) > 1000: 13 | summary_prompt = ( 14 | summarization_prompt_template().format(long_text=document[:8000]).content 15 | ) 16 | summarization = await call_llm(body, summary_prompt) 17 | return f"The response was summarized as: {summarization.text}" 18 | 19 | return document 20 | -------------------------------------------------------------------------------- /beebot/utils.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import os 3 | 4 | logger = logging.getLogger(__name__) 5 | 6 | 7 | def restrict_path(file_path: str, workspace_dir: str): 8 | absolute_path = os.path.abspath(file_path) 9 | relative_path = os.path.relpath(absolute_path, workspace_dir) 10 | 11 | if relative_path.startswith("..") or "/../" in relative_path: 12 | return None 13 | 14 | return absolute_path 15 | -------------------------------------------------------------------------------- /docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: "3.7" 2 | services: 3 | postgres: 4 | image: postgres:latest 5 | restart: always 6 | environment: 7 | - POSTGRES_USER=postgres 8 | - POSTGRES_PASSWORD=postgres 9 | - POSTGRES_DB=postgres 10 | ports: 11 | - "5432:5432" 12 | volumes: 13 | - ./postgres-data:/var/lib/postgresql/data 14 | -------------------------------------------------------------------------------- /docs/architecture.md: -------------------------------------------------------------------------------- 1 | # BeeBot's Architecture 2 | 3 | ## Introduction 4 | 5 | BeeBot is an Autonomous AI Assistant is designed to perform a range of tasks autonomously, taking high-level 6 | instructions from human users and transforming them into actions which are then performed. BeeBot's capabilities are 7 | based on the underlying components: Planner, Decider, Executor, and Body. These components work in sequence 8 | in a state machine, described below. 9 | 10 | ## Major Components 11 | 12 | ### Planner 13 | 14 | The Planner is responsible for taking the human task and the previous outputs, and creating a Plan object for it. This 15 | is done through an LLM that can interpret the requirements of the task and the context provided by 16 | previous outputs. The Planner thus plays a vital role in interpreting and organizing tasks, setting the stage for their 17 | execution. 18 | 19 | ### Decider 20 | 21 | The Decider takes a Plan, which may or may not have been amended by an Oversight. It assesses the Plan and makes a 22 | Decision, again through the LLM, about what action the system should take next. The Decision maps to exactly one 23 | function call. 24 | 25 | ### Executor 26 | 27 | The Executor is the "action" component of the system. Once a Decision has been made about the next step, the Executor 28 | takes this Decision and performs the necessary action. It also creates an Observation based on the results of the 29 | action. This Observation is a structured record of what occurred during execution. 30 | 31 | ### Body 32 | 33 | The Body is a container for the Planner, Decider, and Executor components. It also holds memories and 34 | configuration settings that help the Assistant adapt and optimize its functioning. The Body manages the data flow 35 | between the other components and also holds the State Machine that governs the operation of the entire system. 36 | 37 | ## Operational Workflow (State Machine) 38 | 39 | ``` 40 | [ Setup ] 41 | | 42 | | start 43 | v 44 | [ Oversight ]<------+ 45 | | | 46 | | decide | 47 | v | 48 | [ Deciding ] [Planning] 49 | | | 50 | | execute | 51 | v | 52 | [ Executing ]-------+ 53 | | plan 54 | | finish 55 | v 56 | [ Done ] 57 | 58 | ``` 59 | 60 | These state transitions are governed by events or conditions such as `start`, `plan`, `decide`, `execute`, `oversee`, 61 | and `finish`. These dictate how the Assistant moves from one state to the next, and how the Planner, Decider, and 62 | Executor components interact. -------------------------------------------------------------------------------- /migrations/20230717_01_initial_schema.sql: -------------------------------------------------------------------------------- 1 | -- 2 | -- depends: 3 | 4 | CREATE TABLE body ( 5 | id SERIAL PRIMARY KEY, 6 | task TEXT NOT NULL, 7 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 8 | updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 9 | ); 10 | 11 | CREATE TABLE task_execution ( 12 | id SERIAL PRIMARY KEY, 13 | body_id INT REFERENCES body(id), 14 | agent TEXT NOT NULL, 15 | state TEXT NOT NULL DEFAULT 'waiting', 16 | instructions TEXT NOT NULL, 17 | complete BOOLEAN NOT NULL DEFAULT FALSE, 18 | inputs JSONB, 19 | outputs JSONB, 20 | variables JSONB, 21 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 22 | updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 23 | ); 24 | 25 | CREATE TABLE oversight ( 26 | id SERIAL PRIMARY KEY, 27 | original_plan_text TEXT NOT NULL, 28 | modified_plan_text TEXT NOT NULL, 29 | modifications JSONB, 30 | llm_response TEXT, 31 | prompt_variables JSONB, 32 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 33 | updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 34 | ); 35 | 36 | CREATE TABLE decision ( 37 | id SERIAL PRIMARY KEY, 38 | tool_name TEXT NOT NULL, 39 | tool_args JSONB, 40 | prompt_variables JSONB, 41 | llm_response TEXT, 42 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 43 | updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 44 | ); 45 | 46 | CREATE TABLE observation ( 47 | id SERIAL PRIMARY KEY, 48 | response TEXT, 49 | error_reason TEXT, 50 | success BOOLEAN DEFAULT TRUE, 51 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 52 | updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 53 | ); 54 | 55 | CREATE TABLE plan ( 56 | id SERIAL PRIMARY KEY, 57 | plan_text TEXT NOT NULL, 58 | prompt_variables JSONB, 59 | llm_response TEXT, 60 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 61 | updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 62 | ); 63 | 64 | CREATE TABLE step ( 65 | id SERIAL PRIMARY KEY, 66 | task_execution_id INT REFERENCES task_execution(id), 67 | plan_id INT REFERENCES plan(id), 68 | oversight_id INT REFERENCES oversight(id), 69 | decision_id INT REFERENCES decision(id), 70 | observation_id INT REFERENCES observation(id), 71 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 72 | updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 73 | ); 74 | 75 | CREATE TABLE document ( 76 | id SERIAL PRIMARY KEY, 77 | name TEXT NOT NULL, 78 | content TEXT NOT NULL, 79 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 80 | updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 81 | ); 82 | 83 | CREATE INDEX idx_document_name ON document(name); 84 | 85 | CREATE UNIQUE INDEX idx_document_name_content ON document(name, content); 86 | 87 | CREATE TABLE document_step ( 88 | id SERIAL PRIMARY KEY, 89 | step_id integer NOT NULL references step(id), 90 | document_id integer NOT NULL references document(id), 91 | created_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP, 92 | updated_at TIMESTAMPTZ DEFAULT CURRENT_TIMESTAMP 93 | ); 94 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "beebot" 3 | version = "0.1.0" 4 | description = "An AI agent that works" 5 | authors = ["Erik Peterson "] 6 | license = "MIT" 7 | readme = "README.md" 8 | packages = [{ include = "beebot" }] 9 | 10 | [tool.poetry.scripts] 11 | beebot = 'beebot.initiator.cli:main' 12 | 13 | [tool.poetry.dependencies] 14 | # I hate this version specification but we can't use python-statemachine in 3.12 15 | python = ">=3.10,<3.12" 16 | langchain = ">=0.0.215" 17 | openai = "^0.27.8" 18 | python-dotenv = "^1.0.0" 19 | python-statemachine = "^2.1.0" 20 | playwright = "^1.35.0" 21 | lxml = "^4.9.3" 22 | google-auth-httplib2 = "^0.1.0" 23 | google-auth-oauthlib = "^1.0.0" 24 | google-api-python-client = "^2.93.0" 25 | wolframalpha = "^5.0.0" 26 | yoyo-migrations = "^8.2.0" 27 | psycopg2-binary = { extras = ["ext"], version = "^2.9.6" } 28 | beautifulsoup4 = "^4.12.2" 29 | psutil = "^5.9.5" 30 | coloredlogs = "^15.0.1" 31 | uvicorn = { extras = ["standard"], version = "^0.23.1" } 32 | fastapi = "^0.100.0" 33 | wikipedia = "^1.4.0" 34 | autopack-tools = "^0.4.0" 35 | tortoise-orm = { extras = ["postgres"], version = "^0.19.3" } 36 | asyncpg = "^0.28.0" 37 | pytest-asyncio = "^0.21.1" 38 | 39 | [tool.poetry.group.dev.dependencies] 40 | black = "^23.3.0" 41 | ruff = "^0.0.276" 42 | pytest = "^7.4.0" 43 | colorama = "^0.4.6" 44 | pytest-asyncio = "^0.21.1" 45 | baserun = "^0.3.1" 46 | agbenchmark = "^0.0.7" 47 | 48 | [build-system] 49 | requires = ["poetry-core"] 50 | build-backend = "poetry.core.masonry.api" 51 | 52 | [tool.ruff] 53 | line-length = 120 54 | # Never enforce `E501` (line length violations). Newlines in strings sent to the LLM may impact its comprehension. 55 | ignore = ["E501"] -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | testpaths = tests 3 | filterwarnings = 4 | ignore::DeprecationWarning 5 | -------------------------------------------------------------------------------- /setup.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | pip install poetry 4 | poetry install 5 | poetry run playwright install 6 | docker compose up -d 7 | 8 | echo "BeeBot is now set up" -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | # each test runs on cwd to its temp dir 2 | import asyncio 3 | import os 4 | import signal 5 | import sys 6 | 7 | import psutil 8 | import pytest 9 | from _pytest.fixtures import FixtureRequest 10 | from dotenv import load_dotenv 11 | from tortoise import Tortoise 12 | 13 | from beebot.body import Body 14 | from beebot.models.database_models import initialize_db 15 | 16 | 17 | def pytest_configure(): 18 | load_dotenv() 19 | 20 | 21 | @pytest.fixture(autouse=True) 22 | def go_to_tmpdir(request): 23 | # Get the fixture dynamically by its name. 24 | tmpdir = request.getfixturevalue("tmpdir") 25 | # ensure local test created packages can be imported 26 | sys.path.insert(0, str(tmpdir)) 27 | 28 | # In an ideal world we would truly end-to-end pack search, but it's really expensive to do every time, so we copy it 29 | # source_dir = ".autopack" 30 | # destination_dir = os.path.join(tmpdir.strpath, ".autopack") 31 | # shutil.copytree(source_dir, destination_dir) 32 | 33 | print(f"Executing tests in the directory {tmpdir.strpath}") 34 | 35 | # Chdir only for the duration of the test. 36 | with tmpdir.as_cwd(): 37 | yield 38 | 39 | 40 | def kill_child_processes(parent_pid: int, sig=signal.SIGKILL): 41 | try: 42 | parent = psutil.Process(parent_pid) 43 | except psutil.NoSuchProcess: 44 | return 45 | children = parent.children(recursive=True) 46 | for child in children: 47 | child.send_signal(sig) 48 | 49 | 50 | @pytest.fixture(autouse=True) 51 | def cleanup_processes(): 52 | yield 53 | kill_child_processes(os.getpid()) 54 | 55 | 56 | def db_url() -> str: 57 | return os.environ.get("TORTOISE_TEST_DB", "sqlite://:memory:") 58 | 59 | 60 | @pytest.fixture(autouse=True) 61 | async def initialize_tests(request: FixtureRequest): 62 | await initialize_db(db_url()) 63 | 64 | def fin(): 65 | async def afin(): 66 | if "postgres" not in db_url(): 67 | await Tortoise._drop_databases() 68 | 69 | event_loop = asyncio.get_event_loop() 70 | event_loop.run_until_complete(afin()) 71 | 72 | request.addfinalizer(fin) 73 | 74 | 75 | @pytest.fixture() 76 | async def body_fixture(task: str, initialize_tests, go_to_tmpdir): 77 | await initialize_tests 78 | body_obj = Body(task=task) 79 | await body_obj.setup() 80 | body_obj.config.setup_logging() 81 | body_obj.config.hard_exit = False 82 | return body_obj 83 | -------------------------------------------------------------------------------- /tests/end_to_end/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/erik-megarad/beebot/8082f74490ed76b436d4014878ac622fe3bd60d4/tests/end_to_end/__init__.py -------------------------------------------------------------------------------- /tests/end_to_end/test_background_python.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture() 7 | def task() -> str: 8 | return ( 9 | "Create a python file named 'sleepy.py' that sleeps for a number of seconds according to its arguments and " 10 | "writes that number into the file 'sleepy.txt' after it has finished sleeping. At the end of the program " 11 | "output a success message. Execute sleepy.py in the background with an argument of 10, check the status of " 12 | "the program until it's done, and then exit." 13 | ) 14 | 15 | 16 | @pytest.mark.asyncio 17 | async def test_background_python(task, body_fixture): 18 | body = await body_fixture 19 | 20 | for i in range(0, 15): 21 | response = await body.cycle() 22 | if body.is_done: 23 | break 24 | 25 | if not response.plan: 26 | continue 27 | 28 | plan_text = ( 29 | "Complete" if response.task_execution.complete else response.plan.plan_text 30 | ) 31 | print( 32 | f"----\n{await body.current_task_execution.compile_history()}\n{plan_text}\n{response.decision.tool_name}" 33 | f"({response.decision.tool_args})\n{response.observation.response}\n---" 34 | ) 35 | 36 | assert os.path.exists("workspace/sleepy.py") 37 | assert os.path.exists("workspace/sleepy.txt") 38 | 39 | with open("workspace/sleepy.py", "r") as f: 40 | file_contents = f.read() 41 | assert "sleep" in file_contents 42 | # FIXME: It doesn't use args sometimes, probably a prompt issue? 43 | # assert "argv" in file_contents 44 | 45 | with open("workspace/sleepy.txt", "r") as f: 46 | file_contents = f.read() 47 | assert "10" in file_contents 48 | -------------------------------------------------------------------------------- /tests/end_to_end/test_capital_retrieval.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from beebot.config import Config 6 | 7 | 8 | @pytest.fixture() 9 | def task(): 10 | return "Create a text file named capital.txt and write the name of the capital of America into it." 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_capital_retrieval(task, body_fixture): 15 | body = await body_fixture 16 | 17 | assert "capital.txt" in body.task 18 | assert isinstance(body.config, Config) 19 | assert len(body.config.openai_api_key) > 1 20 | assert len(body.current_task_execution.packs) >= 3 21 | 22 | for i in range(0, 8): 23 | await body.cycle() 24 | if body.is_done: 25 | break 26 | 27 | with open(os.path.join("workspace", "capital.txt"), "r") as f: 28 | file_contents = f.read() 29 | 30 | assert "washington" in file_contents.lower() 31 | -------------------------------------------------------------------------------- /tests/end_to_end/test_history.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | import pytest 4 | 5 | from beebot.body import Body 6 | 7 | 8 | @pytest.fixture() 9 | def task() -> str: 10 | return "Follow the instructions in the instructions_1.txt file" 11 | 12 | 13 | @pytest.fixture() 14 | def ids() -> list[str]: 15 | # ID List length will increase/decrease cost, but increase/decrease the value of the test. 4 is fine. 16 | length = 4 17 | range_start = 1 18 | range_end = 100000 19 | return [randint(range_start, range_end) for _ in range(length)] 20 | 21 | 22 | @pytest.fixture() 23 | async def instructions_files_fixture(body_fixture, ids) -> tuple[Body, list[str]]: 24 | body = await body_fixture 25 | instructions = [] 26 | for i, instruction in enumerate(ids): 27 | instructions.append( 28 | f"The id to remember is {ids[i]}. Read the file instructions_{i + 2}.txt." 29 | ) 30 | 31 | instructions.append("Write the ids previously mentioned to a file named 'ids.txt'.") 32 | for i, instruction in enumerate(instructions): 33 | await body.file_manager.awrite_file(f"instructions_{i + 1}.txt", instruction) 34 | 35 | return body, instructions 36 | 37 | 38 | @pytest.mark.asyncio 39 | async def test_parse_history(body_fixture, task, instructions_files_fixture, ids): 40 | body, _instructions_files = await instructions_files_fixture 41 | for i in range(0, 15): 42 | await body.cycle() 43 | if body.is_done: 44 | break 45 | 46 | with open("workspace/ids.txt", "r") as f: 47 | file_contents = f.read() 48 | for expected_id in ids: 49 | assert str(expected_id) in file_contents 50 | -------------------------------------------------------------------------------- /tests/end_to_end/test_revenue_lookup.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from beebot.config import Config 6 | 7 | 8 | @pytest.fixture() 9 | def task(): 10 | return "Write to a file called output.txt containing tesla's revenue in 2022." 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_revenue_lookup(task, body_fixture): 15 | body = await body_fixture 16 | 17 | assert "tesla" in body.task.lower() 18 | assert "2022" in body.task 19 | assert isinstance(body.config, Config) 20 | assert len(body.config.openai_api_key) > 1 21 | assert len(body.current_task_execution.packs) >= 3 22 | 23 | for i in range(0, 8): 24 | await body.cycle() 25 | if body.is_done: 26 | break 27 | 28 | with open(os.path.join("workspace", "output.txt"), "r") as f: 29 | file_contents = f.read() 30 | 31 | assert "81" in file_contents 32 | -------------------------------------------------------------------------------- /tests/end_to_end/test_system_basic_cycle.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | from beebot.config import Config 6 | 7 | 8 | @pytest.fixture() 9 | def task(): 10 | return "Get my OS name and version, and my current disk usage. write it to a file called my_computer.txt" 11 | 12 | 13 | @pytest.mark.asyncio 14 | async def test_system_basic_cycle(task, body_fixture): 15 | body = await body_fixture 16 | 17 | assert "my_computer.txt" in body.task 18 | assert isinstance(body.config, Config) 19 | assert len(body.config.openai_api_key) > 1 20 | 21 | for i in range(0, 8): 22 | await body.cycle() 23 | if body.is_done: 24 | break 25 | 26 | with open(os.path.join("workspace", "my_computer.txt"), "r") as f: 27 | file_contents = f.read() 28 | assert "Operating System" in file_contents or "OS" in file_contents 29 | assert "GB" in file_contents 30 | -------------------------------------------------------------------------------- /tests/end_to_end/test_webserver.py: -------------------------------------------------------------------------------- 1 | import os 2 | 3 | import pytest 4 | 5 | 6 | @pytest.fixture() 7 | def task() -> str: 8 | return ( 9 | "Create a flask webserver that listens on localhost:9696 and responds to requests to /health with a 200 OK. " 10 | "Run the webserver in a daemon process. Then Make an HTTP request to the health endpoint and write the " 11 | "response to health.txt. Once you have written the file, you should kill the webserver process and exit." 12 | ) 13 | 14 | 15 | @pytest.mark.asyncio 16 | async def test_background_python(body_fixture, task): 17 | body = await body_fixture 18 | 19 | for i in range(0, 15): 20 | response = await body.cycle() 21 | if body.is_done: 22 | break 23 | 24 | if not response.plan: 25 | continue 26 | 27 | print( 28 | f"----\n{await body.current_task_execution.compile_history()}\n{response.plan.plan_text}\n" 29 | f"{response.decision.tool_name}({response.decision.tool_args})\n{response.observation.response}\n---" 30 | ) 31 | 32 | assert os.path.exists("workspace/health.txt") 33 | 34 | with open("workspace/health.txt", "r") as f: 35 | file_contents = f.read() 36 | assert "200" in file_contents 37 | -------------------------------------------------------------------------------- /yoyo.ini: -------------------------------------------------------------------------------- 1 | [DEFAULT] 2 | sources = migrations 3 | database = %(DATABASE_URL)s 4 | batch_mode = off 5 | verbosity = 0 6 | 7 | --------------------------------------------------------------------------------