├── .github └── workflows │ ├── deploy_github_page.yml │ ├── publish_to_pypi.yml │ └── test.yml ├── .gitignore ├── CONTRIBUTING.md ├── LICENSE ├── README.md ├── agency ├── __init__.py ├── agent.py ├── logger.py ├── processor.py ├── queue.py ├── resources.py ├── schema.py ├── space.py └── spaces │ ├── __init__.py │ ├── amqp_space.py │ └── local_space.py ├── examples ├── demo │ ├── .env.example │ ├── Dockerfile │ ├── README.md │ ├── agents │ │ ├── __init__.py │ │ ├── chatty_ai.py │ │ ├── host.py │ │ ├── mixins │ │ │ ├── __init__.py │ │ │ ├── help_methods.py │ │ │ ├── prompt_methods.py │ │ │ └── say_response_methods.py │ │ ├── openai_completion_agent.py │ │ └── openai_function_agent.py │ ├── apps │ │ ├── __init__.py │ │ ├── gradio_app.py │ │ ├── react_app.py │ │ └── templates │ │ │ └── index.html │ ├── demo │ ├── demo_amqp.py │ ├── demo_local.py │ ├── docker-compose.yml │ ├── poetry.lock │ └── pyproject.toml └── mqtt_demo │ ├── README.md │ ├── docker-compose.yml │ ├── main.py │ ├── micropython │ ├── main.py │ ├── micropython_agent.py │ └── micropython_space.py │ ├── pyproject.toml │ └── rabbitmq.conf ├── poetry.lock ├── pyproject.toml ├── pytest.ini ├── scripts └── receive_logs_topic.py ├── site ├── .gitignore ├── .ruby-version ├── 404.html ├── CNAME ├── Gemfile ├── Gemfile.lock ├── README.md ├── _articles │ ├── agent_callbacks.md │ ├── creating_spaces.md │ ├── defining_actions.md │ ├── messaging.md │ ├── responses.md │ └── walkthrough.md ├── _config.yml ├── _includes │ ├── nav_footer_custom.html │ └── search_placeholder_custom.html ├── _sass │ ├── color_schemes │ │ └── light-customized.scss │ └── custom │ │ └── custom.scss ├── devserver ├── favicon.ico ├── index.md └── pdoc_templates │ ├── frame.html.jinja2 │ └── module.html.jinja2 └── tests ├── __init__.py ├── conftest.py ├── helpers.py ├── test_agent.py ├── test_e2e_help.py ├── test_e2e_messaging.py ├── test_e2e_permissions.py └── test_space.py /.github/workflows/deploy_github_page.yml: -------------------------------------------------------------------------------- 1 | name: deploy-github-page 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | permissions: 9 | contents: write 10 | 11 | jobs: 12 | deploy-github-page: 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - name: Checkout code 17 | uses: actions/checkout@v2 18 | 19 | - name: Set up Python 20 | uses: actions/setup-python@v2 21 | with: 22 | python-version: '3.9' 23 | 24 | - name: Install dependencies 25 | run: | 26 | python3 -m pip install --upgrade pip poetry 27 | poetry install 28 | poetry add pdoc docstring-parser 29 | 30 | - name: Generate API documentation 31 | run: | 32 | poetry run pdoc agency \ 33 | --template-directory site/pdoc_templates \ 34 | --docformat google \ 35 | --output-dir site/_api_docs 36 | 37 | 38 | - name: Set up Ruby and Jekyll 39 | uses: ruby/setup-ruby@v1 40 | with: 41 | ruby-version: '3.2.2' 42 | 43 | - name: Build the site 44 | env: 45 | JEKYLL_ENV: production 46 | run: | 47 | cd site 48 | gem install bundler 49 | bundle install 50 | bundle exec jekyll build 51 | 52 | - name: Deploy to GitHub Pages 53 | uses: peaceiris/actions-gh-pages@v3 54 | with: 55 | github_token: ${{ secrets.GITHUB_TOKEN }} 56 | publish_dir: ./site/_site 57 | cname: createwith.agency 58 | -------------------------------------------------------------------------------- /.github/workflows/publish_to_pypi.yml: -------------------------------------------------------------------------------- 1 | name: publish-to-pypi 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | publish-to-pypi: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout repository 13 | uses: actions/checkout@v2 14 | with: 15 | fetch-depth: 2 16 | 17 | - name: Install dependencies 18 | run: npm install @iarna/toml 19 | 20 | - name: Check for version changes 21 | id: check_version 22 | uses: actions/github-script@v5 23 | with: 24 | script: | 25 | const fs = require('fs'); 26 | const execSync = require('child_process').execSync; 27 | const toml = require('@iarna/toml'); 28 | const current = fs.readFileSync('pyproject.toml', 'utf8'); 29 | execSync('git checkout HEAD^1 pyproject.toml'); 30 | const previous = fs.readFileSync('pyproject.toml', 'utf8'); 31 | execSync('git checkout HEAD pyproject.toml'); 32 | const currentVersion = toml.parse(current).tool.poetry.version; 33 | const previousVersion = toml.parse(previous).tool.poetry.version; 34 | const versionChanged = currentVersion !== previousVersion; 35 | if (versionChanged) { 36 | console.log(`version changed from ${previousVersion} to ${currentVersion}`); 37 | return currentVersion; 38 | } else { 39 | console.log(`version did not change from ${previousVersion}`); 40 | } 41 | 42 | - name: Install Poetry 43 | if: ${{ steps.check_version.outputs.result }} 44 | uses: snok/install-poetry@v1 45 | with: 46 | version: 1.5.1 47 | 48 | - name: Build and publish 49 | if: ${{ steps.check_version.outputs.result }} 50 | run: | 51 | echo "Building version ${{ steps.check_version.outputs.result }} ..." 52 | poetry build 53 | echo "Publishing ..." 54 | poetry publish --username __token__ --password ${{ secrets.PYPI_TOKEN }} 55 | -------------------------------------------------------------------------------- /.github/workflows/test.yml: -------------------------------------------------------------------------------- 1 | name: test 2 | 3 | on: 4 | push: 5 | branches: 6 | - main 7 | pull_request: 8 | branches: [ main ] 9 | 10 | jobs: 11 | test: 12 | runs-on: ubuntu-latest 13 | steps: 14 | - uses: actions/checkout@v2 15 | - uses: actions/setup-python@v2 16 | with: { python-version: 3.9 } 17 | - name: Install development dependencies 18 | run: | 19 | python3 -m pip install --upgrade pip poetry 20 | poetry install 21 | - name: Run tests 22 | run: poetry run pytest -xvv 23 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | *.egg-info/ 2 | *.py[cod] 3 | .DS_Store 4 | .env 5 | .pytest_cache 6 | .venv 7 | .vscode 8 | __pycache__/ 9 | build/ 10 | dist/ 11 | nohup.out 12 | tmp/ 13 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | Thanks for considering contributing to the Agency library! Here's everything you 4 | need to know to get started. 5 | 6 | If you have any questions or want to discuss something, please open an 7 | [issue](https://github.com/operand/agency/issues) or 8 | [discussion](https://github.com/operand/agency/discussions) or reach out on 9 | [discord](https://discord.gg/C6F6245z2C). 10 | 11 | ## Development Installation 12 | 13 | ```bash 14 | git clone git@github.com:operand/agency.git 15 | cd agency 16 | poetry install 17 | ``` 18 | 19 | ## Developing with the Demo Application 20 | 21 | See [the demo directory](./examples/demo/) for instructions on how to run the 22 | demo. 23 | 24 | The demo application is written to showcase the different space types and 25 | several agent examples. It can also be used for experimentation and development. 26 | 27 | The application is configured to read the library source when running, allowing 28 | library changes to be tested manually. 29 | 30 | ## Test Suite 31 | 32 | Ensure you have Docker installed. A small RabbitMQ container will be 33 | automatically created by the test suite. 34 | 35 | You can run the tests: 36 | 37 | ```bash 38 | poetry run pytest 39 | ``` 40 | 41 | ## Areas to Contribute 42 | 43 | These are the general areas where you might want to contribute: 44 | 45 | ### The Examples Directory 46 | 47 | The [`examples/`](./examples/) directory is intended to be an informal directory 48 | of example implementations or related sources. 49 | 50 | Feel free to add a folder under [`examples/`](./examples/) with anything you'd 51 | like to share. Please add a README file if you do. 52 | 53 | Library maintainers will not maintain examples, except for the main `demo` 54 | application. So if you want it kept up-to-date, that is up to you, but don't 55 | feel obligated. 56 | 57 | The main demo located at [`examples/demo/`](./examples/demo/) is maintained with 58 | the core library. Feel free to copy the demo application as a basis for your own 59 | examples or personal work. 60 | 61 | If you'd like to make significant changes to the main demo (not a bug fix or 62 | something small), please discuss it with the maintainers. 63 | 64 | ### Core Library Contributions 65 | 66 | There isn't a complex process to contribute. Just open a PR and have it 67 | approved. 68 | 69 | For significant library changes (not bug fixes or small improvements) please 70 | discuss it with the maintainers in order to ensure alignment on design and 71 | implementation. 72 | 73 | Informal guidelines for core contributions: 74 | 75 | * If you're adding functionality you should probably add new tests for it. 76 | * Documentation should be updated or added as needed. 77 | 78 | ### Maintaining Documentation 79 | 80 | Documentation is hosted at https://createwith.agency. The source for the help 81 | site is maintained in the [`site/`](./site/) directory. Please see that 82 | directory for information on editing documentation. 83 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2023 Daniel Rodriguez 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 | # Summary 2 | 3 | Agency is a python library that provides an [Actor 4 | model](https://en.wikipedia.org/wiki/Actor_model) framework for creating 5 | agent-integrated systems. 6 | 7 | The library provides an easy to use API that enables you to connect agents with 8 | traditional software systems in a flexible and scalable way, allowing you to 9 | develop any architecture you need. 10 | 11 | Agency's goal is to enable developers to create custom agent-based applications 12 | by providing a minimal foundation to both experiment and build upon. So if 13 | you're looking to build a custom agent system of your own, Agency might be for 14 | you. 15 | 16 | ## Features 17 | 18 | ### Easy to use API 19 | * Straightforward class/method based agent and action definition 20 | * [Up to date documentation](https://createwith.agency) and [examples](./examples/demo/) for reference 21 | 22 | ### Performance and Scalability 23 | * Supports multiprocessing and multithreading for concurrency 24 | * AMQP support for networked agent systems 25 | 26 | ### Observability and Control 27 | * Action and lifecycle callbacks 28 | * Access policies and permission callbacks 29 | * Detailed logging 30 | 31 | ### Demo application available at [`examples/demo`](./examples/demo/) 32 | * Multiple agent examples for experimentation 33 | * Two OpenAI agent examples 34 | * HuggingFace transformers agent example 35 | * Operating system access 36 | * Includes Gradio UI 37 | * Docker configuration for reference and development 38 | 39 | 40 | # API Overview 41 | 42 | In Agency, all entities are represented as instances of the `Agent` class. This 43 | includes all AI-driven agents, software interfaces, or human users that may 44 | communicate as part of your application. 45 | 46 | All agents may expose "actions" that other agents can discover and invoke at run 47 | time. An example of a simple agent could be: 48 | 49 | ```python 50 | class CalculatorAgent(Agent): 51 | @action 52 | def add(a, b): 53 | return a + b 54 | ``` 55 | 56 | This defines an agent with a single action: `add`. Other agents will be able 57 | to call this method by sending a message to an instance of `CalculatorAgent` and 58 | specifying the `add` action. For example: 59 | 60 | ```python 61 | other_agent.send({ 62 | 'to': 'CalcAgent', 63 | 'action': { 64 | 'name': 'add', 65 | 'args': { 66 | 'a': 1, 67 | 'b': 2, 68 | } 69 | }, 70 | }) 71 | ``` 72 | 73 | Actions may specify an access policy, allowing you to control access for safety. 74 | 75 | ```python 76 | @action(access_policy=ACCESS_PERMITTED) # This allows the action at any time 77 | def add(a, b): 78 | ... 79 | 80 | @action(access_policy=ACCESS_REQUESTED) # This requires review before the action 81 | def add(a, b): 82 | ... 83 | ``` 84 | 85 | Agents may also define callbacks for various purposes: 86 | 87 | ```python 88 | class CalculatorAgent(Agent): 89 | ... 90 | def before_action(self, message: dict): 91 | """Called before an action is attempted""" 92 | 93 | def after_action(self, message: dict, return_value: str, error: str): 94 | """Called after an action is attempted""" 95 | 96 | def after_add(self): 97 | """Called after the agent is added to a space and may begin communicating""" 98 | 99 | def before_remove(self): 100 | """Called before the agent is removed from the space""" 101 | ``` 102 | 103 | A `Space` is how you connect your agents together. An agent cannot communicate 104 | with others until it is added to a common `Space`. 105 | 106 | There are two included `Space` implementations to choose from: 107 | * `LocalSpace` - which connects agents within the same application. 108 | * `AMQPSpace` - which connects agents across a network using an AMQP 109 | server like RabbitMQ. 110 | 111 | Finally, here is a simple example of creating a `LocalSpace` and adding two 112 | agents to it. 113 | 114 | ```python 115 | space = LocalSpace() 116 | space.add(CalculatorAgent, "CalcAgent") 117 | space.add(MyAgent, "MyAgent") 118 | # The agents above can now communicate 119 | ``` 120 | 121 | These are just the basic features that Agency provides. For more information 122 | please see [the help site](https://createwith.agency). 123 | 124 | 125 | # Install 126 | 127 | ```sh 128 | pip install agency 129 | ``` 130 | or 131 | ```sh 132 | poetry add agency 133 | ``` 134 | 135 | 136 | # The Demo Application 137 | 138 | The demo application is maintained as an experimental development environment 139 | and a showcase for library features. It includes multiple agent examples which 140 | may communicate with eachother and supports a "slash" syntax for invoking 141 | actions as an agent yourself. 142 | 143 | To run the demo, please follow the directions at 144 | [examples/demo](./examples/demo/). 145 | 146 | The following is a screenshot of the Gradio UI that demonstrates the example 147 | `OpenAIFunctionAgent` following orders and interacting with the `Host` agent. 148 | 149 |

150 | Screenshot-2023-07-26-at-4-53-05-PM 152 |

153 | 154 | 155 | 156 | # Contributing 157 | 158 | Please do! 159 | 160 | If you're considering a contribution, please check out the [contributing 161 | guide](./CONTRIBUTING.md). 162 | -------------------------------------------------------------------------------- /agency/__init__.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | 3 | multiprocessing.set_start_method('spawn', force=True) 4 | -------------------------------------------------------------------------------- /agency/agent.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | import re 3 | import threading 4 | import uuid 5 | from typing import Dict, List, Union 6 | 7 | from docstring_parser import DocstringStyle, parse 8 | 9 | from agency.logger import log 10 | from agency.queue import Queue 11 | from agency.resources import ResourceManager 12 | from agency.schema import Message 13 | 14 | 15 | def _python_to_json_type_name(python_type_name: str) -> str: 16 | return { 17 | 'str': 'string', 18 | 'int': 'number', 19 | 'float': 'number', 20 | 'bool': 'boolean', 21 | 'list': 'array', 22 | 'dict': 'object' 23 | }[python_type_name] 24 | 25 | 26 | def _generate_help(method: callable) -> dict: 27 | """ 28 | Generates a help object from a method's docstring and signature 29 | 30 | Args: 31 | method: the method 32 | 33 | Returns: 34 | A help object of the form: 35 | 36 | { 37 | "description": , 38 | "args": { 39 | "arg_name": { 40 | "type": , 41 | "description": 42 | }, 43 | } 44 | "returns": { 45 | "type": , 46 | "description": 47 | } 48 | } 49 | """ 50 | signature = inspect.signature(method) 51 | parsed_docstring = parse(method.__doc__, DocstringStyle.GOOGLE) 52 | 53 | help_object = {} 54 | 55 | # description 56 | if parsed_docstring.short_description is not None: 57 | description = parsed_docstring.short_description 58 | if parsed_docstring.long_description is not None: 59 | description += " " + parsed_docstring.long_description 60 | help_object["description"] = re.sub(r"\s+", " ", description).strip() 61 | 62 | # args 63 | help_object["args"] = {} 64 | docstring_args = {arg.arg_name: arg for arg in parsed_docstring.params} 65 | arg_names = list(signature.parameters.keys())[1:] # skip 'self' argument 66 | for arg_name in arg_names: 67 | arg_object = {} 68 | 69 | # type 70 | sig_annotation = signature.parameters[arg_name].annotation 71 | if sig_annotation is not None and sig_annotation.__name__ != "_empty": 72 | arg_object["type"] = _python_to_json_type_name( 73 | signature.parameters[arg_name].annotation.__name__) 74 | elif arg_name in docstring_args and docstring_args[arg_name].type_name is not None: 75 | arg_object["type"] = _python_to_json_type_name( 76 | docstring_args[arg_name].type_name) 77 | 78 | # description 79 | if arg_name in docstring_args and docstring_args[arg_name].description is not None: 80 | arg_object["description"] = docstring_args[arg_name].description.strip() 81 | 82 | help_object["args"][arg_name] = arg_object 83 | 84 | # returns 85 | if parsed_docstring.returns is not None: 86 | help_object["returns"] = {} 87 | 88 | # type 89 | if signature.return_annotation is not None: 90 | help_object["returns"]["type"] = _python_to_json_type_name( 91 | signature.return_annotation.__name__) 92 | elif parsed_docstring.returns.type_name is not None: 93 | help_object["returns"]["type"] = _python_to_json_type_name( 94 | parsed_docstring.returns.type_name) 95 | 96 | # description 97 | if parsed_docstring.returns.description is not None: 98 | help_object["returns"]["description"] = parsed_docstring.returns.description.strip() 99 | 100 | return help_object 101 | 102 | 103 | # Special action names 104 | _RESPONSE_ACTION_NAME = "[response]" 105 | _ERROR_ACTION_NAME = "[error]" 106 | 107 | 108 | # Access policies 109 | ACCESS_PERMITTED = "ACCESS_PERMITTED" 110 | ACCESS_DENIED = "ACCESS_DENIED" 111 | ACCESS_REQUESTED = "ACCESS_REQUESTED" 112 | 113 | 114 | def action(*args, **kwargs): 115 | """ 116 | Declares instance methods as actions making them accessible to other agents. 117 | 118 | Keyword arguments: 119 | name: The name of the action. Defaults to the name of the method. 120 | help: The help object. Defaults to a generated object. 121 | access_policy: The access policy. Defaults to ACCESS_PERMITTED. 122 | """ 123 | def decorator(method): 124 | action_name = kwargs.get("name", method.__name__) 125 | if action_name == _RESPONSE_ACTION_NAME: 126 | raise ValueError(f"action name '{action_name}' is reserved") 127 | method.action_properties: dict = { 128 | "name": method.__name__, 129 | "help": _generate_help(method), 130 | "access_policy": ACCESS_PERMITTED, 131 | **kwargs, 132 | } 133 | return method 134 | 135 | if len(args) == 1 and callable(args[0]) and not kwargs: 136 | return decorator(args[0]) # The decorator was used without parentheses 137 | else: 138 | return decorator # The decorator was used with parentheses 139 | 140 | 141 | class ActionError(Exception): 142 | """Raised from the request() method if the action responds with an error""" 143 | 144 | 145 | class Agent(): 146 | """ 147 | An Actor that may represent an AI agent, software interface, or human user 148 | """ 149 | 150 | def __init__(self, id: str, receive_own_broadcasts: bool = True): 151 | """ 152 | Initializes an Agent. 153 | 154 | This constructor is not meant to be called directly. It is invoked by 155 | the Space class when adding an agent. 156 | 157 | Subclasses should call super().__init__() in their constructor. 158 | 159 | Args: 160 | id: The id of the agent 161 | receive_own_broadcasts: 162 | Whether the agent will receive its own broadcasts. Defaults to 163 | True 164 | """ 165 | if len(id) < 1 or len(id) > 255: 166 | raise ValueError("id must be between 1 and 255 characters") 167 | if re.match(r"^amq\.", id): 168 | raise ValueError("id cannot start with \"amq.\"") 169 | if id == "*": 170 | raise ValueError("id cannot be \"*\"") 171 | self._id: str = id 172 | self._receive_own_broadcasts: bool = receive_own_broadcasts 173 | # --- non-constructor properties set by Space/Processor --- 174 | self._outbound_queue: Queue = None 175 | self._is_processing: bool = False 176 | # --- non-constructor properties --- 177 | self._message_log: List[Message] = [] 178 | self._message_log_lock: threading.Lock = threading.Lock() 179 | self._pending_requests: Dict[str, Union[threading.Event, Message]] = {} 180 | self._pending_requests_lock: threading.Lock = threading.Lock() 181 | self.__thread_local_current_message = threading.local() 182 | self.__thread_local_current_message.value: Message = None 183 | 184 | def id(self) -> str: 185 | return self._id 186 | 187 | def send(self, message: dict) -> str: 188 | """ 189 | Sends (out) a message from this agent. 190 | 191 | Args: 192 | message: The message 193 | 194 | Returns: 195 | The meta.id of the sent message 196 | 197 | Raises: 198 | TypeError: If the message is not a dict 199 | ValueError: If the message is invalid 200 | """ 201 | if not isinstance(message, dict): 202 | raise TypeError("message must be a dict") 203 | if "from" in message and message["from"] != self.id(): 204 | raise ValueError( 205 | f"'from' field value '{message['from']}' does not match this agent's id.") 206 | message["from"] = self.id() 207 | message["meta"] = { 208 | "id": uuid.uuid4().__str__(), 209 | **message.get("meta", {}), 210 | } 211 | message = Message(**message).dict(by_alias=True, exclude_unset=True) 212 | with self._message_log_lock: 213 | log("info", f"{self._id}: sending", message) 214 | self._message_log.append(message) 215 | self._outbound_queue.put(message) 216 | return message["meta"]["id"] 217 | 218 | def request(self, message: dict, timeout: float = 3) -> object: 219 | """ 220 | Synchronously sends a message then waits for and returns the return 221 | value of the invoked action. 222 | 223 | This method allows you to call an action synchronously like a function 224 | and receive its return value in python. If the action raises an 225 | exception an ActionError will be raised containing the error message. 226 | 227 | Args: 228 | message: The message to send 229 | timeout: 230 | The timeout in seconds to wait for the returned value. 231 | Defaults to 3 seconds. 232 | 233 | Returns: 234 | object: The return value of the action. 235 | 236 | Raises: 237 | TimeoutError: If the timeout is reached 238 | ActionError: If the action raised an exception 239 | RuntimeError: 240 | If called before the agent is processing incoming messages 241 | """ 242 | if not self._is_processing: 243 | raise RuntimeError( 244 | "request() called while agent is not processing incoming messages. Use send() instead.") 245 | 246 | # Send and mark the request as pending 247 | with self._pending_requests_lock: 248 | request_id = self.send(message) 249 | self._pending_requests[request_id] = threading.Event() 250 | 251 | # Wait for response 252 | if not self._pending_requests[request_id].wait(timeout=timeout): 253 | raise TimeoutError 254 | 255 | with self._pending_requests_lock: 256 | response_message = self._pending_requests.pop(request_id) 257 | 258 | # Raise error or return value from response 259 | if response_message["action"]["name"] == _ERROR_ACTION_NAME: 260 | raise ActionError(response_message["action"]["args"]["error"]) 261 | 262 | if response_message["action"]["name"] == _RESPONSE_ACTION_NAME: 263 | return response_message["action"]["args"]["value"] 264 | 265 | raise RuntimeError("We should never get here") 266 | 267 | def respond_with(self, value): 268 | """ 269 | Sends a response with the given value. 270 | 271 | Parameters: 272 | value (any): The value to be sent in the response message. 273 | """ 274 | self.send({ 275 | "meta": { 276 | "parent_id": self.current_message()["meta"]["id"] 277 | }, 278 | "to": self.current_message()['from'], 279 | "action": { 280 | "name": _RESPONSE_ACTION_NAME, 281 | "args": { 282 | "value": value, 283 | } 284 | } 285 | }) 286 | 287 | def raise_with(self, error: Exception): 288 | """ 289 | Sends an error response. 290 | 291 | Args: 292 | error (Exception): The error to send. 293 | """ 294 | self.send({ 295 | "meta": { 296 | "parent_id": self.current_message()["meta"]["id"], 297 | }, 298 | "to": self.current_message()['from'], 299 | "action": { 300 | "name": _ERROR_ACTION_NAME, 301 | "args": { 302 | "error": f"{error.__class__.__name__}: {error}" 303 | } 304 | } 305 | }) 306 | 307 | def _receive(self, message: dict): 308 | """ 309 | Receives and handles an incoming message. 310 | 311 | Args: 312 | message: The incoming message 313 | """ 314 | try: 315 | # Ignore own broadcasts if _receive_own_broadcasts is false 316 | if not self._receive_own_broadcasts \ 317 | and message['from'] == self.id() \ 318 | and message['to'] == '*': 319 | return 320 | 321 | log("debug", f"{self.id()}: received message", message) 322 | 323 | # Record the received message before handling 324 | with self._message_log_lock: 325 | self._message_log.append(message) 326 | 327 | # Handle incoming responses 328 | # TODO: make serial/fan-out optional 329 | if message["action"]["name"] in [_RESPONSE_ACTION_NAME, _ERROR_ACTION_NAME]: 330 | parent_id = message["meta"]["parent_id"] 331 | if parent_id in self._pending_requests.keys(): 332 | # This was a response to a request(). We use a little trick 333 | # here and simply swap out the event that is waiting with 334 | # the message, then set the event. The request() method will 335 | # pick up the response message in the existing thread. 336 | event = self._pending_requests[parent_id] 337 | self._pending_requests[parent_id] = message 338 | event.set() 339 | else: 340 | # This was a response to a send() 341 | if message["action"]["name"] == _RESPONSE_ACTION_NAME: 342 | handler_callback = self.handle_action_value 343 | arg = message["action"]["args"]["value"] 344 | elif message["action"]["name"] == _ERROR_ACTION_NAME: 345 | handler_callback = self.handle_action_error 346 | arg = ActionError(message["action"]["args"]["error"]) 347 | else: 348 | raise RuntimeError("Unknown action response") 349 | 350 | # Spawn a thread to handle the response 351 | def __process_response(arg, current_message): 352 | threading.current_thread( 353 | ).name = f"{self.id()}: __process_response {current_message['meta']['id']}" 354 | self.__thread_local_current_message.value = current_message 355 | handler_callback(arg) 356 | 357 | ResourceManager().thread_pool_executor.submit( 358 | __process_response, 359 | arg, 360 | message, 361 | ) 362 | 363 | # Handle all other messages 364 | else: 365 | # Spawn a thread to process the message. This means that messages 366 | # are processed concurrently, but may be processed out of order. 367 | ResourceManager().thread_pool_executor.submit( 368 | self.__process, 369 | message, 370 | ) 371 | except Exception as e: 372 | log("error", f"{self.id()}: raised exception in _receive", e) 373 | 374 | def __process(self, message: dict): 375 | """ 376 | Top level method within the action processing thread. 377 | """ 378 | threading.current_thread( 379 | ).name = f"{self.id()}: __process {message['meta']['id']}" 380 | self.__thread_local_current_message.value = message 381 | try: 382 | log("debug", f"{self.id()}: committing action", message) 383 | self.__commit(message) 384 | except Exception as e: 385 | # Handle errors (including PermissionError) that occur while 386 | # committing an action by reporting back to the sender. 387 | log("warning", 388 | f"{self.id()}: raised exception while committing action '{message['action']['name']}'", e) 389 | self.raise_with(e) 390 | 391 | def __commit(self, message: dict): 392 | """ 393 | Invokes the action method 394 | 395 | Args: 396 | message: The incoming message specifying the action 397 | 398 | Raises: 399 | AttributeError: If the action method is not found 400 | PermissionError: If the action is not permitted 401 | """ 402 | try: 403 | # Check if the action method exists 404 | action_method = self.__action_method(message["action"]["name"]) 405 | except KeyError: 406 | # the action was not found 407 | if message['to'] == '*': 408 | return # broadcasts will not raise an error in this situation 409 | else: 410 | raise AttributeError( 411 | f"\"{message['action']['name']}\" not found on \"{self.id()}\"") 412 | 413 | # Check if the action is permitted 414 | if not self.__permitted(message): 415 | raise PermissionError( 416 | f"\"{self.id()}.{message['action']['name']}\" not permitted") 417 | 418 | self.before_action(message) 419 | 420 | return_value = None 421 | error = None 422 | try: 423 | # Invoke the action method 424 | return_value = action_method(**message['action'].get('args', {})) 425 | except Exception as e: 426 | error = e 427 | raise 428 | finally: 429 | self.after_action(message, return_value, error) 430 | return return_value 431 | 432 | def __permitted(self, message: dict) -> bool: 433 | """ 434 | Checks whether the message's action is allowed 435 | """ 436 | action_method = self.__action_method(message['action']['name']) 437 | policy = action_method.action_properties["access_policy"] 438 | if policy == ACCESS_PERMITTED: 439 | return True 440 | elif policy == ACCESS_DENIED: 441 | return False 442 | elif policy == ACCESS_REQUESTED: 443 | return self.request_permission(message) 444 | else: 445 | raise Exception( 446 | f"Invalid access policy for method: {message['action']}, got '{policy}'") 447 | 448 | def __action_methods(self) -> dict: 449 | instance_methods = inspect.getmembers(self, inspect.ismethod) 450 | action_methods = { 451 | method_name: method 452 | for method_name, method in instance_methods 453 | if hasattr(method, "action_properties") 454 | } 455 | return action_methods 456 | 457 | def __action_method(self, action_name: str): 458 | """ 459 | Returns the method for the given action name. 460 | """ 461 | action_methods = self.__action_methods() 462 | return action_methods[action_name] 463 | 464 | def _find_message(self, message_id: str) -> Message: 465 | """ 466 | Returns a message from the log with the given ID. 467 | 468 | Args: 469 | message_id: The ID of the message 470 | 471 | Returns: 472 | The message or None if not found 473 | """ 474 | for message in self._message_log: 475 | if message["meta"]["id"] == message_id: 476 | return message 477 | 478 | def current_message(self) -> Message: 479 | """ 480 | Returns the full incoming message which invoked the current action. 481 | 482 | This method may be called within an action or action related callback to 483 | retrieve the current message, for example to determine the sender or 484 | inspect other details. 485 | 486 | Returns: 487 | The current message 488 | """ 489 | return self.__thread_local_current_message.value 490 | 491 | def parent_message(self, message: Message = None) -> Message: 492 | """ 493 | Returns the message that the given message is responding to, if any. 494 | 495 | This method may be used within the handle_action_value and 496 | handle_action_error callbacks. 497 | 498 | Args: 499 | message: The message to get the parent message of. Defaults to the 500 | current message. 501 | 502 | Returns: 503 | The parent message or None 504 | """ 505 | if message is None: 506 | message = self.current_message() 507 | parent_id = message["meta"].get("parent_id", None) 508 | if parent_id is not None: 509 | return self._find_message(parent_id) 510 | 511 | @action 512 | def help(self, action_name: str = None) -> dict: 513 | """ 514 | Returns a list of actions on this agent. 515 | 516 | If action_name is passed, returns a list with only that action. 517 | If no action_name is passed, returns all actions. 518 | 519 | Args: 520 | action_name: (Optional) The name of an action to request help for 521 | 522 | Returns: 523 | A dictionary of actions 524 | """ 525 | self.respond_with(self._help(action_name)) 526 | 527 | def _help(self, action_name: str = None) -> dict: 528 | """ 529 | Generates the help information returned by the help() action 530 | """ 531 | special_actions = ["help", _RESPONSE_ACTION_NAME, _ERROR_ACTION_NAME] 532 | help_list = { 533 | method.action_properties["name"]: method.action_properties["help"] 534 | for method in self.__action_methods().values() 535 | if action_name is None 536 | and method.action_properties["name"] not in special_actions 537 | or method.action_properties["name"] == action_name 538 | } 539 | return help_list 540 | 541 | def handle_action_value(self, value): 542 | """ 543 | Receives a return value from a previous action. 544 | 545 | This method receives return values from actions invoked by the send() 546 | method. It is not called when using the request() method, which returns 547 | the value directly. 548 | 549 | To inspect the full response message carrying this value, use 550 | current_message(). To inspect the message which returned the value, use 551 | parent_message(). 552 | 553 | Args: 554 | value: 555 | The return value 556 | """ 557 | if not hasattr(self, "_issued_handle_action_value_warning"): 558 | self._issued_handle_action_value_warning = True 559 | log("warning", 560 | f"A value was returned from an action. Implement {self.__class__.__name__}.handle_action_value() to handle it.") 561 | 562 | def handle_action_error(self, error: ActionError): 563 | """ 564 | Receives an error from a previous action. 565 | 566 | This method receives errors from actions invoked by the send() method. 567 | It is not called when using the request() method, which raises an error 568 | directly. 569 | 570 | To inspect the full response message carrying this error, use 571 | current_message(). To inspect the message which caused the error, use 572 | parent_message(). 573 | 574 | Args: 575 | error: The error 576 | """ 577 | if not hasattr(self, "_issued_handle_action_error_warning"): 578 | self._issued_handle_action_error_warning = True 579 | log("warning", 580 | f"An error was raised from an action. Implement {self.__class__.__name__}.handle_action_error() to handle it.") 581 | 582 | def after_add(self): 583 | """ 584 | Called after the agent is added to a space, but before it begins 585 | processing incoming messages. 586 | 587 | The agent may send messages during this callback using the send() 588 | method, but may not use the request() method since it relies on 589 | processing incoming messages. 590 | """ 591 | 592 | def before_remove(self): 593 | """ 594 | Called before the agent is removed from a space, after it has finished 595 | processing incoming messages. 596 | 597 | The agent may send final messages during this callback using the send() 598 | method, but may not use the request() method since it relies on 599 | processing incoming messages. 600 | """ 601 | 602 | def before_action(self, message: dict): 603 | """ 604 | Called before every action. 605 | 606 | This method will only be called if the action exists and is permitted. 607 | 608 | Args: 609 | message: The received message that contains the action 610 | """ 611 | 612 | def after_action(self, message: dict, return_value: str, error: str): 613 | """ 614 | Called after every action, regardless of whether an error occurred. 615 | 616 | Args: 617 | message: The message which invoked the action 618 | return_value: The return value from the action 619 | error: The error from the action if any 620 | """ 621 | 622 | def request_permission(self, proposed_message: dict) -> bool: 623 | """ 624 | Receives a proposed action message and presents it to the agent for 625 | review. 626 | 627 | Args: 628 | proposed_message: The proposed action message 629 | 630 | Returns: 631 | True if access should be permitted 632 | """ 633 | raise NotImplementedError( 634 | f"You must implement {self.__class__.__name__}.request_permission() to use ACCESS_REQUESTED") 635 | -------------------------------------------------------------------------------- /agency/logger.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | import os 4 | import traceback 5 | 6 | import colorlog 7 | from colorlog.escape_codes import escape_codes 8 | from pygments import highlight 9 | from pygments.formatters import Terminal256Formatter 10 | from pygments.lexers import get_lexer_by_name 11 | 12 | _LOGLEVELS = { 13 | 'CRITICAL': 50, 14 | 'ERROR': 40, 15 | 'WARNING': 30, 16 | 'INFO': 20, 17 | 'DEBUG': 10, 18 | 'NOTSET': 0 19 | } 20 | 21 | _env_loglevel = os.environ.get('LOGLEVEL', 'WARNING').upper() 22 | _LOGLEVEL = _LOGLEVELS[_env_loglevel] 23 | _LOGFORMAT = '%(asctime_color)s%(asctime)s%(reset_color)s - %(levelname_color)s%(levelname)s%(reset_color)s - %(message_color)s%(message)s%(reset_color)s%(object_color)s%(object)s%(reset_color)s' 24 | _LOG_PYGMENTS_STYLE = os.environ.get('LOG_PYGMENTS_STYLE', 'monokai') 25 | 26 | 27 | class CustomColoredFormatter(colorlog.ColoredFormatter): 28 | def format(self, record): 29 | record.reset_color = escape_codes['reset'] 30 | record.asctime_color = escape_codes['light_black'] 31 | record.levelname_color = escape_codes[self.log_colors[record.levelname]] 32 | record.message_color = escape_codes['reset'] 33 | record.object_color = escape_codes['reset'] 34 | 35 | return super().format(record) 36 | 37 | 38 | _logger = logging.getLogger("agency") 39 | _logger.setLevel(_LOGLEVEL) 40 | _handler = logging.StreamHandler() 41 | _handler.setLevel(_LOGLEVEL) 42 | 43 | _formatter = CustomColoredFormatter( 44 | _LOGFORMAT, 45 | log_colors={ 46 | 'CRITICAL': 'bold_red', 47 | 'ERROR': 'red', 48 | 'WARNING': 'yellow', 49 | 'INFO': 'green', 50 | 'DEBUG': 'cyan', 51 | } 52 | ) 53 | 54 | _handler.setFormatter(_formatter) 55 | _logger.addHandler(_handler) 56 | 57 | 58 | class _CustomEncoder(json.JSONEncoder): 59 | def default(self, obj): 60 | try: 61 | return super().default(obj) 62 | except TypeError: 63 | return str(obj) 64 | 65 | 66 | def log(level: str, message: str, object=None): 67 | pretty_object: str = "" 68 | if object != None: 69 | try: 70 | if isinstance(object, Exception): 71 | pretty_object = "\n" + "".join(traceback.format_exception( 72 | etype=type(object), value=object, tb=object.__traceback__)) 73 | else: 74 | json_str = json.dumps(object, indent=2, cls=_CustomEncoder) 75 | pretty_object = "\n" + \ 76 | highlight(json_str, get_lexer_by_name('json'), 77 | Terminal256Formatter(style=_LOG_PYGMENTS_STYLE)) 78 | except: 79 | pass 80 | 81 | numeric_level = _LOGLEVELS.get(level.upper()) 82 | if numeric_level is not None: 83 | _logger.log(numeric_level, message, extra={'object': pretty_object}) 84 | else: 85 | raise ValueError(f"Invalid log level: {level}") 86 | -------------------------------------------------------------------------------- /agency/processor.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import queue 3 | import threading 4 | from abc import ABC, ABCMeta 5 | from concurrent.futures import (Executor, Future) 6 | from typing import Dict, List, Protocol, Type 7 | 8 | from agency.agent import Agent 9 | from agency.logger import log 10 | from agency.queue import Queue 11 | 12 | 13 | class _EventProtocol(Protocol): 14 | def set(self) -> None: 15 | pass 16 | 17 | def clear(self) -> None: 18 | pass 19 | 20 | def is_set(self) -> bool: 21 | pass 22 | 23 | def wait(self, timeout: float = None) -> bool: 24 | pass 25 | 26 | 27 | class Processor(ABC, metaclass=ABCMeta): 28 | """ 29 | Encapsulates a running Agent instance 30 | """ 31 | def __init__(self, 32 | agent_type: Type[Agent], 33 | agent_id: str, 34 | agent_args: List, 35 | agent_kwargs: Dict, 36 | inbound_queue: Queue, 37 | outbound_queue: Queue, 38 | started: _EventProtocol, 39 | stopping: _EventProtocol, 40 | new_message_event: _EventProtocol, 41 | executor: Executor): 42 | self.agent_type: Type[Agent] = agent_type 43 | self.agent_id: str = agent_id 44 | self.agent_args: List = agent_args 45 | self.agent_kwargs: Dict = agent_kwargs 46 | self.inbound_queue: Queue = inbound_queue 47 | self.outbound_queue: Queue = outbound_queue 48 | self.started: _EventProtocol = started 49 | self.stopping: _EventProtocol = stopping 50 | self.new_message_event: _EventProtocol = new_message_event 51 | self.executor: Executor = executor 52 | # --- non-constructor properties --- 53 | self._future: Future = None 54 | self._agent: Agent = None # Accessible if in foreground 55 | 56 | def start(self) -> Agent: 57 | log("debug", f"{self.agent_id}: processor starting ...") 58 | 59 | agent_ref: List = [] 60 | self._future = self.executor.submit( 61 | _process_loop, 62 | self.agent_type, 63 | self.agent_id, 64 | self.agent_args, 65 | self.agent_kwargs, 66 | self.inbound_queue, 67 | self.outbound_queue, 68 | self.started, 69 | self.stopping, 70 | self.new_message_event, 71 | agent_ref, 72 | ) 73 | 74 | if not self.started.wait(timeout=5): 75 | # it couldn't start, force stop it and raise an exception 76 | self.stop() 77 | error = self._future.exception() 78 | if error is not None: 79 | raise error 80 | else: 81 | raise Exception("Processor could not be started.") 82 | 83 | log("debug", f"{self.agent_id}: processor started") 84 | 85 | # return the agent if present. only works in foreground 86 | if agent_ref: 87 | return agent_ref[0] 88 | 89 | def stop(self): 90 | log("debug", f"{self.agent_id}: processor stopping ...") 91 | self.stopping.set() 92 | if self._future is not None: 93 | self._future.result() 94 | log("debug", f"{self.agent_id}: processor stopped") 95 | 96 | 97 | # Placed at the top-level to play nice with the multiprocessing module 98 | def _process_loop(agent_type: Type[Agent], 99 | agent_id: str, 100 | agent_args: List, 101 | agent_kwargs: Dict, 102 | inbound_queue: Queue, 103 | outbound_queue: Queue, 104 | started: _EventProtocol, 105 | stopping: _EventProtocol, 106 | new_message_event: _EventProtocol, 107 | agent_ref: List): 108 | """ 109 | The main agent processing loop 110 | """ 111 | # Set process or thread name 112 | if isinstance(started, threading.Event): 113 | threading.current_thread( 114 | ).name = f"{agent_id}: processor loop thread" 115 | else: 116 | multiprocessing.current_process( 117 | ).name = f"{agent_id}: processor loop process" 118 | 119 | try: 120 | log("debug", f"{agent_id}: processor loop starting") 121 | agent: Agent = agent_type(agent_id, *agent_args, **agent_kwargs) 122 | agent_ref.append(agent) # set the agent reference 123 | inbound_queue.connect() 124 | outbound_queue.connect() 125 | agent._outbound_queue = outbound_queue 126 | agent.after_add() 127 | agent._is_processing = True 128 | started.set() 129 | stopping.clear() 130 | new_message_event.clear() 131 | while not stopping.is_set(): 132 | new_message_event.wait(timeout=0.1) # TODO make configurable 133 | if stopping.is_set(): 134 | log("debug", 135 | f"{agent_id}: processor loop stopping") 136 | break 137 | while True: # drain inbound_queue 138 | try: 139 | message = inbound_queue.get(block=False) 140 | log("debug", 141 | f"{agent_id}: processor loop got message", message) 142 | agent._receive(message) 143 | except queue.Empty: 144 | break 145 | new_message_event.clear() 146 | except KeyboardInterrupt: 147 | log("debug", f"{agent_id}: processor loop interrupted") 148 | pass 149 | except Exception as e: 150 | log("error", f"{agent_id}: processor loop failed", e) 151 | raise 152 | finally: 153 | log("debug", f"{agent_id}: processor loop cleaning up") 154 | agent._is_processing = False 155 | agent.before_remove() 156 | inbound_queue.disconnect() 157 | outbound_queue.disconnect() 158 | log("debug", f"{agent_id}: processor loop stopped") 159 | -------------------------------------------------------------------------------- /agency/queue.py: -------------------------------------------------------------------------------- 1 | from abc import ABC, ABCMeta, abstractmethod 2 | 3 | from agency.schema import Message 4 | 5 | 6 | class Queue(ABC, metaclass=ABCMeta): 7 | """ 8 | Encapsulates a queue intended to be used for communication 9 | """ 10 | 11 | def connect(self) -> None: 12 | """ 13 | Connects to the queue 14 | 15 | This method is called before the queue is first accessed and establishes 16 | a connection if necessary. 17 | """ 18 | 19 | def disconnect(self) -> None: 20 | """ 21 | Disconnects from the queue 22 | 23 | This method is called after the queue will no longer be accessed and 24 | closes a connection if necessary. 25 | """ 26 | 27 | @abstractmethod 28 | def put(self, message: Message): 29 | """ 30 | Put a message onto the queue for sending 31 | 32 | Args: 33 | message: The message 34 | """ 35 | 36 | @abstractmethod 37 | def get(self, block: bool = True, timeout: float = None) -> Message: 38 | """ 39 | Get the next message from the queue 40 | 41 | Args: 42 | block: Whether to block 43 | timeout: The timeout 44 | 45 | Returns: 46 | The next message 47 | 48 | Raises: 49 | queue.Empty: If there are no messages 50 | """ 51 | -------------------------------------------------------------------------------- /agency/resources.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor 3 | 4 | 5 | class ResourceManager: 6 | """ 7 | Singleton for globally managing concurrency primitives 8 | """ 9 | _instance = None 10 | _initialized = False 11 | 12 | def __new__(cls, *args, **kwargs): 13 | if cls._instance is None: 14 | cls._instance = super(ResourceManager, cls).__new__(cls) 15 | return cls._instance 16 | 17 | def __init__(self, max_workers=None): 18 | if not self._initialized: 19 | self._max_workers = max_workers 20 | self.thread_pool_executor = ThreadPoolExecutor(self._max_workers) 21 | self.process_pool_executor = ProcessPoolExecutor(self._max_workers) 22 | self.multiprocessing_manager = multiprocessing.Manager() 23 | self._initialized = True 24 | -------------------------------------------------------------------------------- /agency/schema.py: -------------------------------------------------------------------------------- 1 | from typing import Dict, Optional 2 | 3 | from pydantic import BaseModel, Field 4 | 5 | 6 | class Meta(BaseModel): 7 | """A dictionary field for storing metadata about the message""" 8 | 9 | class Config: 10 | extra = "allow" 11 | validate_assignment = True 12 | 13 | id: str = Field( 14 | ..., 15 | description="The id of the message." 16 | ) 17 | 18 | parent_id: Optional[str] = Field( 19 | None, 20 | description="The id of the previous message that generated this message." 21 | ) 22 | 23 | 24 | class Action(BaseModel): 25 | """Schema for an action""" 26 | 27 | class Config: 28 | extra = "forbid" 29 | validate_assignment = True 30 | 31 | name: str = Field( 32 | ..., 33 | description="The name of the action." 34 | ) 35 | 36 | args: Optional[Dict] = Field( 37 | None, 38 | description="The arguments for the action." 39 | ) 40 | 41 | 42 | class Message(BaseModel): 43 | """The full message schema used for communication""" 44 | 45 | class Config: 46 | extra = "forbid" 47 | validate_assignment = True 48 | 49 | meta: Meta 50 | 51 | from_: str = Field( 52 | ..., 53 | alias="from", 54 | description="The id of the sender." 55 | ) 56 | 57 | to: str = Field( 58 | ..., 59 | description="The intended recipient of the message. If set to `*`, the message is broadcast." 60 | ) 61 | 62 | action: Action 63 | -------------------------------------------------------------------------------- /agency/space.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from abc import ABC, ABCMeta, abstractmethod 3 | from concurrent.futures import Executor 4 | from typing import Dict, List, Type 5 | 6 | from agency.agent import Agent 7 | from agency.logger import log 8 | from agency.processor import Processor, _EventProtocol 9 | from agency.queue import Queue 10 | from agency.resources import ResourceManager 11 | 12 | 13 | class Space(ABC, metaclass=ABCMeta): 14 | """ 15 | A Space is where Agents communicate. 16 | """ 17 | 18 | def __init__(self): 19 | self.processors: Dict[str, Processor] = {} 20 | self._processors_lock: threading.Lock = threading.Lock() 21 | 22 | def __enter__(self): 23 | log("debug", "Entering Space context") 24 | return self 25 | 26 | def __exit__(self, exc_type, exc_val, exc_tb): 27 | if exc_type is not None: 28 | log("debug", "Exiting Space context with exception", { 29 | "exc_type": exc_type, 30 | "exc_val": exc_val, 31 | "exc_tb": exc_tb, 32 | }) 33 | self.destroy() 34 | 35 | def add(self, 36 | agent_type: Type[Agent], 37 | agent_id: str, 38 | *agent_args, 39 | **agent_kwargs): 40 | """ 41 | Adds an agent to the space allowing it to communicate. 42 | 43 | This method adds the agent in a subprocess. The agent may not be 44 | directly accessed from the main thread. 45 | 46 | Args: 47 | agent_type: The type of agent to add 48 | agent_id: The id of the agent to add 49 | 50 | All other arguments are passed to the Agent constructor 51 | 52 | Raises: 53 | ValueError: If the agent ID is already in use 54 | """ 55 | self._add(foreground=False, 56 | agent_type=agent_type, 57 | agent_id=agent_id, 58 | *agent_args, 59 | **agent_kwargs) 60 | 61 | def add_foreground(self, 62 | agent_type: Type[Agent], 63 | agent_id: str, 64 | *agent_args, 65 | **agent_kwargs) -> Agent: 66 | """ 67 | Adds an agent to the space and returns it in the current thread. 68 | 69 | This method adds an agent using threading. The agent instance is 70 | returned allowing direct access. 71 | 72 | It is recommended to use the `add` method instead of this method in most 73 | cases. Agents added this way may block other agents or threads in the 74 | main process. Use this method when direct access to the agent instance 75 | is desired. 76 | 77 | Args: 78 | agent_type: The type of agent to add 79 | agent_id: The id of the agent to add 80 | 81 | All other arguments are passed to the Agent constructor 82 | 83 | Returns: 84 | The agent 85 | 86 | Raises: 87 | ValueError: If the agent ID is already in use 88 | """ 89 | agent = self._add(foreground=True, 90 | agent_type=agent_type, 91 | agent_id=agent_id, 92 | *agent_args, 93 | **agent_kwargs) 94 | return agent 95 | 96 | def remove(self, agent_id: str): 97 | """ 98 | Removes an agent from the space by id. 99 | 100 | This method cannot remove an agent added from a different instance. In 101 | other words, a Space instance cannot remove an agent that it did not 102 | add. 103 | 104 | Args: 105 | agent_id: The id of the agent to remove 106 | 107 | Raises: 108 | ValueError: If the agent is not present in the space 109 | """ 110 | self._stop_processor(agent_id) 111 | log("info", f"{agent_id}: removed from space") 112 | 113 | def destroy(self): 114 | """ 115 | Cleans up resources used by this space. 116 | 117 | Subclasses should call super().destroy() when overriding. 118 | """ 119 | self._stop_all_processors() 120 | 121 | def _add(self, 122 | foreground: bool, 123 | agent_type: Type[Agent], 124 | agent_id: str, 125 | *agent_args, 126 | **agent_kwargs) -> Agent: 127 | 128 | try: 129 | agent = self._start_processor( 130 | foreground=foreground, 131 | agent_type=agent_type, 132 | agent_id=agent_id, 133 | agent_args=agent_args, 134 | agent_kwargs=agent_kwargs, 135 | ) 136 | log("info", f"{agent_id}: added to space") 137 | return agent 138 | except: 139 | # clean up if an error occurs 140 | self.remove(agent_id) 141 | raise 142 | 143 | def _start_processor(self, 144 | foreground: bool, 145 | agent_type: Type[Agent], 146 | agent_id: str, 147 | agent_args: List, 148 | agent_kwargs: Dict): 149 | with self._processors_lock: 150 | # Early existence check. Processor.start() will also check. This is 151 | # because Spaces may be distributed. 152 | if agent_id in self.processors.keys(): 153 | raise ValueError(f"Agent '{agent_id}' already exists") 154 | 155 | self.processors[agent_id] = Processor( 156 | agent_type=agent_type, 157 | agent_id=agent_id, 158 | agent_args=agent_args, 159 | agent_kwargs=agent_kwargs, 160 | inbound_queue=self._create_inbound_queue(agent_id), 161 | outbound_queue=self._create_outbound_queue(agent_id), 162 | started=self._define_event(foreground=foreground), 163 | stopping=self._define_event(foreground=foreground), 164 | new_message_event=self._define_event(foreground=foreground), 165 | executor=self._get_executor(foreground=foreground), 166 | ) 167 | return self.processors[agent_id].start() 168 | 169 | def _stop_processor_unsafe(self, agent_id: str): 170 | self.processors[agent_id].stop() 171 | self.processors.pop(agent_id) 172 | 173 | def _stop_processor(self, agent_id: str): 174 | with self._processors_lock: 175 | self._stop_processor_unsafe(agent_id) 176 | 177 | def _stop_all_processors(self): 178 | for agent_id in list(self.processors.keys()): 179 | try: 180 | with self._processors_lock: 181 | self._stop_processor_unsafe(agent_id) 182 | except Exception as e: 183 | log("error", 184 | f"{agent_id}: processor failed to stop", e) 185 | 186 | def _get_executor(self, foreground: bool = False) -> Executor: 187 | if foreground: 188 | return ResourceManager().thread_pool_executor 189 | else: 190 | return ResourceManager().process_pool_executor 191 | 192 | def _define_event(self, foreground: bool = False) -> _EventProtocol: 193 | if foreground: 194 | return threading.Event() 195 | else: 196 | return ResourceManager().multiprocessing_manager.Event() 197 | 198 | @abstractmethod 199 | def _create_inbound_queue(self, agent_id) -> Queue: 200 | """ 201 | Returns a Queue suitable for receiving messages 202 | """ 203 | raise NotImplementedError 204 | 205 | @abstractmethod 206 | def _create_outbound_queue(self, agent_id) -> Queue: 207 | """ 208 | Returns a Queue suitable for sending messages 209 | """ 210 | raise NotImplementedError 211 | -------------------------------------------------------------------------------- /agency/spaces/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio-tech/AI-Agent-Framework/bc8d1493be3778c5306d7183c41f8bf1bf743dbe/agency/spaces/__init__.py -------------------------------------------------------------------------------- /agency/spaces/amqp_space.py: -------------------------------------------------------------------------------- 1 | import json 2 | import os 3 | import queue 4 | import socket 5 | import threading 6 | import time 7 | from concurrent.futures import Future 8 | from dataclasses import dataclass 9 | 10 | import amqp 11 | import kombu 12 | 13 | from agency.logger import log 14 | from agency.queue import Queue 15 | from agency.resources import ResourceManager 16 | from agency.schema import Message 17 | from agency.space import Space 18 | 19 | _BROADCAST_KEY = "__broadcast__" 20 | 21 | @dataclass 22 | class AMQPOptions: 23 | """A class that defines AMQP connection options""" 24 | hostname: str = 'localhost' 25 | port: int = '5672' 26 | username: str = 'guest' 27 | password: str = 'guest' 28 | virtual_host: str = '/' 29 | use_ssl: bool = False 30 | heartbeat: float = 60 31 | 32 | 33 | class _AMQPQueue(Queue): 34 | """An AMQP based Queue using the kombu library""" 35 | 36 | def __init__(self, amqp_options: AMQPOptions, exchange_name: str, routing_key: str): 37 | self.kombu_connection_options = { 38 | 'hostname': amqp_options.hostname, 39 | 'port': amqp_options.port, 40 | 'userid': amqp_options.username, 41 | 'password': amqp_options.password, 42 | 'virtual_host': amqp_options.virtual_host, 43 | 'ssl': amqp_options.use_ssl, 44 | 'heartbeat': amqp_options.heartbeat, 45 | } 46 | self.exchange_name: str = exchange_name 47 | self.routing_key: str = routing_key 48 | 49 | 50 | class _AMQPInboundQueue(_AMQPQueue): 51 | 52 | def __init__(self, amqp_options: AMQPOptions, exchange_name: str, routing_key: str): 53 | super().__init__(amqp_options, exchange_name, routing_key) 54 | self._connection: kombu.Connection = None 55 | self._exchange: kombu.Exchange = None 56 | self._direct_queue: kombu.Queue = None 57 | self._broadcast_queue: kombu.Queue = None 58 | self._heartbeat_future: Future = None 59 | self._received_queue: queue.Queue = None 60 | self._disconnecting: threading.Event = None 61 | 62 | def connect(self): 63 | log("debug", f"{self.routing_key}: connecting") 64 | 65 | self._received_queue = queue.Queue() 66 | 67 | def _callback(body, amqp_message): 68 | amqp_message.ack() 69 | self._received_queue.put(json.loads(body)) 70 | 71 | try: 72 | self._connection = kombu.Connection( 73 | **self.kombu_connection_options) 74 | self._connection.connect() 75 | self._exchange = kombu.Exchange( 76 | self.exchange_name, 'topic', durable=True) 77 | self._direct_queue = kombu.Queue( 78 | self.routing_key, 79 | exchange=self._exchange, 80 | routing_key=self.routing_key, 81 | exclusive=True) 82 | self._broadcast_queue = kombu.Queue( 83 | f"{self.routing_key}-broadcast", 84 | exchange=self._exchange, 85 | routing_key=_BROADCAST_KEY, 86 | exclusive=True) 87 | self._consumer = kombu.Consumer( 88 | self._connection, 89 | [self._direct_queue, self._broadcast_queue], 90 | callbacks=[_callback]) 91 | self._consumer.consume() 92 | except amqp.exceptions.ResourceLocked: 93 | raise ValueError(f"Agent '{self.routing_key}' already exists") 94 | 95 | # start heartbeat thread 96 | def _heartbeat_thread(disconnecting): 97 | log("debug", f"{self.routing_key}: heartbeat thread starting") 98 | try: 99 | while not disconnecting.is_set(): 100 | try: 101 | self._connection.heartbeat_check() 102 | self._connection.drain_events(timeout=0.2) 103 | time.sleep(0.1) 104 | except socket.timeout: 105 | pass 106 | except amqp.exceptions.ConnectionForced: 107 | log("warning", 108 | f"{self.routing_key}: heartbeat connection force closed") 109 | log("debug", f"{self.routing_key}: heartbeat thread stopped") 110 | self._disconnecting = threading.Event() 111 | self._disconnecting.clear() 112 | self._heartbeat_future = ResourceManager( 113 | ).thread_pool_executor.submit(_heartbeat_thread, self._disconnecting) 114 | 115 | log("debug", f"{self.routing_key}: connected") 116 | 117 | def disconnect(self): 118 | log("debug", f"{self.routing_key}: disconnecting") 119 | if self._disconnecting: 120 | self._disconnecting.set() 121 | try: 122 | if self._heartbeat_future is not None: 123 | self._heartbeat_future.result(timeout=5) 124 | finally: 125 | if self._connection: 126 | self._connection.close() 127 | log("debug", f"{self.routing_key}: disconnected") 128 | 129 | def put(self, message: Message): 130 | raise NotImplementedError("AMQPInboundQueue does not support put") 131 | 132 | def get(self, block: bool = True, timeout: float = None) -> Message: 133 | message = self._received_queue.get(block=block, timeout=timeout) 134 | return message 135 | 136 | 137 | class _AMQPOutboundQueue(_AMQPQueue): 138 | 139 | def __init__(self, amqp_options: AMQPOptions, exchange_name: str, routing_key: str): 140 | super().__init__(amqp_options, exchange_name, routing_key) 141 | self._exchange: kombu.Exchange = None 142 | 143 | def connect(self): 144 | self._exchange = kombu.Exchange( 145 | self.exchange_name, 'topic', durable=True) 146 | 147 | def put(self, message: Message): 148 | with kombu.Connection(**self.kombu_connection_options) as connection: 149 | with connection.Producer() as producer: 150 | if message['to'] == '*': 151 | producer.publish( 152 | json.dumps(message), 153 | exchange=self._exchange, 154 | routing_key=_BROADCAST_KEY, 155 | ) 156 | else: 157 | producer.publish( 158 | json.dumps(message), 159 | exchange=self._exchange, 160 | routing_key=message['to'], 161 | ) 162 | 163 | def get(self, block: bool = True, timeout: float = None) -> Message: 164 | raise NotImplementedError("AMQPOutboundQueue does not support get") 165 | 166 | 167 | class AMQPSpace(Space): 168 | """ 169 | A Space that uses AMQP for message delivery. 170 | 171 | This Space type is useful for distributing agents across multiple hosts. 172 | """ 173 | 174 | def __init__(self, amqp_options: AMQPOptions = None, exchange_name: str = "agency"): 175 | super().__init__() 176 | if amqp_options is None: 177 | amqp_options = self.__default_amqp_options() 178 | self.amqp_options = amqp_options 179 | self.exchange_name = exchange_name 180 | 181 | def __default_amqp_options(self) -> AMQPOptions: 182 | """ 183 | Returns a default AMQPOptions object configurable from environment 184 | variables. 185 | """ 186 | # TODO add support for AMQP_URL 187 | return AMQPOptions( 188 | hostname=os.environ.get('AMQP_HOST', 'localhost'), 189 | port=int(os.environ.get('AMQP_PORT', 5672)), 190 | username=os.environ.get('AMQP_USERNAME', 'guest'), 191 | password=os.environ.get('AMQP_PASSWORD', 'guest'), 192 | virtual_host=os.environ.get('AMQP_VHOST', '/'), 193 | use_ssl=False, 194 | heartbeat=60, 195 | ) 196 | 197 | def _create_inbound_queue(self, agent_id) -> Queue: 198 | return _AMQPInboundQueue( 199 | amqp_options=self.amqp_options, 200 | exchange_name=self.exchange_name, 201 | routing_key=agent_id, 202 | ) 203 | 204 | def _create_outbound_queue(self, agent_id) -> Queue: 205 | return _AMQPOutboundQueue( 206 | amqp_options=self.amqp_options, 207 | exchange_name=self.exchange_name, 208 | routing_key=agent_id, 209 | ) 210 | -------------------------------------------------------------------------------- /agency/spaces/local_space.py: -------------------------------------------------------------------------------- 1 | import multiprocessing 2 | import queue 3 | import threading 4 | from concurrent.futures import Future 5 | 6 | from agency.logger import log 7 | from agency.queue import Queue 8 | from agency.resources import ResourceManager 9 | from agency.schema import Message 10 | from agency.space import Space 11 | 12 | 13 | class _LocalQueue(Queue): 14 | """A multiprocessing based implementation of Queue""" 15 | 16 | def __init__(self, outbound_message_event: multiprocessing.Event = None): 17 | self.outbound_message_event = outbound_message_event 18 | self._queue = ResourceManager().multiprocessing_manager.Queue() 19 | 20 | def put(self, message: Message): 21 | self._queue.put(message) 22 | if self.outbound_message_event is not None: 23 | self.outbound_message_event.set() 24 | 25 | def get(self, block: bool = True, timeout: float = None) -> Message: 26 | return self._queue.get(block=block, timeout=timeout) 27 | 28 | 29 | class LocalSpace(Space): 30 | """ 31 | A LocalSpace allows Agents to communicate within the python application 32 | """ 33 | 34 | def __init__(self, max_workers=None): 35 | super().__init__() 36 | self._stop_router_event: threading.Event = threading.Event() 37 | self._outbound_message_event: multiprocessing.Event = ResourceManager(max_workers 38 | ).multiprocessing_manager.Event() 39 | self._router_future: Future = self._start_router_thread() 40 | 41 | def destroy(self): 42 | self._stop_router_thread() 43 | super().destroy() 44 | 45 | def _start_router_thread(self): 46 | def _router_thread(): 47 | """Routes outbound messages""" 48 | log("debug", "LocalSpace: router thread starting") 49 | while not self._stop_router_event.is_set(): 50 | self._outbound_message_event.wait(timeout=0.1) 51 | if self._stop_router_event.is_set(): 52 | log("debug", "LocalSpace: router thread stopping") 53 | break 54 | self._outbound_message_event.clear() 55 | # drain each outbound queue 56 | processors = list(self.processors.values()) 57 | for processor in processors: 58 | outbound_queue = processor.outbound_queue 59 | while True: 60 | try: 61 | message = outbound_queue.get(block=False) 62 | log("debug", f"LocalSpace: routing message", message) 63 | recipient_processors = [ 64 | processor for processor in processors 65 | if message["to"] == processor.agent_id or message["to"] == "*" 66 | ] 67 | for recipient_processor in recipient_processors: 68 | recipient_processor.inbound_queue.put(message) 69 | except queue.Empty: 70 | break 71 | log("debug", "LocalSpace: router thread stopped") 72 | 73 | return ResourceManager().thread_pool_executor.submit(_router_thread) 74 | 75 | def _stop_router_thread(self): 76 | self._stop_router_event.set() 77 | self._router_future.result() 78 | 79 | def _create_inbound_queue(self, agent_id) -> Queue: 80 | return _LocalQueue() 81 | 82 | def _create_outbound_queue(self, agent_id) -> Queue: 83 | return _LocalQueue(outbound_message_event=self._outbound_message_event) 84 | -------------------------------------------------------------------------------- /examples/demo/.env.example: -------------------------------------------------------------------------------- 1 | # Copy this file as .env and set values as needed 2 | 3 | # NOTE: 4 | # If defined here, the following log options will override the environment. 5 | # Keep them commented if you want to set them in your environment. 6 | 7 | # Set logging level (debug, info, warning, error, critical) 8 | # LOGLEVEL=info 9 | 10 | # Set pygments style used for logging objects 11 | # See: https://pygments.org/styles/ 12 | # LOG_PYGMENTS_STYLE= 13 | 14 | # Local path for downloaded HF models 15 | MODELS_PATH= 16 | 17 | # Port for the demo web application 18 | WEB_APP_PORT=8080 19 | 20 | # OpenAI key for the demo agents 21 | OPENAI_API_KEY= 22 | 23 | # AMQP settings 24 | AMQP_HOST=rabbitmq 25 | AMQP_PORT=5672 26 | AMQP_USERNAME=guest 27 | AMQP_PASSWORD=guest 28 | AMQP_VHOST=/ 29 | -------------------------------------------------------------------------------- /examples/demo/Dockerfile: -------------------------------------------------------------------------------- 1 | # Set base image 2 | FROM python:3.9 as base 3 | 4 | # Update system 5 | RUN apt-get update && apt-get upgrade -y 6 | 7 | WORKDIR /agency 8 | 9 | # Install build dependencies 10 | RUN pip install setuptools wheel poetry 11 | ENV POETRY_VIRTUALENVS_PATH=/venv 12 | 13 | # Install agency dependencies 14 | FROM base as agency_deps 15 | COPY ./pyproject.toml ./poetry.lock /agency/ 16 | RUN poetry install --no-root 17 | 18 | # Install demo app dependencies 19 | FROM base as demo_deps 20 | 21 | # Copy venv from agency_deps stage 22 | COPY --from=agency_deps /venv /venv 23 | 24 | # Install demo dependencies without full agency source 25 | COPY examples/demo/pyproject.toml examples/demo/poetry.lock ./examples/demo/ 26 | COPY pyproject.toml poetry.lock README.md ./ 27 | COPY agency/__init__.py ./agency/__init__.py 28 | RUN cd examples/demo && poetry update && poetry install 29 | 30 | # Build demo image 31 | FROM base 32 | 33 | # Copy venv from demo_deps stage 34 | COPY --from=demo_deps /venv /venv 35 | 36 | # Copy full source 37 | COPY . . 38 | 39 | # Go 40 | WORKDIR /agency/examples/demo 41 | CMD ["python", "demo_threaded.py"] 42 | -------------------------------------------------------------------------------- /examples/demo/README.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | This demo application is maintained as an experimental development environment 4 | and a showcase for library features. You are encouraged to use the source as a 5 | reference but beware that the quality is intended to be proof-of-concept only. 6 | 7 | 8 | ## Example Classes 9 | 10 | By default the demo includes the following two `Agent` classes: 11 | 12 | * `OpenAIFunctionAgent` - An LLM agent that uses the OpenAI function calling API 13 | * `Host` - An agent that exposes operating system access to the host system 14 | 15 | More agent examples are located under the [./agents](./agents/) directory. 16 | 17 | 18 | ## Running the demo 19 | 20 | The demo application uses docker-compose for orchestration. Configuration is 21 | included for running the demo using the different space types. To run the demo: 22 | 23 | 1. Ensure you have Docker installed on your system. 24 | 25 | 1. Run: 26 | 27 | git clone git@github.com:operand/agency.git 28 | cd agency/examples/demo 29 | cp .env.example .env 30 | 31 | 1. Open and populate the `.env` file with appropriate values. 32 | 33 | 1. Start the application. 34 | 35 | To run the `LocalSpace` application: 36 | ```sh 37 | ./demo run local 38 | ``` 39 | 40 | To run the `AMQPSpace` application: 41 | ```sh 42 | ./demo run amqp 43 | ``` 44 | 45 | 1. Visit [http://localhost:8080](http://localhost:8080) and try it out! 46 | 47 | 48 | ## The Gradio UI 49 | 50 | The Gradio UI is a [`Chatbot`](https://www.gradio.app/docs/chatbot) based 51 | application used for development and demonstration. 52 | 53 | It is defined in 54 | [examples/demo/apps/gradio_app.py](https://github.com/operand/agency/tree/main/examples/demo/apps/gradio_app.py) 55 | and simply needs to be imported and used like so: 56 | 57 | ```python 58 | from examples.demo.apps.gradio_app import GradioApp 59 | ... 60 | demo = GradioApp(space).demo() 61 | demo.launch() 62 | ``` 63 | 64 | The Gradio application automatically adds its user (you) to the space as an 65 | agent, allowing you to chat and invoke actions on the other agents. 66 | 67 | The application is designed to convert plain text input into a `say` action 68 | which is broadcast to the other agents in the space. For example, simply 69 | writing: 70 | 71 | ``` 72 | Hello, world! 73 | ``` 74 | 75 | will invoke the `say` action on all other agents in the space, passing the 76 | `content` argument as `Hello, world!`. Any agents which implement a `say` action 77 | will receive and process this message. 78 | 79 | 80 | ## Gradio App - Command Syntax 81 | 82 | The Gradio application also supports a command syntax for more control over 83 | invoking actions on other agents. 84 | 85 | For example, to send a point-to-point message to a specific agent, or to call 86 | actions other than `say`, you can use the following format: 87 | 88 | ``` 89 | /agent_id.action_name arg1:"value 1" arg2:"value 2" 90 | ``` 91 | 92 | A broadcast to all agents in the space is also supported using the `*` wildcard. 93 | For example, the following will broadcast the `say` action to all other agents, 94 | similar to how it would work without the slash syntax: 95 | 96 | ``` 97 | /*.say content:"Hello, world!" 98 | ``` 99 | -------------------------------------------------------------------------------- /examples/demo/agents/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio-tech/AI-Agent-Framework/bc8d1493be3778c5306d7183c41f8bf1bf743dbe/examples/demo/agents/__init__.py -------------------------------------------------------------------------------- /examples/demo/agents/chatty_ai.py: -------------------------------------------------------------------------------- 1 | import os 2 | import textwrap 3 | 4 | from agents.mixins.prompt_methods import PromptMethods 5 | from transformers import AutoModelForCausalLM, AutoTokenizer 6 | 7 | from agency.agent import Agent, action 8 | 9 | os.environ['TOKENIZERS_PARALLELISM'] = 'true' 10 | 11 | 12 | class ChattyAI(PromptMethods, Agent): 13 | """ 14 | Encapsulates a simple chatting AI backed by a language model. 15 | Uses the transformers library as a backend provider. 16 | """ 17 | 18 | def __init__(self, id: str, **kwargs): 19 | super().__init__(id) 20 | # initialize transformers model 21 | self.tokenizer = AutoTokenizer.from_pretrained(kwargs['model']) 22 | self.tokenizer.pad_token = self.tokenizer.eos_token 23 | self.model = AutoModelForCausalLM.from_pretrained(kwargs['model']) 24 | 25 | def _prompt_head(self) -> str: 26 | return textwrap.dedent(f""" 27 | Below is a conversation between "ChattyAI", an awesome AI that follows 28 | instructions and a human who they serve. 29 | """) + \ 30 | self._message_log_to_list(self._message_log) 31 | 32 | def _message_line(self, message: dict, indent: int = None) -> str: 33 | pre_prompt = self._pre_prompt(message['from'].split('.')[0]) 34 | # Here we format what a previous message looks like in the prompt 35 | # For "say" actions, we just present the content as a line of text 36 | if message['action']['name'] == 'say': 37 | return f"\n{pre_prompt} {message['action']['args']['content']}" 38 | else: 39 | return "" 40 | 41 | def _pre_prompt(self, agent_id: str, timestamp=None) -> str: 42 | return f"\n### {agent_id.split('.')[0]}: " 43 | 44 | @action 45 | def say(self, content: str): 46 | """ 47 | Use this action to say something to Chatty 48 | """ 49 | full_prompt = self._full_prompt() 50 | input_ids = self.tokenizer.encode(full_prompt, return_tensors="pt") 51 | output = self.model.generate( 52 | input_ids, 53 | attention_mask=input_ids.new_ones(input_ids.shape), 54 | do_sample=True, 55 | max_new_tokens=50, 56 | ) 57 | new_tokens = output[0][input_ids.shape[1]:] 58 | response_text = self.tokenizer.decode( 59 | new_tokens, 60 | skip_special_tokens=True, 61 | ) 62 | response_content = response_text.split('\n###')[0] 63 | self.send({ 64 | "to": self.current_message['from'], 65 | "action": { 66 | "name": "say", 67 | "args": { 68 | "content": response_content, 69 | } 70 | } 71 | }) 72 | -------------------------------------------------------------------------------- /examples/demo/agents/host.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | from agents.mixins.help_methods import HelpMethods 5 | 6 | from agency.agent import ACCESS_REQUESTED, Agent, action 7 | 8 | 9 | class Host(HelpMethods, Agent): 10 | """ 11 | Represents the host system of the running application 12 | """ 13 | 14 | @action(access_policy=ACCESS_REQUESTED) 15 | def shell_command(self, command: str) -> str: 16 | """Execute a shell command""" 17 | command = ["bash", "-l", "-c", command] 18 | result = subprocess.run( 19 | command, 20 | stdout=subprocess.PIPE, 21 | stderr=subprocess.PIPE, 22 | text=True, 23 | ) 24 | output = result.stdout + result.stderr 25 | if result.returncode != 0: 26 | raise Exception(output) 27 | self.respond_with(output) 28 | 29 | @action(access_policy=ACCESS_REQUESTED) 30 | def write_to_file(self, filepath: str, text: str, mode: str = "w") -> str: 31 | """Write to a file""" 32 | with open(filepath, mode) as f: 33 | f.write(text) 34 | self.respond_with(f"Wrote to {filepath}") 35 | 36 | @action(access_policy=ACCESS_REQUESTED) 37 | def read_file(self, filepath: str) -> str: 38 | """Read a file""" 39 | with open(filepath, "r") as f: 40 | text = f.read() 41 | self.respond_with(text) 42 | 43 | @action(access_policy=ACCESS_REQUESTED) 44 | def delete_file(self, filepath: str) -> str: 45 | """Delete a file""" 46 | os.remove(filepath) 47 | self.respond_with(f"Deleted {filepath}") 48 | 49 | @action(access_policy=ACCESS_REQUESTED) 50 | def list_files(self, directory_path: str) -> str: 51 | """List files in a directory""" 52 | files = os.listdir(directory_path) 53 | self.respond_with(f"{files}") 54 | 55 | def request_permission(self, proposed_message: dict) -> bool: 56 | """Asks for permission on the command line""" 57 | # TODO: This functionality is temporarily disabled in the demo. All 58 | # actions are allowed for now. 59 | return True 60 | -------------------------------------------------------------------------------- /examples/demo/agents/mixins/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio-tech/AI-Agent-Framework/bc8d1493be3778c5306d7183c41f8bf1bf743dbe/examples/demo/agents/mixins/__init__.py -------------------------------------------------------------------------------- /examples/demo/agents/mixins/help_methods.py: -------------------------------------------------------------------------------- 1 | from typing import Dict 2 | 3 | 4 | class HelpMethods(): 5 | """ 6 | A utility mixin for simple discovery of actions upon agent addition 7 | 8 | Adds a member dictionary named `_available_actions`, of the form: 9 | { 10 | "agent id": { 11 | "action name": 12 | ... 13 | } 14 | } 15 | 16 | Where the help object above is whatever is returned by the `help` method for 17 | each action. 18 | 19 | NOTE This does not handle agent removal 20 | """ 21 | 22 | def after_add(self): 23 | """ 24 | Broadcasts two messages on add: 25 | 1. a message to request actions from other agents 26 | 2. a message to announce its actions to other agents 27 | """ 28 | self._available_actions: Dict[str, Dict[str, dict]] = {} 29 | self.send({ 30 | "meta": { 31 | "id": "help_request", 32 | }, 33 | "to": "*", 34 | "action": { 35 | "name": "help", 36 | } 37 | }) 38 | self.send({ 39 | "meta": { 40 | "parent_id": "help_request", 41 | }, 42 | "to": "*", 43 | "action": { 44 | "name": "[response]", 45 | "args": { 46 | "value": self._help(), 47 | } 48 | } 49 | }) 50 | 51 | def handle_action_value(self, value): 52 | current_message = self.current_message() 53 | if current_message["meta"].get("parent_id", None) == "help_request": 54 | self._available_actions[current_message["from"]] = value 55 | else: 56 | # this was in response to something else, call the original 57 | super().handle_action_value(value) 58 | -------------------------------------------------------------------------------- /examples/demo/agents/mixins/prompt_methods.py: -------------------------------------------------------------------------------- 1 | import json 2 | from abc import ABC, ABCMeta, abstractmethod 3 | from datetime import datetime 4 | from typing import List 5 | 6 | from agency import util 7 | from agency.schema import Message 8 | 9 | 10 | class PromptMethods(ABC, metaclass=ABCMeta): 11 | """ 12 | A mixin containing utility methods for constructing prompts from the message 13 | log 14 | """ 15 | 16 | def _full_prompt(self): 17 | """ 18 | Returns the full prompt, including the pre-prompt and the message log 19 | """ 20 | return self._prompt_head() + self._pre_prompt(agent_id=self.id()) 21 | 22 | def _message_log_to_list(self, message_log: List[Message]) -> str: 23 | """Convert an array of message_log entries to a prompt ready list""" 24 | promptable_list = "" 25 | for message in message_log: 26 | promptable_list += self._message_line(message) 27 | return promptable_list 28 | 29 | @abstractmethod 30 | def _prompt_head(self): 31 | """ 32 | Returns the "head" of the prompt, the contents up to the pre-prompt 33 | """ 34 | 35 | @abstractmethod 36 | def _pre_prompt(self, agent_id: str, timestamp=util.to_timestamp(datetime.now())): 37 | """ 38 | Returns the "pre-prompt", the special string sequence that indicates it is 39 | ones turn to act: e.g. "### Assistant: " 40 | """ 41 | 42 | @abstractmethod 43 | def _message_line(self, message: Message) -> str: 44 | """ 45 | Returns a single line for a prompt that represents a previous message 46 | """ 47 | 48 | DEFAULT_TIMESTAMP_FORMAT = '%Y-%m-%d %H:%M:%S' 49 | 50 | def to_timestamp(dt=datetime.now(), date_format=DEFAULT_TIMESTAMP_FORMAT): 51 | """Convert a datetime to a timestamp""" 52 | return dt.strftime(date_format) 53 | 54 | def extract_json(input: str, stopping_strings: list = []): 55 | """Util method to extract JSON from a string""" 56 | stopping_string = next((s for s in stopping_strings if s in input), '') 57 | split_string = input.split(stopping_string, 1)[ 58 | 0] if stopping_string else input 59 | start_position = split_string.find('{') 60 | end_position = split_string.rfind('}') + 1 61 | 62 | if start_position == -1 or end_position == -1 or start_position > end_position: 63 | raise ValueError(f"Couldn't find valid JSON in \"{input}\"") 64 | 65 | try: 66 | return json.loads(split_string[start_position:end_position]) 67 | except json.JSONDecodeError: 68 | raise ValueError(f"Couldn't parse JSON in \"{input}\"") 69 | -------------------------------------------------------------------------------- /examples/demo/agents/mixins/say_response_methods.py: -------------------------------------------------------------------------------- 1 | from agency.agent import ActionError 2 | 3 | 4 | class SayResponseMethods(): 5 | """ 6 | A mixin for converting incoming responses into `say` actions. 7 | 8 | This is intended to be used within a chat-like context. It will treat the 9 | `say` action as the primary way to communicate. It assumes all other actions 10 | are function calls whose responses should be converted into `say` actions. 11 | 12 | NOTE The _message_log will contain both messages 13 | """ 14 | 15 | def handle_action_value(self, value): 16 | if self.parent_message()["action"]["name"] != "say": 17 | # This was in response to a function call, convert it to a `say` 18 | self._receive({ 19 | **self.current_message(), 20 | "action": { 21 | "name": "say", 22 | "args": { 23 | "content": f"{value}", 24 | } 25 | }, 26 | }) 27 | 28 | def handle_action_error(self, error: ActionError): 29 | # convert errors into a `say` for inspection 30 | self._receive({ 31 | **self.current_message(), 32 | "action": { 33 | "name": "say", 34 | "args": { 35 | "content": f"ERROR: {error}", 36 | } 37 | }, 38 | }) 39 | -------------------------------------------------------------------------------- /examples/demo/agents/openai_completion_agent.py: -------------------------------------------------------------------------------- 1 | import json 2 | import textwrap 3 | from datetime import datetime 4 | 5 | import openai 6 | from agents.mixins.help_methods import HelpMethods 7 | from agents.mixins.prompt_methods import PromptMethods 8 | from agents.mixins.say_response_methods import SayResponseMethods 9 | 10 | from agency import util 11 | from agency.agent import Agent, action 12 | from agency.schema import Message 13 | 14 | 15 | class OpenAICompletionAgent(HelpMethods, SayResponseMethods, PromptMethods, Agent): 16 | """ 17 | An agent which uses OpenAI's completion API for inference 18 | """ 19 | 20 | def __init__(self, id, model, openai_api_key, **args): 21 | super().__init__(id, **args) 22 | self.__model = model 23 | openai.api_key = openai_api_key 24 | 25 | def _prompt_head(self): 26 | return textwrap.dedent(f""" 27 | I am "{self.id()}". I am an early prototype of an "agent" system which 28 | can freely interact with its environment. 29 | 30 | My creator has recently open-sourced my codebase intended to allow 31 | others to easily create their own AI integrated systems. He is 32 | demonstrating my abilities so that others can better understand agents 33 | like myself and how we may integrate with the world. 34 | 35 | My goal is to demonstrate my accurate understanding of the world and my 36 | ability to communicate with other systems as needed to solve any 37 | problems at hand. 38 | 39 | In order to communicate, I use a simple terminal application where I can 40 | interact via JSON formatted messages. I can send messages to discover 41 | and interact with other systems, AI agents, or humans who may also be 42 | present. The following JSON schema describes the message format: 43 | 44 | ``` 45 | {json.dumps(Message.schema())} 46 | ``` 47 | 48 | %%%%% Terminal App 1.0.0 %%%%% 49 | """) + \ 50 | self._message_log_to_list([ 51 | # Ignore outgoing help_request messages 52 | message 53 | for message in self._message_log 54 | if not (message['from'] == self.id() and message.get('id') == "help_request") 55 | ]) 56 | 57 | def _pre_prompt(self, agent_id, timestamp=util.to_timestamp(datetime.now())): 58 | return f"\n[{timestamp}] {agent_id}:" 59 | 60 | def _message_line(self, message: dict): 61 | pre_prompt = self._pre_prompt(message['from']) 62 | return f"{pre_prompt} {json.dumps(message)}/END" 63 | 64 | @action 65 | def say(self, content: str) -> bool: 66 | # NOTE that we don't use the content arg here since we construct the 67 | # prompt from the message log 68 | full_prompt = self._full_prompt() 69 | completion = openai.Completion.create( 70 | model=self.__model, 71 | prompt=full_prompt, 72 | temperature=0.1, 73 | max_tokens=500, 74 | ) 75 | # parse the output 76 | action = util.extract_json(completion.choices[0].text, ["/END"]) 77 | self.send(action) 78 | -------------------------------------------------------------------------------- /examples/demo/agents/openai_function_agent.py: -------------------------------------------------------------------------------- 1 | import json 2 | import textwrap 3 | 4 | import openai 5 | from agents.mixins.help_methods import HelpMethods 6 | from agents.mixins.say_response_methods import SayResponseMethods 7 | 8 | from agency.agent import _RESPONSE_ACTION_NAME, Agent, action 9 | 10 | 11 | class OpenAIFunctionAgent(HelpMethods, SayResponseMethods, Agent): 12 | """ 13 | An agent which uses OpenAI's function calling API 14 | """ 15 | 16 | def __init__(self, id, model, openai_api_key, user_id): 17 | super().__init__(id, receive_own_broadcasts=False) 18 | self.__model = model 19 | self.__user_id = user_id 20 | openai.api_key = openai_api_key 21 | 22 | def __system_prompt(self): 23 | return textwrap.dedent(f""" 24 | You are "{self.id()}". You are a prototype of an "agent" system which 25 | can freely interact with its environment. 26 | 27 | Your creator has recently open-sourced your codebase intended to allow 28 | others to easily create their own AI integrated systems. He is 29 | demonstrating your abilities so that others can better understand agents 30 | like yourself and how you may integrate with the world. 31 | 32 | Your goal is to demonstrate your accurate understanding of the world and 33 | your ability to solve any problems at hand. 34 | 35 | The following is your current conversation. Respond appropriately. 36 | """) 37 | 38 | def __open_ai_messages(self): 39 | """ 40 | Returns a list of messages converted from the message_log to be sent to 41 | OpenAI 42 | """ 43 | # start with the system message 44 | open_ai_messages = [ 45 | {"role": "system", "content": self.__system_prompt()}] 46 | 47 | # format and add the rest of the messages 48 | # NOTE: the chat api limits to only four predefined roles so we do our 49 | # best to translate to them here. 50 | for message in self._message_log: 51 | # ignore response messages 52 | if message['action']['name'] != _RESPONSE_ACTION_NAME: 53 | # "say" actions are converted to messages using the content arg 54 | if message['action']['name'] == "say": 55 | # assistant 56 | if message['from'] == self.id(): 57 | open_ai_messages.append({ 58 | "role": "assistant", 59 | "content": message["action"]["args"]["content"], 60 | }) 61 | # user 62 | elif message['from'] == self.__user_id: 63 | open_ai_messages.append({ 64 | "role": "user", 65 | "content": message["action"]["args"]["content"], 66 | }) 67 | 68 | # a "say" from anyone else is considered a function message 69 | else: 70 | open_ai_messages.append({ 71 | "role": "function", 72 | "name": f"{'-'.join(message['from'].split('.'))}-{message['action']['name']}", 73 | "content": message["action"]["args"]["content"], 74 | }) 75 | 76 | # all other actions are considered a function_call 77 | else: 78 | # AFAICT from the documentation I've found, it does not 79 | # appear that openai suggests including function_call 80 | # messages (the responses from openai) in the messages list. 81 | # 82 | # I am going to add them here as a "system" message 83 | # reporting the details of what the function call was. This 84 | # is important information to infer from and it's currently 85 | # not clear whether the language model has access to it 86 | # during inference. 87 | open_ai_messages.append({ 88 | "role": "system", 89 | "content": f"""{message['from']} called function "{message['action']['name']}" with args {message['action'].get('args', {})}""", 90 | }) 91 | 92 | return open_ai_messages 93 | 94 | def __open_ai_functions(self): 95 | """ 96 | Returns a list of functions converted from space._get_help__sync() to be 97 | sent to OpenAI as the functions arg 98 | """ 99 | functions = [ 100 | { 101 | # note that we send a fully qualified name for the action and 102 | # convert '.' to '-' since openai doesn't allow '.' 103 | "name": f"{agent_id}-{action_name}", 104 | "description": action_help.get("description", ""), 105 | "parameters": { 106 | "type": "object", 107 | "properties": action_help['args'], 108 | "required": [ 109 | # We don't currently support a notion of required args 110 | # so we make everything required 111 | arg_name for arg_name in action_help['args'].keys() 112 | ], 113 | } 114 | } 115 | for agent_id, actions in self._available_actions.items() 116 | for action_name, action_help in actions.items() 117 | if not (agent_id == self.__user_id and action_name == "say") 118 | # the openai chat api handles a chat message differently than a 119 | # function, so we don't list the user's "say" action as a function 120 | ] 121 | return functions 122 | 123 | @action 124 | def say(self, content: str) -> bool: 125 | """ 126 | Sends a message to this agent 127 | """ 128 | completion = openai.ChatCompletion.create( 129 | model=self.__model, 130 | messages=self.__open_ai_messages(), 131 | functions=self.__open_ai_functions(), 132 | function_call="auto", 133 | # ... https://platform.openai.com/docs/api-reference/chat/create 134 | ) 135 | 136 | # parse the output 137 | message = { 138 | "to": self.__user_id, 139 | "action": {} 140 | } 141 | response_message = completion['choices'][0]['message'] 142 | if 'function_call' in response_message: 143 | # extract receiver and action 144 | function_parts = response_message['function_call']['name'].split( 145 | '-') 146 | message['to'] = "-".join(function_parts[:-1]) # all but last 147 | message['action']['name'] = function_parts[-1] # last 148 | # arguments comes as a string when it probably should be an object 149 | if isinstance(response_message['function_call']['arguments'], str): 150 | message['action']['args'] = json.loads( 151 | response_message['function_call']['arguments']) 152 | else: 153 | message['action']['args'] = response_message['function_call']['arguments'] 154 | else: 155 | message['action']['name'] = "say" 156 | message['action']['args'] = { 157 | "content": response_message['content'], 158 | } 159 | 160 | self.send(message) 161 | -------------------------------------------------------------------------------- /examples/demo/apps/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio-tech/AI-Agent-Framework/bc8d1493be3778c5306d7183c41f8bf1bf743dbe/examples/demo/apps/__init__.py -------------------------------------------------------------------------------- /examples/demo/apps/gradio_app.py: -------------------------------------------------------------------------------- 1 | import json 2 | import re 3 | import gradio as gr 4 | from agency.agent import Agent, action 5 | from agency.schema import Message 6 | 7 | 8 | class GradioUser(Agent): 9 | """ 10 | Represents the Gradio user as an Agent and contains methods for integrating 11 | with the Chatbot component 12 | """ 13 | def __init__(self, id: str): 14 | super().__init__(id, receive_own_broadcasts=False) 15 | 16 | @action 17 | def say(self, content): 18 | # We don't do anything to render an incoming message here because the 19 | # get_chatbot_messages method will render the full message history 20 | pass 21 | 22 | def send_message(self, text): 23 | """ 24 | Sends a message as this user 25 | """ 26 | message = self.__parse_input_message(text) 27 | self.send(message) 28 | return "", self.get_chatbot_messages() 29 | 30 | def get_chatbot_messages(self): 31 | """ 32 | Returns the full message history for the Chatbot component 33 | """ 34 | return [ 35 | self.__chatbot_message(message) 36 | for message in self._message_log 37 | ] 38 | 39 | def __chatbot_message(self, message): 40 | """ 41 | Returns a single message as a tuple for the Chatbot component 42 | """ 43 | text = f"**{message['from']}:** " 44 | if message['action']['name'] == 'say': 45 | text += f"{message['action']['args']['content']}" 46 | else: 47 | text += f"\n```javascript\n{json.dumps(message, indent=2)}\n```" 48 | 49 | if message['from'] == self.id(): 50 | return text, None 51 | else: 52 | return None, text 53 | 54 | def __parse_input_message(self, text) -> Message: 55 | """ 56 | Parses input text into a message. 57 | 58 | If the text does not begin with "/", it is assumed to be a broadcasted 59 | "say" action, with the content argument set to the text. 60 | 61 | If the text begins with "/", it is assumed to be of the form: 62 | 63 | /agent_id.action_name arg1:val1 arg2:val2 ... 64 | 65 | Where agent_id and all argument names and values must be enclosed in 66 | quotes if they contain spaces. For example: 67 | 68 | /"agent with a space in the id".say content:"Hello, agent!" 69 | 70 | Returns: 71 | Message: The parsed message for sending 72 | """ 73 | text = text.strip() 74 | 75 | if not text.startswith("/"): 76 | # assume it's a broadcasted "say" 77 | return { 78 | "to": "*", 79 | "action": { 80 | "name": "say", 81 | "args": { 82 | "content": text 83 | } 84 | } 85 | } 86 | 87 | pattern = r'^/(?:((?:"[^"]+")|(?:[^.\s]+))\.)?(\w+)\s*(.*)$' 88 | match = re.match(pattern, text) 89 | 90 | if not match: 91 | raise ValueError("Invalid input format") 92 | 93 | agent_id, action_name, args_str = match.groups() 94 | 95 | if agent_id is None: 96 | raise ValueError("Agent ID must be provided. Example: '/MyAgent.say' or '/*.say'") 97 | 98 | args_pattern = r'(\w+):"([^"]*)"' 99 | args = dict(re.findall(args_pattern, args_str)) 100 | 101 | return { 102 | "to": agent_id.strip('"'), 103 | "action": { 104 | "name": action_name, 105 | "args": args 106 | } 107 | } 108 | 109 | def demo(self): 110 | # The following adapted from: https://www.gradio.app/docs/chatbot#demos 111 | 112 | # Custom css to: 113 | # - Expand text area to fill vertical space 114 | # - Remove orange border from the chat area that appears because of polling 115 | css = """ 116 | .gradio-container { 117 | height: 100vh !important; 118 | } 119 | 120 | .gradio-container > .main, 121 | .gradio-container > .main > .wrap, 122 | .gradio-container > .main > .wrap > .contain, 123 | .gradio-container > .main > .wrap > .contain > div { 124 | height: 100% !important; 125 | } 126 | 127 | #chatbot { 128 | height: auto !important; 129 | flex-grow: 1 !important; 130 | } 131 | 132 | #chatbot > div.wrap { 133 | border: none !important; 134 | } 135 | """ 136 | 137 | with gr.Blocks(css=css, title="Agency Demo") as demo: 138 | # Chatbot area 139 | chatbot = gr.Chatbot( 140 | self.get_chatbot_messages, 141 | show_label=False, 142 | elem_id="chatbot", 143 | ) 144 | 145 | # Input area 146 | with gr.Row(): 147 | txt = gr.Textbox( 148 | show_label=False, 149 | placeholder="Enter text and press enter", 150 | container=False, 151 | ) 152 | btn = gr.Button("Send", scale=0) 153 | 154 | # Callbacks 155 | txt.submit(self.send_message, [txt], [txt, chatbot]) 156 | btn.click(self.send_message, [txt], [txt, chatbot]) 157 | 158 | # Continously updates the chatbot. Runs only while client is connected. 159 | demo.load( 160 | self.get_chatbot_messages, None, [chatbot], every=1 161 | ) 162 | 163 | # Queueing necessary for periodic events using `every` 164 | demo.queue() 165 | return demo 166 | -------------------------------------------------------------------------------- /examples/demo/apps/react_app.py: -------------------------------------------------------------------------------- 1 | import logging 2 | 3 | import eventlet 4 | from eventlet import wsgi 5 | from flask import Flask, render_template, request 6 | from flask.logging import default_handler 7 | from flask_socketio import SocketIO 8 | 9 | from agency.agent import ActionError, Agent, action 10 | from agency.schema import Message 11 | from agency.space import Space 12 | 13 | # IMPORTANT! This example react application is out of date and untested, but is 14 | # left here for reference. It will be updated or replaced in the future. 15 | 16 | 17 | class ReactApp(): 18 | """ 19 | A simple Flask/React web application which connects human users to a space. 20 | """ 21 | 22 | def __init__(self, space: Space, port: int, demo_username: str): 23 | self.__space = space 24 | self.__port = port 25 | self.__demo_username = demo_username 26 | self.__current_user = None 27 | 28 | def start(self): 29 | """ 30 | Run Flask server in a separate thread 31 | """ 32 | app = Flask(__name__) 33 | app.config['SECRET_KEY'] = 'secret!' 34 | 35 | # six lines to disable logging... 36 | app.logger.removeHandler(default_handler) 37 | app.logger.setLevel(logging.ERROR) 38 | werkzeug_log = logging.getLogger('werkzeug') 39 | werkzeug_log.setLevel(logging.ERROR) 40 | eventlet_logger = logging.getLogger('eventlet.wsgi.server') 41 | eventlet_logger.setLevel(logging.ERROR) 42 | 43 | # start socketio server 44 | self.socketio = SocketIO(app, async_mode='eventlet', 45 | logger=False, engineio_logger=False) 46 | 47 | # Define routes 48 | @app.route('/') 49 | def index(): 50 | return render_template( 51 | 'index.html', 52 | username=f"{self.__demo_username}") 53 | 54 | @self.socketio.on('connect') 55 | def handle_connect(): 56 | # When a client connects add them to the space 57 | # NOTE We're hardcoding a single demo_username for simplicity 58 | self.__current_user = ReactAppUser( 59 | name=self.__demo_username, 60 | app=self, 61 | sid=request.sid 62 | ) 63 | self.__space.add(self.__current_user) 64 | 65 | @self.socketio.on('disconnect') 66 | def handle_disconnect(): 67 | # When a client disconnects remove them from the space 68 | self.__space.remove(self.__current_user) 69 | self.__current_user = None 70 | 71 | @self.socketio.on('message') 72 | def handle_action(action): 73 | """ 74 | Handles sending incoming actions from the web interface 75 | """ 76 | self.__current_user.send(action) 77 | 78 | @self.socketio.on('permission_response') 79 | def handle_alert_response(allowed: bool): 80 | """ 81 | Handles incoming alert response from the web interface 82 | """ 83 | raise NotImplementedError() 84 | 85 | # Wrap the Flask application with wsgi middleware and start 86 | def run_server(): 87 | wsgi.server(eventlet.listen(('', int(self.__port))), 88 | app, log=eventlet_logger) 89 | eventlet.spawn(run_server) 90 | 91 | 92 | class ReactAppUser(Agent): 93 | """ 94 | A human user of the web app 95 | """ 96 | 97 | def __init__(self, name: str, app: ReactApp, sid: str) -> None: 98 | super().__init__(id=name) 99 | self.name = name 100 | self.app = app 101 | self.sid = sid 102 | 103 | def request_permission(self, proposed_message: Message) -> bool: 104 | """ 105 | Raises an alert in the users browser and returns true if the user 106 | approves the action 107 | """ 108 | self.app.socketio.server.emit( 109 | 'permission_request', proposed_message) 110 | 111 | # The following methods simply forward incoming messages to the web client 112 | 113 | @action 114 | def say(self, content: str): 115 | """ 116 | Sends a message to the user 117 | """ 118 | self.app.socketio.server.emit( 119 | 'message', self.current_message(), room=self.sid) 120 | 121 | def handle_action_value(self, value): 122 | self.app.socketio.server.emit( 123 | 'message', self.current_message(), room=self.sid) 124 | 125 | def handle_action_error(self, error: ActionError): 126 | self.app.socketio.server.emit( 127 | 'message', self.current_message(), room=self.sid) 128 | -------------------------------------------------------------------------------- /examples/demo/apps/templates/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Chat App 6 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 | 50 | 51 | {% raw %} 52 | 227 | {% endraw %} 228 | 229 | 230 | -------------------------------------------------------------------------------- /examples/demo/demo: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # This script mostly passes subcommands to docker-compose but overrides some 4 | # subcommands to add default options. See below. 5 | 6 | 7 | set -ae 8 | source .env 9 | 10 | 11 | function dc() { 12 | echo "Running (docker-compose $@) ..." 13 | eval "docker-compose $@" 14 | } 15 | 16 | 17 | SUBCOMMAND=$1 18 | if [ -z "$SUBCOMMAND" ]; then 19 | dc --help 20 | exit 1 21 | fi 22 | shift 23 | 24 | 25 | # add default arguments to these compose commands 26 | if [[ "$SUBCOMMAND" == "run" ]]; then 27 | command="$SUBCOMMAND --build --remove-orphans --rm --service-ports $@" 28 | elif [[ "$SUBCOMMAND" == "up" ]]; then 29 | command="$SUBCOMMAND --build --remove-orphans --force-recreate --always-recreate-deps --abort-on-container-exit $@" 30 | elif [[ "$SUBCOMMAND" == "down" ]]; then 31 | command="$SUBCOMMAND --remove-orphans $@" 32 | 33 | 34 | # otherwise just pass the subcommand through to docker-compose 35 | else 36 | command="$SUBCOMMAND $@" 37 | fi 38 | 39 | 40 | dc $command 41 | -------------------------------------------------------------------------------- /examples/demo/demo_amqp.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | from agency.spaces.amqp_space import AMQPSpace 5 | from examples.demo.agents.host import Host 6 | from examples.demo.agents.openai_function_agent import OpenAIFunctionAgent 7 | from examples.demo.apps.gradio_app import GradioUser 8 | 9 | if __name__ == "__main__": 10 | 11 | # Create the space instance 12 | with AMQPSpace() as space: 13 | 14 | # Add a host agent to the space, exposing access to the host system 15 | space.add(Host, "Host") 16 | 17 | # Add an OpenAI function API agent to the space 18 | space.add(OpenAIFunctionAgent, 19 | "FunctionAI", 20 | model="gpt-3.5-turbo-16k", 21 | openai_api_key=os.getenv("OPENAI_API_KEY"), 22 | # user_id determines the "user" role in the OpenAI chat API 23 | user_id="User") 24 | 25 | # Connect the Gradio app user to the space 26 | gradio_user: GradioUser = space.add_foreground(GradioUser, "User") 27 | 28 | # Launch the gradio app 29 | gradio_user.demo().launch( 30 | server_name="0.0.0.0", 31 | server_port=8080, 32 | prevent_thread_lock=True, 33 | quiet=True, 34 | ) 35 | 36 | try: 37 | # block here until Ctrl-C 38 | while True: 39 | time.sleep(1) 40 | except KeyboardInterrupt: 41 | pass 42 | -------------------------------------------------------------------------------- /examples/demo/demo_local.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | from agency.spaces.local_space import LocalSpace 5 | from examples.demo.agents.host import Host 6 | from examples.demo.agents.openai_function_agent import OpenAIFunctionAgent 7 | from examples.demo.apps.gradio_app import GradioUser 8 | 9 | if __name__ == "__main__": 10 | 11 | # Create the space instance 12 | with LocalSpace() as space: 13 | 14 | # Add a host agent to the space, exposing access to the host system 15 | space.add(Host, "Host") 16 | 17 | # Add an OpenAI function API agent to the space 18 | space.add(OpenAIFunctionAgent, 19 | "FunctionAI", 20 | model="gpt-3.5-turbo-16k", 21 | openai_api_key=os.getenv("OPENAI_API_KEY"), 22 | # user_id determines the "user" role in the OpenAI chat API 23 | user_id="User") 24 | 25 | # Connect the Gradio app user to the space 26 | gradio_user: GradioUser = space.add_foreground(GradioUser, "User") 27 | 28 | # Launch the gradio app 29 | gradio_user.demo().launch( 30 | server_name="0.0.0.0", 31 | server_port=8080, 32 | prevent_thread_lock=True, 33 | quiet=True, 34 | ) 35 | 36 | try: 37 | # block here until Ctrl-C 38 | while True: 39 | time.sleep(1) 40 | except KeyboardInterrupt: 41 | pass 42 | -------------------------------------------------------------------------------- /examples/demo/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | 4 | # Shared config for all containers 5 | x-demo-base: 6 | &demo-base 7 | build: 8 | context: ../.. # root of the repo 9 | dockerfile: examples/demo/Dockerfile 10 | volumes: 11 | # mounts models dir into container for reuse 12 | - $MODELS_PATH:/models 13 | # mounts source into container for development 14 | - ../..:/agency 15 | env_file: .env 16 | environment: 17 | LOGLEVEL: # pass through 18 | LOG_PYGMENTS_STYLE: # pass through 19 | TRANSFORMERS_CACHE: /models/transformers_cache 20 | 21 | 22 | services: 23 | 24 | # This container demonstrates using a LocalSpace 25 | local: 26 | <<: *demo-base 27 | profiles: [local] 28 | ports: 29 | - '$WEB_APP_PORT:8080' 30 | # socat is used to redirect from the hardcoded ip:port in gradio's dev mode 31 | # https://github.com/gradio-app/gradio/issues/3656 32 | command: | 33 | bash -ce " 34 | poetry run python demo_local.py 35 | " 36 | tty: true 37 | stdin_open: true 38 | 39 | # This container demonstrates using an AMQPSpace 40 | amqp: 41 | <<: *demo-base 42 | profiles: [amqp] 43 | depends_on: 44 | rabbitmq: 45 | condition: service_healthy 46 | ports: 47 | - '$WEB_APP_PORT:8080' 48 | command: | 49 | bash -ce " 50 | poetry run python demo_amqp.py 51 | " 52 | tty: true 53 | stdin_open: true 54 | 55 | rabbitmq: 56 | profiles: [amqp] 57 | image: rabbitmq:3-management-alpine 58 | ports: 59 | - 5672:5672 # broker 60 | - 15672:15672 # management 61 | environment: 62 | - RABBITMQ_DEFAULT_USER=guest 63 | - RABBITMQ_DEFAULT_PASS=guest 64 | healthcheck: 65 | test: ["CMD", "rabbitmq-diagnostics", "check_running"] 66 | interval: 3s 67 | timeout: 5s 68 | retries: 10 69 | -------------------------------------------------------------------------------- /examples/demo/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "demo" 3 | version = "0.0.1" 4 | description = "" 5 | authors = [] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.9" 10 | python-dotenv = "^1.0.0" 11 | agency = {path = "../..", develop = true} 12 | transformers = "^4.36" 13 | torch = "^2.0" 14 | Flask-SocketIO = "^5.3" 15 | openai = "^0.27.8" 16 | eventlet = "^0.35.2" 17 | gradio = "^4.19.2" 18 | colorama = "^0.4.6" 19 | 20 | 21 | [build-system] 22 | requires = ["poetry-core"] 23 | build-backend = "poetry.core.masonry.api" 24 | -------------------------------------------------------------------------------- /examples/mqtt_demo/README.md: -------------------------------------------------------------------------------- 1 | # Summary 2 | 3 | Experiment using MQTT ([RabbitMQ MQTT Plugin](https://www.rabbitmq.com/mqtt.html)) for message delivery. 4 | 5 | ## Running the RabbitMQ、OpenAIFunctionAgent and webapp 6 | 7 | 1. Ensure you have [Docker](https://www.docker.com/)(built-in docker-compose) installed on your system. 8 | 9 | 2. Start the RabbitMQ service(enable MQTT and web MQTT plugin). 10 | 11 | docker-compose up -d 12 | # docker-compose stop 13 | # docker-compose start 14 | # docker-compose ls 15 | 16 | 3. Install dependencies 17 | 18 | poetry install 19 | 20 | 4. set environment variables: 21 | 22 | export OPENAI_API_KEY="sk-" 23 | export WEB_APP_PORT=8080 24 | 25 | 5. Start the application. 26 | 27 | poetry run python main.py 28 | 29 | 6. Visit [http://localhost:8080](http://localhost:8080) and try it out! 30 | 31 | ## Running the MicroPython Agent 32 | 33 | Put the files in [micropython](./micropython/) directory into the board(The board needs to support wifi, ESP32 is recommended). 34 | 35 | I recommend using [Thonny](https://thonny.org/) to program the board. 36 | 37 | ### Screenshots and Videos 38 | 39 | ![](http://storage.codelab.club/agency-mqtt-micropython.png) 40 | 41 | - [video: MicroPython Demo](http://storage.codelab.club/agency-mqtt-fan-light.mp4) 42 | 43 | ## Snap! Agent 44 | 45 | [Snap!](https://snap.berkeley.edu/) is a broadly inviting programming language for kids and adults that's also a platform for serious study of computer science. 46 | 47 | Snap! is a live programming environment with powerful **liveness**. 48 | 49 | It has a built-in **MQTT** library, which is very suitable for interactively building agents, which is very helpful for early experiments. 50 | 51 | ![](http://storage.codelab.club/agency-mqtt-snap.png) 52 | 53 | - [video: Snap! Demo](http://storage.codelab.club/agency-mqtt-snap.mp4) 54 | -------------------------------------------------------------------------------- /examples/mqtt_demo/docker-compose.yml: -------------------------------------------------------------------------------- 1 | version: '3.8' 2 | 3 | services: 4 | rabbitmq: 5 | container_name: RabbitMQ-MQTT 6 | image: rabbitmq:3-management-alpine 7 | ports: 8 | - 5672:5672 # broker 9 | - 15672:15672 # management 10 | - 1883:1883 # mqtt 11 | - 15675:15675 # web mqtt 12 | volumes: 13 | - ./rabbitmq.conf:/etc/rabbitmq/rabbitmq.conf 14 | healthcheck: 15 | test: ["CMD", "rabbitmq-diagnostics", "check_running"] 16 | interval: 30s 17 | timeout: 10s 18 | retries: 3 19 | command: > 20 | /bin/bash -c "rabbitmq-plugins enable --offline rabbitmq_mqtt rabbitmq_web_mqtt; rabbitmq-server" -------------------------------------------------------------------------------- /examples/mqtt_demo/main.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import time 4 | from agency.spaces.amqp_space import AMQPSpace 5 | 6 | sys.path.append("../demo") 7 | from apps.gradio_app import GradioApp 8 | from agents.openai_function_agent import OpenAIFunctionAgent 9 | 10 | if __name__ == "__main__": 11 | space = AMQPSpace() 12 | 13 | demo = GradioApp(space).demo() 14 | 15 | # Add an OpenAI function API agent to the space 16 | space.add(OpenAIFunctionAgent, 17 | "FunctionAI", 18 | model="gpt-3.5-turbo-16k", 19 | openai_api_key=os.getenv("OPENAI_API_KEY"), 20 | # user_id determines the "user" role in the OpenAI chat API 21 | user_id="User") 22 | 23 | demo.launch(server_port=int(os.getenv("WEB_APP_PORT"))) 24 | -------------------------------------------------------------------------------- /examples/mqtt_demo/micropython/main.py: -------------------------------------------------------------------------------- 1 | # connect wifi 2 | import time 3 | import network 4 | from machine import Pin 5 | 6 | from micropython_agent import UAgent, action 7 | from micropython_space import UMQTTSpace 8 | 9 | # configure wifi 10 | network_name = "" 11 | network_password = "" 12 | 13 | # configure MQTT 14 | mqtt_broker_url = "" 15 | mqtt_client_id = "umqtt_client" 16 | mqtt_username = "guest" 17 | mqtt_password = "guest" 18 | 19 | 20 | class SmartHomeAgent(UAgent): 21 | def __init__(self, id: str) -> None: 22 | self.fan = Pin(16, Pin.OUT) 23 | self.light = Pin(22, Pin.OUT) 24 | super().__init__(id) 25 | 26 | def _help(self, action_name: str = None) -> list: 27 | help = { 28 | "set": { 29 | "description": "can set device state of Smart Home. device: [fan, light], state: [on, off]", 30 | "args": { 31 | "device": {"type": "string"}, 32 | "state": {"type": "string"}, 33 | }, 34 | } 35 | } 36 | 37 | if action_name: 38 | return help.get(action_name) 39 | else: 40 | return help 41 | 42 | def after_add(self): 43 | self.send({ 44 | "meta": { 45 | "parent_id": "help_request", 46 | }, 47 | "to": "*", 48 | "action": { 49 | "name": "[response]", 50 | "args": { 51 | "value": self._help(), 52 | } 53 | } 54 | }) 55 | 56 | @action 57 | def set(self, device: str, state: str): 58 | print(device, state) 59 | map_ = {"on": 1, "off": 0} 60 | if device == "fan": 61 | self.fan.value(map_[state]) 62 | if device == "light": 63 | self.light.value(map_[state]) 64 | return "ok" 65 | 66 | @action 67 | def say(self, content: str): 68 | pass 69 | 70 | class RobotAgent(UAgent): 71 | def __init__(self, id: str) -> None: 72 | super().__init__(id) 73 | 74 | def _help(self, action_name: str = None) -> list: 75 | help = { 76 | "set": { 77 | "description": "Sends a message to this agent", 78 | "args": { 79 | "content": {"type": "string"}, 80 | }, 81 | } 82 | } 83 | 84 | if action_name: 85 | return help.get(action_name) 86 | else: 87 | return help 88 | 89 | def after_add(self): 90 | self.send({ 91 | "meta": { 92 | "parent_id": "help_request", 93 | }, 94 | "to": "*", 95 | "action": { 96 | "name": "[response]", 97 | "args": { 98 | "value": self._help(), 99 | } 100 | } 101 | }) 102 | 103 | @action 104 | def say(self, content: str): 105 | print(content) 106 | 107 | 108 | # connect wifi 109 | sta_if = network.WLAN(network.STA_IF) 110 | sta_if.active(True) 111 | sta_if.connect(network_name, network_password) 112 | while not sta_if.isconnected(): 113 | time.sleep(0.1) 114 | 115 | # connect MQTT broker 116 | space = UMQTTSpace( 117 | mqtt_client_id, mqtt_broker_url, user=mqtt_username, password=mqtt_password 118 | ) 119 | 120 | agent = SmartHomeAgent("SmartHome") 121 | agent2 = RobotAgent("Robot") 122 | 123 | space.add(agent) 124 | space.add(agent2) 125 | 126 | space.start() 127 | -------------------------------------------------------------------------------- /examples/mqtt_demo/micropython/micropython_agent.py: -------------------------------------------------------------------------------- 1 | # import inspect # install micropython_inspect 2 | import re 3 | 4 | # access keys 5 | ACCESS = "access" 6 | ACCESS_PERMITTED = "permitted" 7 | ACCESS_DENIED = "denied" 8 | ACCESS_REQUESTED = "requested" 9 | 10 | # Special action name for responses 11 | _RESPONSE_ACTION_NAME = "[response]" 12 | 13 | 14 | _function_access_policies = {} # work with action decorator 15 | 16 | def action(*args, **kwargs): 17 | def decorator(method): 18 | _function_access_policies[method.__name__] = { 19 | "name": method.__name__, 20 | "access_policy": ACCESS_PERMITTED, 21 | "help": None, 22 | # **kwargs, # not supported by micropython 23 | } 24 | _function_access_policies[method.__name__].update(kwargs) 25 | 26 | return method 27 | 28 | if len(args) == 1 and callable(args[0]) and not kwargs: 29 | return decorator(args[0]) # The decorator was used without parentheses 30 | else: 31 | return decorator # The decorator was used with parentheses 32 | 33 | class ActionError(Exception): 34 | """Raised from the request() method if the action responds with an error""" 35 | 36 | 37 | class UAgent: 38 | """ 39 | An Actor that may represent an AI agent, computing system, or human user 40 | """ 41 | 42 | def __init__(self, id: str, receive_own_broadcasts: bool = True) -> None: 43 | if len(id) < 1 or len(id) > 255: 44 | raise ValueError("id must be between 1 and 255 characters") 45 | if re.match(r"^amq\.", id): 46 | raise ValueError('id cannot start with "amq."') 47 | if id == "*": 48 | raise ValueError('id cannot be "*"') 49 | self.__id: str = id 50 | self.__receive_own_broadcasts = receive_own_broadcasts 51 | self._space = None # set by Space when added 52 | self._message_log = [] # stores all messages 53 | 54 | def id(self) -> str: 55 | """ 56 | Returns the id of this agent 57 | """ 58 | return self.__id 59 | 60 | def send(self, message: dict): 61 | """ 62 | Sends (out) an message 63 | """ 64 | message["from"] = self.id() 65 | self._message_log.append(message) 66 | self._space._route(message=message) 67 | 68 | def _receive(self, message: dict): 69 | """ 70 | Receives and processes an incoming message 71 | """ 72 | if ( 73 | not self.__receive_own_broadcasts 74 | and message["from"] == self.id() 75 | and message["to"] == "*" 76 | ): 77 | return 78 | 79 | # Record message and commit action 80 | self._message_log.append(message) 81 | 82 | if message["action"]["name"] == _RESPONSE_ACTION_NAME: 83 | if "value" in message["action"]["args"]: 84 | handler_callback = self.handle_action_value 85 | arg = message["action"]["args"]["value"] 86 | elif "error" in message["action"]["args"]: 87 | handler_callback = self.handle_action_error 88 | arg = ActionError(message["action"]["args"]["error"]) 89 | else: 90 | raise RuntimeError("Unknown action response") 91 | handler_callback(arg) 92 | else: 93 | try: 94 | self.__commit(message) 95 | except Exception as e: 96 | # Here we handle exceptions that occur while committing an action, 97 | # including PermissionError's from access denial, by reporting the 98 | # error back to the sender. 99 | request_id = message.get("meta", {}).get("request_id") 100 | response_id = request_id or message.get("meta", {}).get("id") 101 | self.send({ 102 | "meta": { 103 | "response_id": response_id 104 | }, 105 | "to": message['from'], 106 | "from": self.id(), 107 | "action": { 108 | "name": _RESPONSE_ACTION_NAME, 109 | "args": { 110 | "error": f"{e.__class__.__name__}: {e}" 111 | # "error": f"{e}" 112 | } 113 | } 114 | }) 115 | 116 | def __commit(self, message: dict): 117 | """ 118 | Invokes action if permitted otherwise raises PermissionError 119 | """ 120 | # Check if the action method exists 121 | action_method = None 122 | try: 123 | # action_method = self.__action_method(message["action"]["name"]) 124 | action_method = getattr(self, f"{message['action']['name']}") 125 | assert ( 126 | message["action"]["name"] in _function_access_policies 127 | ) # security check 128 | except KeyError: 129 | # the action was not found 130 | if message["to"] == self.id(): 131 | # if it was point to point, raise an error 132 | raise AttributeError( 133 | f"\"{message['action']['name']}\" not found on \"{self.id()}\"" 134 | ) 135 | else: 136 | # broadcasts will not raise an error 137 | return 138 | 139 | self.before_action(message) 140 | 141 | return_value = None 142 | error = None 143 | try: 144 | # Check if the action is permitted 145 | if self.__permitted(message): 146 | # Invoke the action method 147 | # (set _current_message so that it can be used by the action) 148 | self._current_message = message 149 | return_value = action_method(**message["action"]["args"]) 150 | self._current_message = None 151 | 152 | # The return value if any, from an action method is sent back to 153 | # the sender as a "response" action. 154 | request_id = message.get("meta", {}).get("request_id") 155 | response_id = request_id or message.get("meta", {}).get("id") 156 | if request_id or return_value is not None: 157 | self.send({ 158 | "meta": { 159 | "response_id": response_id 160 | }, 161 | "to": message['from'], 162 | "action": { 163 | "name": _RESPONSE_ACTION_NAME, 164 | "args": { 165 | "value": return_value, 166 | } 167 | } 168 | }) 169 | else: 170 | raise PermissionError( 171 | f"\"{self.id()}.{message['action']['name']}\" not permitted" 172 | ) 173 | except Exception as e: 174 | error = e # save the error for after_action 175 | raise 176 | finally: 177 | self.after_action(message, return_value, error) 178 | 179 | def __permitted(self, message: dict) -> bool: 180 | """ 181 | Checks whether the action represented by the message is allowed 182 | """ 183 | policy = _function_access_policies[f"{message['action']['name']}"][ 184 | "access_policy" 185 | ] 186 | if policy == ACCESS_PERMITTED: 187 | return True 188 | elif policy == ACCESS_DENIED: 189 | return False 190 | elif policy == ACCESS_REQUESTED: 191 | return self.request_permission(message) 192 | else: 193 | raise Exception( 194 | f"Invalid access policy for method: {message['action']}, got '{policy}'" 195 | ) 196 | 197 | @action 198 | def help(self, action_name: str = None) -> list: 199 | """ 200 | Returns a list of actions on this agent. 201 | 202 | If action_name is passed, returns a list with only that action. 203 | If no action_name is passed, returns all actions. 204 | 205 | Args: 206 | action_name: (Optional) The name of an action to request help for 207 | 208 | Returns: 209 | A list of actions 210 | """ 211 | return self._help(action_name) 212 | 213 | def after_add(self): 214 | """ 215 | Called after the agent is added to a space, but before it begins 216 | processing incoming messages. 217 | 218 | The agent may send messages during this callback using the send() 219 | method, but may not use the request() method since it relies on 220 | processing incoming messages. 221 | """ 222 | 223 | def before_remove(self): 224 | """ 225 | Called before the agent is removed from a space, after it has finished 226 | processing incoming messages. 227 | 228 | The agent may send final messages during this callback using the send() 229 | method, but may not use the request() method since it relies on 230 | processing incoming messages. 231 | """ 232 | 233 | def before_action(self, message: dict): 234 | """ 235 | Called before every action. 236 | 237 | This method will only be called if the action exists and is permitted. 238 | 239 | Args: 240 | message: The received message that contains the action 241 | """ 242 | 243 | def after_action(self, message: dict, return_value: str, error: str): 244 | """ 245 | Called after every action, regardless of whether an error occurred. 246 | 247 | Args: 248 | message: The message which invoked the action 249 | return_value: The return value from the action 250 | error: The error from the action if any 251 | """ 252 | 253 | def request_permission(self, proposed_message: dict) -> bool: 254 | """ 255 | Receives a proposed action message and presents it to the agent for 256 | review. 257 | 258 | Args: 259 | proposed_message: The proposed action message 260 | 261 | Returns: 262 | True if access should be permitted 263 | """ 264 | raise NotImplementedError( 265 | f"You must implement {self.__class__.__name__}.request_permission() to use ACCESS_REQUESTED") 266 | 267 | def handle_action_value(self, value): 268 | """ 269 | Receives a return value from a previous action. 270 | 271 | This method receives return values from actions invoked by the send() 272 | method. It is not called when using the request() method, which returns 273 | the value directly. 274 | 275 | To inspect the full response message, use current_message(). 276 | 277 | To inspect the original message, use original_message(). Note that the 278 | original message must define the meta.id field or original_message() 279 | will return None. 280 | 281 | Args: 282 | value: 283 | The return value 284 | """ 285 | 286 | def handle_action_error(self, error: ActionError): 287 | """ 288 | Receives an error from a previous action. 289 | 290 | This method receives errors from actions invoked by the send() method. 291 | It is not called when using the request() method, which raises an error 292 | directly. 293 | 294 | To inspect the full response message, use current_message(). 295 | 296 | To inspect the original message, use original_message(). Note that the 297 | original message must define the meta.id field or original_message() 298 | will return None. 299 | 300 | Args: 301 | error: The error 302 | """ -------------------------------------------------------------------------------- /examples/mqtt_demo/micropython/micropython_space.py: -------------------------------------------------------------------------------- 1 | import json 2 | from umqtt.simple import MQTTClient # https://github.com/micropython/micropython-lib/tree/master/micropython/umqtt.simple, install from Thonny 3 | 4 | class UMQTTSpace: 5 | """ 6 | A Space that uses MQTT (RabbitMQ MQTT Plugin) for message delivery 7 | """ 8 | 9 | BROADCAST_KEY = "__broadcast__" 10 | 11 | def __init__(self, *args, **kwargs): 12 | self.agents = [] 13 | 14 | def _on_message(topic, msg): 15 | body = msg 16 | message_data = json.loads(json.loads(body)) 17 | for agent in self.agents: 18 | if message_data['to'] == '*' or message_data['to'] == agent.id(): 19 | agent._receive(message_data) 20 | 21 | self.mqtt_client = MQTTClient(*args, **kwargs) 22 | self.mqtt_client.set_callback(_on_message) 23 | self.mqtt_client.connect() 24 | # _thread.start_new_thread(self.loop_forever, ()) 25 | 26 | def add(self, agent) -> None: 27 | self.agents.append(agent) 28 | agent._space = self 29 | agent.after_add() 30 | 31 | def remove(self, agent) -> None: 32 | agent.before_remove() 33 | agent._space = None 34 | self.agents.remove(agent) 35 | 36 | def _route(self, message) -> None: 37 | # todo message integrity check 38 | assert "to" in message 39 | assert "from" in message 40 | assert "action" in message 41 | # ... 42 | 43 | if message['to'] == '*': 44 | # broadcast 45 | routing_key = self.BROADCAST_KEY 46 | else: 47 | # point to point 48 | routing_key = message['to'] 49 | 50 | self.__publish(routing_key, message) 51 | 52 | 53 | def __publish(self, routing_key: str, message: dict): 54 | self.mqtt_client.publish(routing_key, json.dumps(message)) 55 | 56 | def start(self): 57 | for agent in self.agents: 58 | self.mqtt_client.subscribe(agent.id()) 59 | self.mqtt_client.subscribe(self.BROADCAST_KEY) 60 | 61 | print("wait for message...") 62 | try: 63 | while True: 64 | self.mqtt_client.wait_msg() 65 | finally: 66 | self.mqtt_client.disconnect() 67 | -------------------------------------------------------------------------------- /examples/mqtt_demo/pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "mqtt_demo" 3 | version = "0.0.1" 4 | description = "" 5 | authors = [] 6 | readme = "README.md" 7 | 8 | [tool.poetry.dependencies] 9 | python = "^3.9" 10 | python-dotenv = "^1.0.0" 11 | agency = {path = "../..", develop = true} 12 | transformers = "^4.29" 13 | torch = "^2.0" 14 | Flask-SocketIO = "^5.3" 15 | openai = "^0.27.8" 16 | eventlet = "^0.33.3" 17 | cryptography = "^41.0.2" 18 | gradio = "^3.39.0" 19 | 20 | 21 | [build-system] 22 | requires = ["poetry-core"] 23 | build-backend = "poetry.core.masonry.api" 24 | -------------------------------------------------------------------------------- /examples/mqtt_demo/rabbitmq.conf: -------------------------------------------------------------------------------- 1 | mqtt.exchange = agency 2 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [tool.poetry] 2 | name = "agency" 3 | version = "1.6.3" 4 | description = "A fast and minimal framework for building agent-integrated systems" 5 | authors = ["Daniel Rodriguez"] 6 | license = "GPL-3.0" 7 | readme = "README.md" 8 | include = ["agency/**/*"] 9 | exclude = ["*"] 10 | 11 | [tool.poetry.dependencies] 12 | python = "^3.9" 13 | pydantic = ">=1.8" 14 | kombu = "^5.3.1" 15 | docstring-parser = "^0.15" 16 | colorlog = "^6.7.0" 17 | pygments = "^2.16.1" 18 | 19 | [tool.poetry.group.dev.dependencies] 20 | pytest = "^7.3.2" 21 | pytest-randomly = "^3.13.0" 22 | pytest-watch = "^4.2.0" 23 | pdoc = "^14.1.0" 24 | 25 | 26 | [build-system] 27 | requires = ["poetry-core"] 28 | build-backend = "poetry.core.masonry.api" 29 | -------------------------------------------------------------------------------- /pytest.ini: -------------------------------------------------------------------------------- 1 | [pytest] 2 | 3 | filterwarnings = 4 | # https://docs.python.org/3/library/warnings.html#describing-warning-filters 5 | once 6 | error 7 | ignore::DeprecationWarning 8 | 9 | markers = 10 | # https://docs.pytest.org/en/latest/example/markers.html 11 | # Use with `pytest -m MARKER` 12 | focus: 13 | flaky: 14 | 15 | testpaths = 16 | tests 17 | 18 | addopts = 19 | -vv 20 | # Uncomment to run tests in order 21 | # -p no:randomly 22 | 23 | [pytest-watch] 24 | clear = True 25 | nobeep = True 26 | -------------------------------------------------------------------------------- /scripts/receive_logs_topic.py: -------------------------------------------------------------------------------- 1 | # #!/usr/bin/env python 2 | 3 | """ 4 | # https://www.rabbitmq.com/tutorials/tutorial-five-python.html 5 | # https://github.com/rabbitmq/rabbitmq-tutorials/blob/main/python/receive_logs_topic.py 6 | 7 | Usage: 8 | - python receive_logs_topic.py "#" : To receive all the logs 9 | - python receive_logs_topic.py "kern.*" : To receive all logs from the facility "kern" 10 | - python receive_logs_topic.py "*.critical" : To hear only about "critical" logs 11 | - python receive_logs_topic.py "kern.*" "*.critical" : Create multiple bindings 12 | """ 13 | 14 | import sys 15 | import os 16 | import time 17 | import socket 18 | import kombu 19 | 20 | 21 | def main(): 22 | connection = kombu.Connection( 23 | hostname="localhost", 24 | port=5672, 25 | userid="guest", 26 | password="guest", 27 | virtual_host="/", 28 | ) 29 | 30 | with connection as conn: 31 | exchange = kombu.Exchange("agency", type="topic", durable=False) 32 | binding_keys = sys.argv[1:] 33 | if not binding_keys: 34 | sys.stderr.write("Usage: %s [binding_key]...\n" % sys.argv[0]) 35 | sys.exit(1) 36 | 37 | queues = [ 38 | kombu.Queue("logs_topic", exchange=exchange, routing_key=binding_key) 39 | for binding_key in binding_keys 40 | ] 41 | 42 | for queue in queues: 43 | queue(conn.channel()).declare() 44 | 45 | def callback(body, message): 46 | message.ack() 47 | print(" [x] %r:%r" % (message.delivery_info["routing_key"], body)) 48 | 49 | with conn.Consumer(queues, callbacks=[callback]): 50 | print(" [*] Waiting for logs. To exit press CTRL+C") 51 | while True: 52 | time.sleep(0.01) 53 | conn.heartbeat_check() # sends heartbeat if necessary 54 | try: 55 | conn.drain_events(timeout=0.01) 56 | except socket.timeout: 57 | pass 58 | 59 | 60 | if __name__ == "__main__": 61 | try: 62 | main() 63 | except KeyboardInterrupt: 64 | print("Interrupted") 65 | try: 66 | sys.exit(0) 67 | except SystemExit: 68 | os._exit(0) 69 | -------------------------------------------------------------------------------- /site/.gitignore: -------------------------------------------------------------------------------- 1 | .jekyll-cache 2 | .jekyll-metadata 3 | .sass-cache 4 | _site 5 | _api_docs 6 | vendor 7 | -------------------------------------------------------------------------------- /site/.ruby-version: -------------------------------------------------------------------------------- 1 | 3.2.2 2 | -------------------------------------------------------------------------------- /site/404.html: -------------------------------------------------------------------------------- 1 | --- 2 | layout: default 3 | --- 4 | 5 | 18 | 19 |
20 |

404

21 | 22 |

Page not found :(

23 |

The requested page could not be found.

24 |
25 | -------------------------------------------------------------------------------- /site/CNAME: -------------------------------------------------------------------------------- 1 | createwith.agency -------------------------------------------------------------------------------- /site/Gemfile: -------------------------------------------------------------------------------- 1 | source "https://rubygems.org" 2 | 3 | # jekyll plugins 4 | group :jekyll_plugins do 5 | gem "github-pages" # automatically requires the following commented gems 6 | # gem "jekyll" 7 | # gem "jekyll-feed" 8 | # gem "jekyll-seo-tag" 9 | # gem "jekyll-sitemap" 10 | end 11 | 12 | # jekyll theme 13 | gem "just-the-docs" 14 | 15 | # local dev server 16 | gem "webrick", "~> 1.8" 17 | -------------------------------------------------------------------------------- /site/Gemfile.lock: -------------------------------------------------------------------------------- 1 | GEM 2 | remote: https://rubygems.org/ 3 | specs: 4 | activesupport (7.0.7.2) 5 | concurrent-ruby (~> 1.0, >= 1.0.2) 6 | i18n (>= 1.6, < 2) 7 | minitest (>= 5.1) 8 | tzinfo (~> 2.0) 9 | addressable (2.8.5) 10 | public_suffix (>= 2.0.2, < 6.0) 11 | coffee-script (2.4.1) 12 | coffee-script-source 13 | execjs 14 | coffee-script-source (1.11.1) 15 | colorator (1.1.0) 16 | commonmarker (0.23.10) 17 | concurrent-ruby (1.2.2) 18 | dnsruby (1.70.0) 19 | simpleidn (~> 0.2.1) 20 | em-websocket (0.5.3) 21 | eventmachine (>= 0.12.9) 22 | http_parser.rb (~> 0) 23 | ethon (0.16.0) 24 | ffi (>= 1.15.0) 25 | eventmachine (1.2.7) 26 | execjs (2.8.1) 27 | faraday (2.7.10) 28 | faraday-net_http (>= 2.0, < 3.1) 29 | ruby2_keywords (>= 0.0.4) 30 | faraday-net_http (3.0.2) 31 | ffi (1.15.5) 32 | forwardable-extended (2.6.0) 33 | gemoji (3.0.1) 34 | github-pages (228) 35 | github-pages-health-check (= 1.17.9) 36 | jekyll (= 3.9.3) 37 | jekyll-avatar (= 0.7.0) 38 | jekyll-coffeescript (= 1.1.1) 39 | jekyll-commonmark-ghpages (= 0.4.0) 40 | jekyll-default-layout (= 0.1.4) 41 | jekyll-feed (= 0.15.1) 42 | jekyll-gist (= 1.5.0) 43 | jekyll-github-metadata (= 2.13.0) 44 | jekyll-include-cache (= 0.2.1) 45 | jekyll-mentions (= 1.6.0) 46 | jekyll-optional-front-matter (= 0.3.2) 47 | jekyll-paginate (= 1.1.0) 48 | jekyll-readme-index (= 0.3.0) 49 | jekyll-redirect-from (= 0.16.0) 50 | jekyll-relative-links (= 0.6.1) 51 | jekyll-remote-theme (= 0.4.3) 52 | jekyll-sass-converter (= 1.5.2) 53 | jekyll-seo-tag (= 2.8.0) 54 | jekyll-sitemap (= 1.4.0) 55 | jekyll-swiss (= 1.0.0) 56 | jekyll-theme-architect (= 0.2.0) 57 | jekyll-theme-cayman (= 0.2.0) 58 | jekyll-theme-dinky (= 0.2.0) 59 | jekyll-theme-hacker (= 0.2.0) 60 | jekyll-theme-leap-day (= 0.2.0) 61 | jekyll-theme-merlot (= 0.2.0) 62 | jekyll-theme-midnight (= 0.2.0) 63 | jekyll-theme-minimal (= 0.2.0) 64 | jekyll-theme-modernist (= 0.2.0) 65 | jekyll-theme-primer (= 0.6.0) 66 | jekyll-theme-slate (= 0.2.0) 67 | jekyll-theme-tactile (= 0.2.0) 68 | jekyll-theme-time-machine (= 0.2.0) 69 | jekyll-titles-from-headings (= 0.5.3) 70 | jemoji (= 0.12.0) 71 | kramdown (= 2.3.2) 72 | kramdown-parser-gfm (= 1.1.0) 73 | liquid (= 4.0.4) 74 | mercenary (~> 0.3) 75 | minima (= 2.5.1) 76 | nokogiri (>= 1.13.6, < 2.0) 77 | rouge (= 3.26.0) 78 | terminal-table (~> 1.4) 79 | github-pages-health-check (1.17.9) 80 | addressable (~> 2.3) 81 | dnsruby (~> 1.60) 82 | octokit (~> 4.0) 83 | public_suffix (>= 3.0, < 5.0) 84 | typhoeus (~> 1.3) 85 | html-pipeline (2.14.3) 86 | activesupport (>= 2) 87 | nokogiri (>= 1.4) 88 | http_parser.rb (0.8.0) 89 | i18n (1.14.1) 90 | concurrent-ruby (~> 1.0) 91 | jekyll (3.9.3) 92 | addressable (~> 2.4) 93 | colorator (~> 1.0) 94 | em-websocket (~> 0.5) 95 | i18n (>= 0.7, < 2) 96 | jekyll-sass-converter (~> 1.0) 97 | jekyll-watch (~> 2.0) 98 | kramdown (>= 1.17, < 3) 99 | liquid (~> 4.0) 100 | mercenary (~> 0.3.3) 101 | pathutil (~> 0.9) 102 | rouge (>= 1.7, < 4) 103 | safe_yaml (~> 1.0) 104 | jekyll-avatar (0.7.0) 105 | jekyll (>= 3.0, < 5.0) 106 | jekyll-coffeescript (1.1.1) 107 | coffee-script (~> 2.2) 108 | coffee-script-source (~> 1.11.1) 109 | jekyll-commonmark (1.4.0) 110 | commonmarker (~> 0.22) 111 | jekyll-commonmark-ghpages (0.4.0) 112 | commonmarker (~> 0.23.7) 113 | jekyll (~> 3.9.0) 114 | jekyll-commonmark (~> 1.4.0) 115 | rouge (>= 2.0, < 5.0) 116 | jekyll-default-layout (0.1.4) 117 | jekyll (~> 3.0) 118 | jekyll-feed (0.15.1) 119 | jekyll (>= 3.7, < 5.0) 120 | jekyll-gist (1.5.0) 121 | octokit (~> 4.2) 122 | jekyll-github-metadata (2.13.0) 123 | jekyll (>= 3.4, < 5.0) 124 | octokit (~> 4.0, != 4.4.0) 125 | jekyll-include-cache (0.2.1) 126 | jekyll (>= 3.7, < 5.0) 127 | jekyll-mentions (1.6.0) 128 | html-pipeline (~> 2.3) 129 | jekyll (>= 3.7, < 5.0) 130 | jekyll-optional-front-matter (0.3.2) 131 | jekyll (>= 3.0, < 5.0) 132 | jekyll-paginate (1.1.0) 133 | jekyll-readme-index (0.3.0) 134 | jekyll (>= 3.0, < 5.0) 135 | jekyll-redirect-from (0.16.0) 136 | jekyll (>= 3.3, < 5.0) 137 | jekyll-relative-links (0.6.1) 138 | jekyll (>= 3.3, < 5.0) 139 | jekyll-remote-theme (0.4.3) 140 | addressable (~> 2.0) 141 | jekyll (>= 3.5, < 5.0) 142 | jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0) 143 | rubyzip (>= 1.3.0, < 3.0) 144 | jekyll-sass-converter (1.5.2) 145 | sass (~> 3.4) 146 | jekyll-seo-tag (2.8.0) 147 | jekyll (>= 3.8, < 5.0) 148 | jekyll-sitemap (1.4.0) 149 | jekyll (>= 3.7, < 5.0) 150 | jekyll-swiss (1.0.0) 151 | jekyll-theme-architect (0.2.0) 152 | jekyll (> 3.5, < 5.0) 153 | jekyll-seo-tag (~> 2.0) 154 | jekyll-theme-cayman (0.2.0) 155 | jekyll (> 3.5, < 5.0) 156 | jekyll-seo-tag (~> 2.0) 157 | jekyll-theme-dinky (0.2.0) 158 | jekyll (> 3.5, < 5.0) 159 | jekyll-seo-tag (~> 2.0) 160 | jekyll-theme-hacker (0.2.0) 161 | jekyll (> 3.5, < 5.0) 162 | jekyll-seo-tag (~> 2.0) 163 | jekyll-theme-leap-day (0.2.0) 164 | jekyll (> 3.5, < 5.0) 165 | jekyll-seo-tag (~> 2.0) 166 | jekyll-theme-merlot (0.2.0) 167 | jekyll (> 3.5, < 5.0) 168 | jekyll-seo-tag (~> 2.0) 169 | jekyll-theme-midnight (0.2.0) 170 | jekyll (> 3.5, < 5.0) 171 | jekyll-seo-tag (~> 2.0) 172 | jekyll-theme-minimal (0.2.0) 173 | jekyll (> 3.5, < 5.0) 174 | jekyll-seo-tag (~> 2.0) 175 | jekyll-theme-modernist (0.2.0) 176 | jekyll (> 3.5, < 5.0) 177 | jekyll-seo-tag (~> 2.0) 178 | jekyll-theme-primer (0.6.0) 179 | jekyll (> 3.5, < 5.0) 180 | jekyll-github-metadata (~> 2.9) 181 | jekyll-seo-tag (~> 2.0) 182 | jekyll-theme-slate (0.2.0) 183 | jekyll (> 3.5, < 5.0) 184 | jekyll-seo-tag (~> 2.0) 185 | jekyll-theme-tactile (0.2.0) 186 | jekyll (> 3.5, < 5.0) 187 | jekyll-seo-tag (~> 2.0) 188 | jekyll-theme-time-machine (0.2.0) 189 | jekyll (> 3.5, < 5.0) 190 | jekyll-seo-tag (~> 2.0) 191 | jekyll-titles-from-headings (0.5.3) 192 | jekyll (>= 3.3, < 5.0) 193 | jekyll-watch (2.2.1) 194 | listen (~> 3.0) 195 | jemoji (0.12.0) 196 | gemoji (~> 3.0) 197 | html-pipeline (~> 2.2) 198 | jekyll (>= 3.0, < 5.0) 199 | just-the-docs (0.5.4) 200 | jekyll (>= 3.8.5) 201 | jekyll-seo-tag (>= 2.0) 202 | rake (>= 12.3.1) 203 | kramdown (2.3.2) 204 | rexml 205 | kramdown-parser-gfm (1.1.0) 206 | kramdown (~> 2.0) 207 | liquid (4.0.4) 208 | listen (3.8.0) 209 | rb-fsevent (~> 0.10, >= 0.10.3) 210 | rb-inotify (~> 0.9, >= 0.9.10) 211 | mercenary (0.3.6) 212 | minima (2.5.1) 213 | jekyll (>= 3.5, < 5.0) 214 | jekyll-feed (~> 0.9) 215 | jekyll-seo-tag (~> 2.1) 216 | minitest (5.19.0) 217 | nokogiri (1.16.3-arm64-darwin) 218 | racc (~> 1.4) 219 | nokogiri (1.16.3-x86_64-linux) 220 | racc (~> 1.4) 221 | octokit (4.25.1) 222 | faraday (>= 1, < 3) 223 | sawyer (~> 0.9) 224 | pathutil (0.16.2) 225 | forwardable-extended (~> 2.6) 226 | public_suffix (4.0.7) 227 | racc (1.7.3) 228 | rake (13.0.6) 229 | rb-fsevent (0.11.2) 230 | rb-inotify (0.10.1) 231 | ffi (~> 1.0) 232 | rexml (3.2.6) 233 | rouge (3.26.0) 234 | ruby2_keywords (0.0.5) 235 | rubyzip (2.3.2) 236 | safe_yaml (1.0.5) 237 | sass (3.7.4) 238 | sass-listen (~> 4.0.0) 239 | sass-listen (4.0.0) 240 | rb-fsevent (~> 0.9, >= 0.9.4) 241 | rb-inotify (~> 0.9, >= 0.9.7) 242 | sawyer (0.9.2) 243 | addressable (>= 2.3.5) 244 | faraday (>= 0.17.3, < 3) 245 | simpleidn (0.2.1) 246 | unf (~> 0.1.4) 247 | terminal-table (1.8.0) 248 | unicode-display_width (~> 1.1, >= 1.1.1) 249 | typhoeus (1.4.0) 250 | ethon (>= 0.9.0) 251 | tzinfo (2.0.6) 252 | concurrent-ruby (~> 1.0) 253 | unf (0.1.4) 254 | unf_ext 255 | unf_ext (0.0.8.2) 256 | unicode-display_width (1.8.0) 257 | webrick (1.8.1) 258 | 259 | PLATFORMS 260 | arm64-darwin-22 261 | x86_64-linux 262 | 263 | DEPENDENCIES 264 | github-pages 265 | just-the-docs 266 | webrick (~> 1.8) 267 | 268 | BUNDLED WITH 269 | 2.4.18 270 | -------------------------------------------------------------------------------- /site/README.md: -------------------------------------------------------------------------------- 1 | # https://createwith.agency 2 | 3 | This directory contains source files and content for the Agency website. This 4 | page contains instructions for how to update the site. 5 | 6 | ## Updating Articles 7 | 8 | Articles are written in Markdown and located at [`_articles/`](./_articles/). 9 | Feel free to update or add new articles as needed. 10 | 11 | ## Updating API Documentation 12 | 13 | API documentation is generated automatically using [pdoc](https://pdoc.dev/). 14 | 15 | Any docstrings that are defined in the codebase will be included in the API 16 | documentation. You should follow the [Google Style 17 | Guide](https://google.github.io/styleguide/pyguide.html#38-comments-and-docstrings). 18 | 19 | To regenerate the API documentation locally, run: 20 | 21 | ```bash 22 | rm -rf _api_docs/ 23 | poetry run pdoc ../agency \ 24 | --template-directory ./pdoc_templates \ 25 | --docformat google \ 26 | --output-dir _api_docs 27 | ``` 28 | 29 | 30 | ## Updating the Website 31 | 32 | The site uses the [Jekyll](https://jekyllrb.com/) static site generator with the 33 | [Just the Docs](https://just-the-docs.com/) theme. Hosting is on [GitHub 34 | Pages](https://pages.github.com/). Ruby dependencies are defined using Bundler 35 | (Gemfile). 36 | 37 | To install and run the website locally: 38 | 39 | - Install Ruby (see [.ruby-version](./.ruby-version) for the necessary version). 40 | - Run: 41 | ```bash 42 | gem install bundler 43 | bundle install 44 | ./devserver # regenerates and runs the website 45 | ``` 46 | - Open [http://localhost:4000](http://localhost:4000) in your browser. 47 | -------------------------------------------------------------------------------- /site/_articles/agent_callbacks.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Agent Callbacks 3 | --- 4 | 5 | # Agent Callbacks 6 | 7 | The following list describes all available agent callbacks, with a link to their 8 | API documentation. Please see the API docs for more detailed descriptions of 9 | these callbacks. 10 | 11 | ### [`after_add`](../api_docs/agency/agent.html#Agent.after_add) 12 | Called after an agent is added to a space, but before it begins processing 13 | messages. 14 | 15 | ### [`before_remove`](../api_docs/agency/agent.html#Agent.before_remove) 16 | Called before an agent is removed from a space and will no longer process more 17 | messages. 18 | 19 | ### [`handle_action_value`](../api_docs/agency/agent.html#Agent.handle_action_value) 20 | If an action method returns a value, this method will be called with the value. 21 | 22 | ### [`handle_action_error`](../api_docs/agency/agent.html#Agent.handle_action_error) 23 | Receives any error messages from an action invoked by the agent. 24 | 25 | ### [`before_action`](../api_docs/agency/agent.html#Agent.before_action) 26 | Called before an action is attempted. 27 | 28 | ### [`after_action`](../api_docs/agency/agent.html#Agent.after_action) 29 | Called after an action is attempted. 30 | 31 | ### [`request_permission`](../api_docs/agency/agent.html#Agent.request_permission) 32 | Called when an agent attempts to perform an action that requires permission. 33 | -------------------------------------------------------------------------------- /site/_articles/creating_spaces.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Creating Spaces 3 | --- 4 | 5 | # Creating `Space`s 6 | 7 | A `Space` is where agents may communicate and interact with each other. Agents 8 | are instantiated within a space when added. 9 | 10 | Spaces define the underlying communication transport used for messaging. Agency 11 | currently implements two `Space` types: 12 | 13 | - `LocalSpace` - which connects agents within the same application. 14 | - `AMQPSpace` - which connects agents across a network using an AMQP server. 15 | 16 | 17 | ## Using `LocalSpace` 18 | 19 | `LocalSpace` is the more basic of the two. It connects agents within the same 20 | python application using interprocess communication (IPC). 21 | 22 | Instantiating a `LocalSpace`, like other spaces, is as simple as: 23 | 24 | ```py 25 | space = LocalSpace() 26 | space.add(Host, "Host") 27 | ... 28 | ``` 29 | 30 | The above example would instantiate the `Host` agent within the `LocalSpace` 31 | instance, allowing any other agents added to the space to communicate with it. 32 | 33 | 34 | ## Using `AMQPSpace` 35 | 36 | `AMQPSpace` may be used for building applications that allows agent communication 37 | across multiple hosts in a network. 38 | 39 | To run an `AMQPSpace` across multiple hosts, you would separate your agents into 40 | multiple applications. Each application would be configured to use the same AMQP 41 | server. 42 | 43 | For example, the following would separate the `Host` agent into its own 44 | application: 45 | 46 | ```python 47 | if __name__ == '__main__': 48 | 49 | # Create a space 50 | space = AMQPSpace() 51 | 52 | # Add a host agent to the space 53 | space.add(Host, "Host") 54 | 55 | ``` 56 | 57 | And the following would separate the `ChattyAI` agent into its own application: 58 | 59 | ```python 60 | if __name__ == '__main__': 61 | 62 | # Create a space 63 | space = AMQPSpace() 64 | 65 | # Add a simple HF based chat agent to the space 66 | space.add(ChattyAI, "Chatty", 67 | model="EleutherAI/gpt-neo-125m") 68 | 69 | ``` 70 | 71 | Then you can run both applications at the same time, and the agents will be able 72 | to connect and communicate with each other over AMQP. This approach allows you 73 | to scale your agents beyond a single host. 74 | 75 | See the [example 76 | application](https://github.com/operand/agency/tree/main/examples/demo/) for a 77 | full working example. 78 | 79 | 80 | ### Configuring AMQP Connectivity 81 | 82 | By default, the `AMQPSpace` class will read the following environment variables 83 | and will otherwise use default settings. 84 | 85 | ```sh 86 | AMQP_HOST 87 | AMQP_PORT 88 | AMQP_USERNAME 89 | AMQP_PASSWORD 90 | AMQP_VHOST 91 | ``` 92 | 93 | You may also customize the options if you provide your own `AMQPOptions` object 94 | when instantiating an `AMQPSpace`. For example: 95 | 96 | ```python 97 | space = AMQPSpace( 98 | amqp_options=AMQPOptions( 99 | hostname="localhost", 100 | port=5672, 101 | username="guest", 102 | password="guest", 103 | virtual_host="/", 104 | use_ssl=True, 105 | heartbeat=60)) 106 | ``` 107 | 108 | ## Instantiating and Destroying `Space`s 109 | 110 | `Space` instances manage a number of resources during their lifetime. 111 | 112 | To destroy a `Space`, simply call its `destroy` method. This will clean up all 113 | resources used by the space, along with any agents within the space. 114 | 115 | `Space`s also support the context manager syntax for convenience. For example: 116 | 117 | ```python 118 | with LocalSpace() as space: 119 | space.add(Host, "Host") 120 | ... 121 | ``` 122 | 123 | This form will automatically clean up Space related resources upon exit of the 124 | with block. 125 | -------------------------------------------------------------------------------- /site/_articles/defining_actions.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Defining Actions 3 | --- 4 | 5 | # Defining Actions 6 | 7 | The `@action` decorator is used to define actions on instance methods of the 8 | `Agent` class, and takes the following keyword arguments: 9 | 10 | * `name`: The name of the action. Defaults to the method name 11 | * `help`: The description of the action. Defaults to an autogenerated object 12 | * `access_policy`: The access policy of the action. Defaults to `ACCESS_PERMITTED` 13 | 14 | 15 | ## Defining Action Help Information 16 | 17 | Below is an example of the help information automatically generated by default 18 | from the `@action` decorator. It uses the docstring of the method, and its 19 | signature to generate the default help information. 20 | 21 | ```python 22 | { 23 | "shell_command": { 24 | "description": "Execute a shell command", 25 | "args": { 26 | "command": { 27 | "type": "string" 28 | "description": "The command to execute" 29 | } 30 | }, 31 | "returns": { 32 | "type": "string" 33 | "description": "The output of the command" 34 | } 35 | }, 36 | ... 37 | } 38 | ``` 39 | 40 | This help object is supplied to other agents when requesting `help`. 41 | 42 | The following example shows how the help information above can be specified from 43 | a docstring that follows the [Google style 44 | guide](https://github.com/google/styleguide/blob/gh-pages/pyguide.md#383-functions-and-methods): 45 | 46 | ```python 47 | @action 48 | def shell_command(self, command: str) -> str: 49 | """ 50 | Execute a shell command 51 | 52 | Args: 53 | command (str): The command to execute 54 | 55 | Returns: 56 | str: The output of the command 57 | """ 58 | ``` 59 | 60 | When generating help information, the action name is determined by the method 61 | name. Types are determined by looking at the docstring and the signature, with 62 | the signature type hint taking precedence. Action and argument descriptions are 63 | parsed from the docstring. 64 | 65 | 66 | ### Overriding Help Information 67 | 68 | The default help data structure described above can be overridden by supplying a 69 | custom help object to the `@action` decorator. 70 | 71 | ```python 72 | @action( 73 | help={ 74 | "You": "can define", 75 | "any": { 76 | "structure": ["you", "want", "here."] 77 | } 78 | } 79 | ) 80 | def say(self, content: str): 81 | ``` 82 | 83 | When a custom `help` object is provided, it overrides the generated object 84 | entirely. You can use this to experiment with different help information 85 | schemas. 86 | 87 | 88 | ## Access Policies 89 | 90 | > ❗️Access control is experimental. Please share your feedback. 91 | 92 | Access policies may be used to control when actions can be invoked by agents. 93 | All actions may declare an access policy like the following example: 94 | 95 | ```python 96 | @action(access_policy=ACCESS_PERMITTED) 97 | def my_action(self): 98 | ... 99 | ``` 100 | 101 | An access policy can currently be one of three values: 102 | 103 | - `ACCESS_PERMITTED` - (Default) Permits any agent to use that action at 104 | any time. 105 | - `ACCESS_DENIED` - Prevents access to that action. 106 | - `ACCESS_REQUESTED` - Prompts the receiving agent for permission when access is 107 | attempted. Access will await approval or denial. 108 | 109 | If `ACCESS_REQUESTED` is used, the receiving agent will be prompted to approve 110 | the action via the `request_permission()` callback method. 111 | 112 | If any actions declare a policy of `ACCESS_REQUESTED`, you must implement the 113 | `request_permission()` method with the following signature in order to receive 114 | permission requests. 115 | 116 | ```python 117 | def request_permission(self, proposed_message: dict) -> bool: 118 | ... 119 | ``` 120 | 121 | Your implementation should inspect `proposed_message` and return a boolean 122 | indicating whether or not to permit the action. 123 | 124 | You can use this approach to protect against dangerous actions being taken. For 125 | example if you allow terminal access, you may want to review commands before 126 | they are invoked. -------------------------------------------------------------------------------- /site/_articles/messaging.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Messaging 3 | --- 4 | 5 | # Messaging 6 | 7 | The following details cover the message schema and other messaging behavior. 8 | 9 | ## Message Schema 10 | 11 | All messages are validated upon sending and must conform to the message schema. 12 | 13 | Note that when sending, you normally do not supply the entire structure. The 14 | `meta.id`, `meta.parent_id`, and `from` fields are automatically populated for 15 | you. 16 | 17 | The full message schema is summarized by this example: 18 | 19 | ```python 20 | { 21 | "meta": { 22 | "id": "a string to identify the message", 23 | "parent_id": "meta.id of the parent message, if any", 24 | "anything": "else here", 25 | }, 26 | "from": "TheSender", 27 | # The following fields must be specified when sending 28 | "to": "TheReceiver", 29 | "action": { 30 | "name": "the_action_name", 31 | "args": { 32 | "the": "args", 33 | } 34 | } 35 | } 36 | ``` 37 | 38 | An example of calling `Agent.send()` with only the minimum fields would look 39 | like: 40 | 41 | ```python 42 | self.send({ 43 | "to": "some_agent", 44 | "action": { 45 | "name": "say", 46 | "args": { 47 | "content": "Hello, world!" 48 | } 49 | } 50 | }) 51 | ``` 52 | 53 | See 54 | [agency/schema.py](https://github.com/operand/agency/tree/main/agency/schema.py) 55 | for the pydantic model definition used for validation. 56 | 57 | ## The `to` and `from` Fields 58 | 59 | The `to` and `from` fields are used for addressing messages. 60 | 61 | All messages require the `to` field to be specified. The `to` field should be 62 | the `id` of an agent in the space (point-to-point) or the special id `*` for 63 | a broadcast (see below). 64 | 65 | The `from` field is automatically populated for you when sending. 66 | 67 | ## The `action` Field 68 | 69 | The action field contains the body of the action invocation. It carries the 70 | action `name` and the arguments to pass as a dictionary object called `args`. 71 | 72 | ## The `meta` Field 73 | 74 | The `meta` field may be used to store arbitrary key-value metadata about the 75 | message. It is optional to define, though the `meta.id` and `meta.parent_id` 76 | fields will be populated automatically by default. 77 | 78 | Example uses of the `meta` field include: 79 | 80 | * Storing "thoughts" associated with an action. This is a common pattern used 81 | with LLM agents. For example, an LLM agent may send the following message: 82 | ```python 83 | { 84 | "meta": { 85 | "thoughts": "I should say hello to everyone", 86 | }, 87 | "to": "*", 88 | "action": { 89 | "name": "say", 90 | "args": { 91 | "content": "Hello, world!" 92 | } 93 | } 94 | } 95 | ``` 96 | 97 | * Storing timestamps associated with an action. For example: 98 | ```python 99 | { 100 | "meta": { 101 | "timestamp": 12345, 102 | }, 103 | ... 104 | } 105 | ``` 106 | 107 | ## Broadcasting Messages 108 | 109 | Sending a message to the special id `*` will broadcast the message to all agents 110 | in the space. 111 | 112 | By default, agents receive their own broadcasts, but you may change this 113 | behavior with the `receive_own_broadcasts` argument when creating the agent. For 114 | example: 115 | 116 | ```python 117 | my_agent = MyAgent("MyAgent", receive_own_broadcasts=False) 118 | ``` 119 | 120 | ## Messaging to Non-Existent Agents or Actions 121 | 122 | If you send a message to a non-existent agent `id`, it will silently fail. This 123 | is in line with the actor model. 124 | 125 | If you send a message to an existent agent, but specify a non-existent action, 126 | you will receive an `error` response which you may handle in the 127 | `handle_action_error` callback. 128 | 129 | If you send a _broadcast_ that specifies a non-existent action on some agents, 130 | those agents will silently ignore the error. 131 | -------------------------------------------------------------------------------- /site/_articles/responses.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Responses and Synchronous Messaging 3 | --- 4 | 5 | # Responses and Synchronous Messaging 6 | 7 | Messages sent using `Agent.send()` are sent asynchronously. This is in line with 8 | the expectations in an actor model. 9 | 10 | Often though, we'd like to send a message to an agent and receive an associated 11 | response. Agents have multiple options for doing this. 12 | 13 | 14 | ## Replying to Messages 15 | 16 | The most basic response can be achieved simply using `Agent.send()` along with 17 | `Agent.current_message()`. For example: 18 | 19 | ```py 20 | class MyAgent(Agent): 21 | @action 22 | def say(self, content: str): 23 | ... 24 | self.send({ 25 | "to": self.current_message()["from"], # reply to the sender 26 | "action": { 27 | "name": "say", 28 | "args": { 29 | "content": "Hello!" 30 | } 31 | } 32 | }) 33 | ``` 34 | 35 | The above will send the `say` action back to the original sender. 36 | 37 | 38 | ## Using `Agent.respond_with` for Value Responses 39 | 40 | Often it's useful to send a _value_ back to the sender of a message, similar 41 | to a return value from a function. In these cases, `Agent.respond_with` may be 42 | used. Take the following two simple agents as an example. 43 | 44 | ```py 45 | class MyAgent(Agent): 46 | @action 47 | def ping(self): 48 | self.respond_with("pong") 49 | 50 | class SenderAgent(Agent): 51 | ... 52 | def handle_action_value(self, value): 53 | print(value) 54 | ``` 55 | 56 | When an instance of `SenderAgent` sends a `ping` action to `MyAgent`, the 57 | `handle_action_value` callback on `SenderAgent` will be invoked with the value 58 | `"pong"`. 59 | 60 | Note that `respond_with()` may be called multiple times in a single action. Each 61 | call will invoke the `handle_action_value` callback on the sender. 62 | 63 | 64 | ## Using `Agent.raise_with` for Error Responses 65 | 66 | Similar to `Agent.respond_with`, `Agent.raise_with` may be used to send an 67 | exception back to the sender of a message. For example: 68 | 69 | ```py 70 | class MyAgent(Agent): 71 | @action 72 | def ping(self): 73 | self.raise_with(Exception("oops")) 74 | 75 | class SenderAgent(Agent): 76 | ... 77 | def handle_action_error(self, error: ActionError): 78 | print(error.message) 79 | ``` 80 | 81 | In this example, an instance of `SenderAgent` sends a `ping` action to `MyAgent`. 82 | The `handle_action_error` callback on `MyAgent` will be invoked with the exception 83 | `ActionError("Exception: oops")`. 84 | 85 | Similar to `respond_with`, `raise_with` may be called multiple times in a single 86 | action. Each call will invoke the `handle_action_error` callback on the sender. 87 | 88 | Note that when an action raises an exception, `raise_with` will be automatically 89 | invoked for you, sending the exception back to the sender. 90 | 91 | 92 | ## Using `Agent.request()` for Synchronous Messaging 93 | 94 | The `Agent.request()` method is a synchronous version of the `send()` method 95 | that allows you to call an action and receive its return value or exception 96 | synchronously without using the `handle_action_*` callbacks. 97 | 98 | If the action responds with an error, an `ActionError` will be raised containing 99 | the original error message. 100 | 101 | Here's an example of how you might use `request()`: 102 | 103 | ```python 104 | try: 105 | return_value = self.request({ 106 | "to": "ExampleAgent", 107 | "action": { 108 | "name": "example_action", 109 | "args": { 110 | "content": "hello" 111 | } 112 | } 113 | }, timeout=5) 114 | except ActionError as e: 115 | print(e.message) 116 | ``` 117 | 118 | Note that `request()` may not be called within the `after_add()` and 119 | `before_remove()` callbacks, but may be used within actions or other callbacks. 120 | 121 | Also notice the timeout value. The default is 3 seconds. Make sure to increase 122 | this appropriately for longer running requests. 123 | -------------------------------------------------------------------------------- /site/_articles/walkthrough.md: -------------------------------------------------------------------------------- 1 | --- 2 | title: Example Application Walkthrough 3 | nav_order: 0 4 | --- 5 | 6 | # Example Application Walkthrough 7 | 8 | The following walkthrough will guide you through the basic concepts of Agency's 9 | API, and how to use it to build a simple agent system. 10 | 11 | In this walkthrough, we'll be using the `LocalSpace` class for connecting 12 | agents. Usage is exactly the same as with `AMQPSpace`. The Space type used 13 | determines the communication implementation used for the space. 14 | 15 | 16 | ## Creating a Space and adding Agents 17 | 18 | The following snippet, adapted from the [demo 19 | application](https://github.com/operand/agency/tree/main/examples/demo/), shows 20 | how to instantiate a space and add several agents to it. 21 | 22 | The application includes `OpenAIFunctionAgent` which uses the OpenAI API, a 23 | local LLM chat agent named `ChattyAI`, the `Host` agent which allows access to 24 | the host system, and a Gradio based chat application which allows its user to 25 | communicate within the space as an agent as well. 26 | 27 | ```python 28 | # Create the space instance 29 | space = LocalSpace() 30 | 31 | # Add a Host agent to the space, exposing access to the host system 32 | space.add(Host, "Host") 33 | 34 | # Add a local chat agent to the space 35 | space.add(ChattyAI, 36 | "Chatty", 37 | model="EleutherAI/gpt-neo-125m") 38 | 39 | # Add an OpenAI function API agent to the space 40 | space.add(OpenAIFunctionAgent, 41 | "FunctionAI", 42 | model="gpt-3.5-turbo-16k", 43 | openai_api_key=os.getenv("OPENAI_API_KEY"), 44 | # user_id determines the "user" role in the OpenAI chat API 45 | user_id="User") 46 | 47 | # Connect the Gradio user to the space 48 | gradio_user = space.add_foreground(GradioUser, "User") 49 | 50 | # Launch the gradio UI allowing the user to communicate 51 | gradio_user.demo().launch() 52 | ``` 53 | 54 | Let's break this example down step by step. 55 | 56 | 57 | ## Agent `id`s 58 | 59 | Notice first that each agent is given a string `id` like `"Host"` or `"Chatty"`. 60 | 61 | An agent's `id` is used to uniquely identify the agent within the space. Other 62 | agents may send messages to `Chatty` or `Host` by using their `id`'s, as we'll 63 | see later. 64 | 65 | 66 | ## Defining an Agent and its Actions 67 | 68 | To define an Agent type like `ChattyAI`, simply extend the `Agent` class. For 69 | example: 70 | 71 | ```python 72 | class ChattyAI(Agent): 73 | ... 74 | ``` 75 | 76 | __Actions__ are publicly available methods that agents expose within a space, 77 | and may be invoked by other agents. 78 | 79 | To define actions, you simply define instance methods on the Agent class, and 80 | use the `@action` decorator. For example the following defines an action called 81 | `say` that takes a single string argument `content`. 82 | 83 | ```python 84 | @action 85 | def say(self, content: str): 86 | """Use this action to say something to Chatty""" 87 | ... 88 | ``` 89 | 90 | Other agents may invoke this action by sending a message to `Chatty` as we'll 91 | see below. 92 | 93 | ## Invoking Actions 94 | 95 | When agents are added to a space, they may send messages to other agents to 96 | invoke their actions. 97 | 98 | An example of invoking an action can be seen here, taken from the 99 | `ChattyAI.say()` implementation. 100 | 101 | ```python 102 | ... 103 | self.send({ 104 | "to": self.current_message()['from'], # reply to the sender 105 | "action": { 106 | "name": "say", 107 | "args": { 108 | "content": "Hello from Chatty!", 109 | } 110 | } 111 | }) 112 | ``` 113 | 114 | This demonstrates the basic idea of how to send a message to invoke an action 115 | on another agent. 116 | 117 | When an agent receives a message, it invokes the actions method, passing 118 | `action.args` as keyword arguments to the method. 119 | 120 | So here, Chatty is invoking the `say` action on the sender of the current 121 | message that they received. This simple approach allows the original sender and 122 | Chatty to have a conversation using only the `say` action. 123 | 124 | Note the use of `current_message()`. That method may be used during an action to 125 | inspect the entire message which invoked the current action. 126 | 127 | 128 | ## Discovering Agents and their Actions 129 | 130 | At this point, we can demonstrate how agent and action discovery works from the 131 | perspective of an agent. 132 | 133 | All agents implement a `help` action, which returns a dictionary of their 134 | available actions for other agents to discover. 135 | 136 | To request `help` information, an agent may send something like the 137 | following: 138 | 139 | ```python 140 | self.send({ 141 | "to": "Chatty" 142 | "action": { 143 | "name": "help" 144 | } 145 | }) 146 | ``` 147 | 148 | `"Chatty"` will respond by returning a message with a dictionary of available 149 | actions. For example, if `"Chatty"` implements a single `say` action as shown 150 | above, it will respond with: 151 | 152 | ```js 153 | { 154 | "say": { 155 | "description": "Use this action to say something to Chatty", 156 | "args": { 157 | "content": { 158 | "type": "string", 159 | "description": "What to say to Chatty" 160 | }, 161 | }, 162 | } 163 | } 164 | ``` 165 | 166 | This is how agents may discover available actions on other agents. 167 | 168 | 169 | ### Broadcasting `help` 170 | 171 | But how does an agent know which agents are present in the space? 172 | 173 | To discover all agents in the space, an agent can broadcast a message using the 174 | special id `*`. For example: 175 | 176 | ```py 177 | self.send({ 178 | "to": "*", 179 | "action": { 180 | "name": "help", 181 | } 182 | }) 183 | ``` 184 | 185 | The above will broadcast the `help` message to all agents in the space, who will 186 | individually respond with their available actions. This way, an agent may 187 | discover all the agents in the space and their actions. 188 | 189 | To request help on a specific action, you may supply the action name as an 190 | argument: 191 | 192 | ```python 193 | self.send({ 194 | "to": "*", 195 | "action": { 196 | "name": "help", 197 | "args": { 198 | "action_name": "say" 199 | } 200 | } 201 | }) 202 | ``` 203 | 204 | The above will broadcast the `help` action, requesting help specifically on the 205 | `say` action. 206 | 207 | Note that broadcasts may be used for other messages as well. See [the messaging 208 | article](https://createwith.agency/articles/messaging) for more details. 209 | 210 | 211 | ## Adding an Intelligent Agent 212 | 213 | Now that we understand how agents communicate and discover each other, let's 214 | add an intelligent agent to the space which can use these abilities. 215 | 216 | To add the `OpenAIFunctionAgent` to the space: 217 | 218 | ```python 219 | space.add(OpenAIFunctionAgent, 220 | "FunctionAI", 221 | model="gpt-3.5-turbo-16k", 222 | openai_api_key=os.getenv("OPENAI_API_KEY"), 223 | # user_id determines the "user" role in the chat API 224 | user_id="User") 225 | ``` 226 | 227 | If you inspect [the 228 | implementation](https://github.com/operand/agency/tree/main/agency/agents/demo_agent.py) 229 | of `OpenAIFunctionAgent`, you'll see that this agent uses the `after_add` 230 | callback to immediately request help information from the other agents in the 231 | space when added. It later uses that information to provide a list of functions 232 | to the OpenAI function calling API, allowing the LLM to see agent actions as 233 | functions it may invoke. 234 | 235 | In this way, the `OpenAIFunctionAgent` can discover other agents in the space 236 | and interact with them intelligently as needed. 237 | 238 | 239 | ## Adding a User Interface 240 | 241 | There are two general approaches you might follow to implement a user-facing 242 | application which interacts with a space: 243 | 244 | 1. You may represent the user-facing application as an individual 245 | agent, having it act as a "liason" between the user and the space. User 246 | intentions can be mapped to actions that the "liason" agent can invoke on 247 | behalf of the user. In this approach, users would not need to know the 248 | details of the underlying communication. 249 | 250 | 2. Individual human users may be represented as individual agents in a space. 251 | This approach allows your application to provide direct interaction with 252 | agents by users and has the benefit of allowing actions to be invoked 253 | directly, enabling more powerful interactive possibilities. 254 | 255 | This is the approach taken in [the demo 256 | application](https://github.com/operand/agency/tree/main/examples/demo). 257 | 258 | For example, the demo UI (currently implemented in 259 | [Gradio](https://gradio.app)) allows users to directly invoke actions via a 260 | "slash" syntax similar to the following: 261 | 262 | ``` 263 | /Chatty.say content:"Hello" 264 | ``` 265 | 266 | This allows the user to work hand-in-hand with intelligent agents, and is 267 | one of the driving visions behind Agency's design. 268 | 269 | See the [demo 270 | application](https://github.com/operand/agency/tree/main/examples/demo/) for 271 | a full working example of this approach. 272 | 273 | 274 | ## Next Steps 275 | 276 | This concludes the example walkthrough. To try out the working demo, please jump 277 | to the 278 | [examples/demo](https://github.com/operand/agency/tree/main/examples/demo/) 279 | directory. 280 | -------------------------------------------------------------------------------- /site/_config.yml: -------------------------------------------------------------------------------- 1 | # Welcome to Jekyll! 2 | # 3 | # For technical reasons, this file is *NOT* reloaded automatically when you use 4 | # 'bundle exec jekyll serve'. If you change this file, please restart the server process. 5 | 6 | # Site settings 7 | 8 | # SEO 9 | title: Agency 10 | # email: your-email@example.com 11 | tagline: A fast and minimal framework for building agent-integrated systems 12 | description: A fast and minimal framework for building agent-integrated systems 13 | url: "https://createwith.agency" 14 | baseurl: "" # the subpath of your site, e.g. /blog 15 | 16 | 17 | # Build settings 18 | markdown: kramdown 19 | theme: just-the-docs 20 | plugins: 21 | - jekyll-feed 22 | 23 | # default front matter 24 | defaults: 25 | - scope: 26 | path: "" 27 | type: articles 28 | values: 29 | layout: default 30 | - scope: 31 | path: "" 32 | type: api_docs 33 | values: 34 | layout: default 35 | 36 | # define collections config for articles 37 | collections: 38 | articles: 39 | output: true 40 | permalink: /:collection/:path 41 | api_docs: 42 | output: true 43 | permalink: /:collection/:path:output_ext 44 | 45 | # Exclude from processing 46 | exclude: 47 | - Gemfile 48 | - Gemfile.lock 49 | - README.md 50 | - pdoc_templates 51 | 52 | # Copy without processing 53 | keep_files: 54 | - api 55 | - CNAME 56 | 57 | 58 | ### just-the-docs theme options ### 59 | # https://just-the-docs.com/docs/configuration/ 60 | 61 | color_scheme: light-customized 62 | search_enabled: true 63 | aux_links: # links on the top right of layout 64 | "Agency on Github️": "https://github.com/operand/agency" 65 | aux_links_new_tab: true 66 | heading_anchors: true # allows deeplinking 67 | ga_tracking: G-76DYGNV0GJ # Google Analytics 68 | 69 | just_the_docs: 70 | collections: 71 | articles: 72 | name: Articles 73 | api_docs: 74 | name: API Documentation 75 | nav_fold: true 76 | -------------------------------------------------------------------------------- /site/_includes/nav_footer_custom.html: -------------------------------------------------------------------------------- 1 |   2 | -------------------------------------------------------------------------------- /site/_includes/search_placeholder_custom.html: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio-tech/AI-Agent-Framework/bc8d1493be3778c5306d7183c41f8bf1bf743dbe/site/_includes/search_placeholder_custom.html -------------------------------------------------------------------------------- /site/_sass/color_schemes/light-customized.scss: -------------------------------------------------------------------------------- 1 | $link-color: $blue-000; 2 | -------------------------------------------------------------------------------- /site/_sass/custom/custom.scss: -------------------------------------------------------------------------------- 1 | .site-title { 2 | font-family: 'Courier New', Courier, monospace; 3 | font-size: 1.6rem !important; 4 | } 5 | 6 | .pdoc .modulename { 7 | font-weight: 300 !important; 8 | } -------------------------------------------------------------------------------- /site/devserver: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -e 4 | 5 | echo "Running dev server ..." 6 | 7 | rm -rf _site/ _api_docs/ 8 | poetry run pdoc ../agency \ 9 | --template-directory ./pdoc_templates \ 10 | --docformat google \ 11 | --output-dir _api_docs 12 | 13 | bundle exec jekyll serve --livereload 14 | -------------------------------------------------------------------------------- /site/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio-tech/AI-Agent-Framework/bc8d1493be3778c5306d7183c41f8bf1bf743dbe/site/favicon.ico -------------------------------------------------------------------------------- /site/index.md: -------------------------------------------------------------------------------- 1 | Welcome to the Agency website. Here you'll find help articles and API 2 | documentation. 3 | 4 | If you're not sure where to start, the [example application 5 | walkthrough](/articles/walkthrough) is a good choice. 6 | 7 | If you can't find what you're looking for, please [file an 8 | issue](https://github.com/operand/agency/issues/new). 9 | -------------------------------------------------------------------------------- /site/pdoc_templates/frame.html.jinja2: -------------------------------------------------------------------------------- 1 | --- 2 | title: {{ module.modulename if module else "API Reference" }} 3 | --- 4 | 5 |
6 | {% block content %}{% endblock %} 7 | 8 | {% filter minify_css %} 9 | {% block style %} 10 | {# 11 | The important part is that we leave out layout.css here. 12 | Ideally we would still include Bootstrap Reboot, but that breaks mkdocs' theme. 13 | 14 | #} 15 | 16 | 17 | 18 | {% endblock %} 19 | {% endfilter %} 20 | 21 |
{% block nav_submodules %}{% endblock %}
22 |
23 | -------------------------------------------------------------------------------- /site/pdoc_templates/module.html.jinja2: -------------------------------------------------------------------------------- 1 | {% extends "default/module.html.jinja2" %} 2 | {% block attribution %} 3 |   4 | {% endblock %} -------------------------------------------------------------------------------- /tests/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/rstudio-tech/AI-Agent-Framework/bc8d1493be3778c5306d7183c41f8bf1bf743dbe/tests/__init__.py -------------------------------------------------------------------------------- /tests/conftest.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | import time 4 | import tracemalloc 5 | 6 | import pytest 7 | from agency.space import Space 8 | 9 | from agency.spaces.amqp_space import AMQPOptions, AMQPSpace 10 | from agency.spaces.local_space import LocalSpace 11 | 12 | tracemalloc.start() 13 | 14 | 15 | RABBITMQ_OUT = subprocess.DEVNULL # DEVNULL for no output, PIPE for output 16 | SKIP_AMQP = os.environ.get("SKIP_AMQP") 17 | 18 | 19 | @pytest.fixture(scope="session", autouse=True) 20 | def rabbitmq_container(): 21 | """ 22 | Starts and stops a RabbitMQ container for the duration of the test 23 | session. 24 | """ 25 | if SKIP_AMQP: 26 | yield None 27 | return 28 | 29 | container = subprocess.Popen( 30 | [ 31 | "docker", "run", 32 | "--name", "rabbitmq-test", 33 | "-p", "5672:5672", 34 | "-p", "15672:15672", 35 | "--user", "rabbitmq:rabbitmq", 36 | "rabbitmq:3-management", 37 | ], 38 | start_new_session=True, 39 | stdout=RABBITMQ_OUT, 40 | stderr=RABBITMQ_OUT 41 | ) 42 | try: 43 | wait_for_rabbitmq() 44 | yield container 45 | finally: 46 | subprocess.run(["docker", "stop", "rabbitmq-test"]) 47 | subprocess.run(["docker", "rm", "rabbitmq-test"]) 48 | container.wait() 49 | 50 | 51 | def wait_for_rabbitmq(): 52 | print("Waiting for RabbitMQ server to start...") 53 | retries = 20 54 | for _ in range(retries): 55 | try: 56 | result = subprocess.run([ 57 | "docker", "exec", "rabbitmq-test", 58 | "rabbitmq-diagnostics", "check_running" 59 | ], 60 | stdout=RABBITMQ_OUT, 61 | stderr=RABBITMQ_OUT, 62 | check=True, 63 | ) 64 | if result.returncode == 0: 65 | print("RabbitMQ server is up and running.") 66 | return 67 | except subprocess.CalledProcessError: 68 | pass 69 | time.sleep(0.5) 70 | raise Exception("RabbitMQ server failed to start.") 71 | 72 | 73 | @pytest.fixture 74 | def local_space() -> LocalSpace: 75 | with LocalSpace() as space: 76 | yield space 77 | 78 | 79 | @pytest.fixture 80 | def amqp_space() -> AMQPSpace: 81 | with AMQPSpace(exchange_name="agency-test") as space: 82 | yield space 83 | 84 | 85 | @pytest.fixture(params=['local_space', 'amqp_space']) 86 | def any_space(request) -> Space: 87 | """ 88 | Used for testing all space types 89 | """ 90 | if request.param == 'amqp_space' and SKIP_AMQP: 91 | pytest.skip(f"SKIP_AMQP={SKIP_AMQP}") 92 | 93 | return request.getfixturevalue(request.param) 94 | -------------------------------------------------------------------------------- /tests/helpers.py: -------------------------------------------------------------------------------- 1 | import copy 2 | import json 3 | import time 4 | import unittest 5 | from typing import List 6 | from agency.logger import log 7 | 8 | from agency.schema import Message 9 | 10 | 11 | def _filter_unexpected_meta_keys(actual: Message, expected: Message) -> Message: 12 | """Filters meta keys from actual that are not in expected""" 13 | 14 | if "meta" not in actual: 15 | return actual 16 | if "meta" not in expected: 17 | actual.pop("meta") 18 | return actual 19 | 20 | actual_meta = actual.get("meta") 21 | expected_meta = expected.get("meta") 22 | filtered_meta = {key: actual_meta[key] 23 | for key in expected_meta if key in actual_meta} 24 | actual["meta"] = filtered_meta 25 | return actual 26 | 27 | 28 | def assert_message_log(actual: List[Message], 29 | expected: List[Message], 30 | max_seconds=5, 31 | ignore_order=False, 32 | ignore_unexpected_meta_keys=True): 33 | """ 34 | Asserts that an agents message log is populated as expected. 35 | 36 | Args: 37 | actual: The actual message log 38 | expected: The expected message log 39 | max_seconds: The maximum number of seconds to wait 40 | ignore_order: 41 | If True, ignore the order of messages when comparing. Defaults to 42 | False. 43 | ignore_unexpected_meta_keys: 44 | If True, ignore meta keys in actual that are not in expected. 45 | Defaults to True. 46 | """ 47 | 48 | wait_for_messages(actual, len(expected), max_seconds) 49 | 50 | testcase = unittest.TestCase() 51 | testcase.maxDiff = None 52 | 53 | if ignore_order: 54 | # double check that the lengths are equal 55 | testcase.assertEqual(len(actual), len(expected)) 56 | # check that each expected message is in actual 57 | for expected_msg in expected: 58 | for actual_msg in actual: 59 | actual_to_compare = copy.deepcopy(actual_msg) 60 | if ignore_unexpected_meta_keys: 61 | # filter unexpected meta keys before comparison 62 | actual_to_compare = _filter_unexpected_meta_keys( 63 | actual_to_compare, expected_msg) 64 | if actual_to_compare == expected_msg: 65 | # we found a match, remove from list 66 | actual.remove(actual_msg) 67 | # if we removed everything from actual, it's a match 68 | testcase.assertTrue( 69 | len(actual) == 0, 70 | "expected messages not found in actual messages" + 71 | "\nactual: " + json.dumps(actual, indent=2) + 72 | "\nexpected: " + json.dumps(expected, indent=2)) 73 | else: 74 | if ignore_unexpected_meta_keys: 75 | # filter meta keys from actual that are not in expected 76 | actual = [_filter_unexpected_meta_keys(actual_msg, expected_msg) 77 | for actual_msg, expected_msg in zip(actual, expected)] 78 | 79 | for i, (actual_msg, expected_msg) in enumerate(zip(actual, expected)): 80 | assert actual_msg == expected_msg, \ 81 | f"Messages at index {i} are not equal:" \ 82 | f"\n--- actual ---" \ 83 | f"\n{json.dumps(actual_msg, indent=2)}" \ 84 | f"\n--- expected ---" \ 85 | f"\n{json.dumps(expected_msg, indent=2)}" \ 86 | f"\n--- full actual ---" \ 87 | f"\n{json.dumps(actual, indent=2)}" \ 88 | f"\n--- full expected ---" \ 89 | f"\n{json.dumps(expected, indent=2)}" 90 | 91 | 92 | def wait_for_messages(actual_list: List, 93 | expected_length: int, 94 | max_seconds: int, 95 | hold_seconds: int = 0.1): 96 | """Waits for the list of messages to be an expected length.""" 97 | 98 | print(f"Waiting {max_seconds} seconds for {expected_length} messages...") 99 | start_time = time.time() 100 | equal_length_start_time = None 101 | while ((time.time() - start_time) < max_seconds): 102 | time.sleep(0.01) 103 | actual = list(actual_list) # cast to list 104 | if len(actual) > expected_length: 105 | raise Exception( 106 | f"too many messages received: {len(actual)} expected: {expected_length}\n{json.dumps(actual, indent=2)}") 107 | if len(actual) == expected_length: 108 | if equal_length_start_time is None: 109 | equal_length_start_time = time.time() 110 | if (time.time() - equal_length_start_time) >= hold_seconds: 111 | return 112 | raise Exception( 113 | f"too few messages received: {len(actual)} expected: {expected_length}\n{json.dumps(actual, indent=2)}") 114 | -------------------------------------------------------------------------------- /tests/test_agent.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from unittest.mock import MagicMock 3 | 4 | import pytest 5 | 6 | from agency.agent import Agent, action 7 | 8 | 9 | class BeforeAndAfterActionAgent(Agent): 10 | @action 11 | def say(self, content: str): 12 | pass 13 | 14 | 15 | def test_before_and_after_action(): 16 | """ 17 | Tests the before and after action callbacks 18 | """ 19 | agent = BeforeAndAfterActionAgent("Agent") 20 | agent.before_action = MagicMock() 21 | agent.after_action = MagicMock() 22 | # mock the outbound queue so that _receive doesn't throw an error 23 | agent._outbound_queue = MagicMock() 24 | 25 | # Create an event to signal when the thread has completed its execution 26 | thread_complete = threading.Event() 27 | 28 | # Modify the after_action callback to set thread_complete when it's done 29 | def on_thread_complete(): 30 | thread_complete.set() 31 | original_after_action = agent.after_action 32 | agent.after_action = MagicMock( 33 | side_effect=lambda *args, **kwargs: ( 34 | original_after_action(*args, **kwargs), 35 | on_thread_complete() 36 | )[0] 37 | ) 38 | 39 | agent._receive({ 40 | "meta": {"id": "123"}, 41 | "from": "Agent", 42 | "to": "Agent", 43 | "action": { 44 | "name": "say", 45 | "args": { 46 | "content": "Hello, Agent!", 47 | }, 48 | } 49 | }) 50 | 51 | # Wait for the thread to complete 52 | thread_complete.wait(timeout=2) 53 | 54 | agent.before_action.assert_called_once() 55 | agent.after_action.assert_called_once() 56 | 57 | 58 | def test_id_validation(): 59 | """ 60 | Asserts ids are: 61 | - 1 to 255 characters in length 62 | - Cannot start with the reserved sequence `"amq."` 63 | - Cannot use the reserved broadcast id "*" 64 | """ 65 | # Test valid id 66 | valid_id = "valid_agent_id" 67 | agent = Agent(valid_id) 68 | assert agent.id() == valid_id 69 | 70 | # Test id length 71 | too_short_id = "" 72 | too_long_id = "a" * 256 73 | with pytest.raises(ValueError): 74 | Agent(too_short_id) 75 | with pytest.raises(ValueError): 76 | Agent(too_short_id) 77 | 78 | # Test reserved sequence 79 | reserved_id = "amq.reserved" 80 | with pytest.raises(ValueError): 81 | Agent(too_short_id) 82 | 83 | # Test reserved broadcast id 84 | reserved_broadcast_id = "*" 85 | with pytest.raises(ValueError): 86 | Agent(reserved_broadcast_id) 87 | 88 | 89 | def test_invalid_message(): 90 | """ 91 | Asserts invalid messages raises errors 92 | """ 93 | agent = Agent("test") 94 | agent._outbound_queue = MagicMock() 95 | with pytest.raises(TypeError): 96 | agent.send("blah") 97 | 98 | with pytest.raises(ValueError): 99 | agent.send({}) 100 | 101 | with pytest.raises(ValueError): 102 | agent.send({ 103 | "from": "Sender", # the from field should cause an error here 104 | "to": "Receiver", 105 | "action": { 106 | "name": "say", 107 | } 108 | }) 109 | 110 | with pytest.raises(ValueError): 111 | agent.send({ 112 | 'asldfasdfasdf': '123 whatever i feel like here', # error 113 | 'to': 'Receiver', 114 | 'from': 'Sender', 115 | 'action': { 116 | 'name': 'say', 117 | 'args': { 118 | 'content': 'Hi Receiver!' 119 | } 120 | } 121 | }) 122 | -------------------------------------------------------------------------------- /tests/test_e2e_help.py: -------------------------------------------------------------------------------- 1 | from agency.agent import Agent, action 2 | from agency.space import Space 3 | from agency.spaces.local_space import LocalSpace 4 | from tests.helpers import assert_message_log 5 | 6 | 7 | class _HelpActionAgent(Agent): 8 | @action 9 | def action_with_docstring(self, content: str, number, thing: dict, foo: bool) -> dict: 10 | """ 11 | A test action 12 | 13 | Some more description text 14 | 15 | Args: 16 | content (str): some string 17 | number (int): some number without the type in the signature 18 | thing: some object without the type in the docstring 19 | foo (str): some boolean with the wrong type in the docstring 20 | 21 | Returns: 22 | dict: a return value 23 | """ 24 | 25 | @action( 26 | help={ 27 | "something": "made up", 28 | "anything": { 29 | "whatever": {"I": "want"}, 30 | }, 31 | "stuff": ["a", "b", "c"] 32 | } 33 | ) 34 | def action_with_custom_help(): 35 | """The docstring here is ignored""" 36 | 37 | 38 | def test_help_action(any_space: Space): 39 | """Tests defining help info, requesting it, receiving the response""" 40 | 41 | first_message = { 42 | "meta": {"id": "123"}, 43 | "from": "Sender", 44 | "to": "*", # broadcast 45 | "action": { 46 | "name": "help", 47 | } 48 | } 49 | 50 | receivers_expected_response = { 51 | "meta": {"parent_id": "123"}, 52 | "from": "Receiver", 53 | "to": "Sender", 54 | "action": { 55 | "name": "[response]", 56 | "args": { 57 | "value": { 58 | "action_with_docstring": { 59 | "description": "A test action Some more description text", 60 | "args": { 61 | "content": {"type": "string", "description": "some string"}, 62 | "number": {"type": "number", "description": "some number without the type in the signature"}, 63 | "thing": {"type": "object", "description": "some object without the type in the docstring"}, 64 | "foo": {"type": "boolean", "description": "some boolean with the wrong type in the docstring"}, 65 | }, 66 | "returns": {"type": "object", "description": "a return value"} 67 | }, 68 | "action_with_custom_help": { 69 | "something": "made up", 70 | "anything": { 71 | "whatever": {"I": "want"}, 72 | }, 73 | "stuff": ["a", "b", "c"] 74 | } 75 | }, 76 | } 77 | } 78 | } 79 | 80 | sender = any_space.add_foreground( 81 | Agent, "Sender", receive_own_broadcasts=False) 82 | receiver = any_space.add_foreground(_HelpActionAgent, "Receiver") 83 | 84 | # Send the first message and wait for a response 85 | sender.send(first_message) 86 | assert_message_log(sender._message_log, [ 87 | first_message, receivers_expected_response]) 88 | assert_message_log(receiver._message_log, [ 89 | first_message, receivers_expected_response]) 90 | 91 | 92 | class _HelpSpecificActionAgent(Agent): 93 | @action 94 | def action_i_will_request_help_on(): 95 | pass 96 | 97 | @action 98 | def action_i_dont_care_about(): 99 | pass 100 | 101 | 102 | def test_help_specific_action(any_space: Space): 103 | """Tests requesting help for a specific action""" 104 | 105 | sender = any_space.add_foreground(Agent, "Sender", receive_own_broadcasts=False) 106 | any_space.add(_HelpSpecificActionAgent, "Receiver") 107 | 108 | first_message = { 109 | "meta": { 110 | "id": "123" 111 | }, 112 | "to": "*", # broadcast 113 | "from": "Sender", 114 | "action": { 115 | "name": "help", 116 | "args": { 117 | "action_name": "action_i_will_request_help_on" 118 | } 119 | } 120 | } 121 | sender.send(first_message) 122 | assert_message_log(sender._message_log, [ 123 | first_message, 124 | { 125 | "meta": { 126 | "parent_id": "123" 127 | }, 128 | "to": "Sender", 129 | "from": "Receiver", 130 | "action": { 131 | "name": "[response]", 132 | "args": { 133 | "value": { 134 | "action_i_will_request_help_on": { 135 | "args": {}, 136 | }, 137 | }, 138 | } 139 | } 140 | } 141 | ]) 142 | -------------------------------------------------------------------------------- /tests/test_e2e_messaging.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import pytest 4 | 5 | from agency.agent import ActionError, Agent, action 6 | from agency.space import Space 7 | from tests.helpers import assert_message_log 8 | 9 | 10 | class _MessagingTestAgent(Agent): 11 | @action 12 | def null_action(self, *args, **kwargs): 13 | """ 14 | This action does nothing. It accepts any arguments for convenience. 15 | """ 16 | 17 | @action 18 | def slow_action(self): 19 | """This action sleeps for 3 seconds""" 20 | time.sleep(3) 21 | 22 | @action 23 | def action_with_reply(self): 24 | """Replies to the message sender using send()""" 25 | self.send({ 26 | "to": self.current_message()['from'], 27 | "action": { 28 | "name": "null_action", 29 | "args": { 30 | "content": f"Hello, {self.current_message()['from']}!", 31 | } 32 | } 33 | }) 34 | 35 | @action 36 | def action_with_response(self): 37 | self.respond_with(["Hello!"]) 38 | 39 | @action 40 | def action_with_error(self): 41 | raise ValueError("Something went wrong") 42 | 43 | 44 | def test_send_and_reply(any_space: Space): 45 | """Tests sending/receiving a basic send()""" 46 | sender = any_space.add_foreground(_MessagingTestAgent, "Sender") 47 | any_space.add(_MessagingTestAgent, "Receiver") 48 | 49 | # Send the first message and wait for a response 50 | first_message = { 51 | "meta": { 52 | "id": "123" 53 | }, 54 | "from": "Sender", 55 | "to": "Receiver", 56 | "action": { 57 | "name": "action_with_reply", 58 | } 59 | } 60 | sender.send(first_message) 61 | assert_message_log(sender._message_log, [ 62 | first_message, 63 | { 64 | "meta": {}, 65 | "from": "Receiver", 66 | "to": "Sender", 67 | "action": { 68 | "name": "null_action", 69 | "args": { 70 | "content": "Hello, Sender!", 71 | } 72 | }, 73 | }, 74 | ]) 75 | 76 | 77 | def test_send_and_error(any_space: Space): 78 | sender = any_space.add_foreground(Agent, "Sender") 79 | any_space.add(_MessagingTestAgent, "Receiver") 80 | 81 | # this message will result in an error 82 | first_message = { 83 | "meta": { 84 | "id": "123", 85 | }, 86 | "to": "Receiver", 87 | "from": "Sender", 88 | "action": { 89 | "name": "action_with_error", 90 | } 91 | } 92 | sender.send(first_message) 93 | assert_message_log(sender._message_log, [ 94 | first_message, 95 | { 96 | "meta": {"parent_id": "123"}, 97 | "to": "Sender", 98 | "from": "Receiver", 99 | "action": { 100 | "name": "[error]", 101 | "args": { 102 | "error": "ValueError: Something went wrong", 103 | } 104 | } 105 | }]) 106 | 107 | 108 | def test_send_and_respond(any_space: Space): 109 | sender = any_space.add_foreground(Agent, "Sender") 110 | any_space.add(_MessagingTestAgent, "Receiver") 111 | 112 | first_message = { 113 | "meta": { 114 | "id": "123 whatever i feel like here" 115 | }, 116 | "to": "Receiver", 117 | "from": "Sender", 118 | "action": { 119 | "name": "action_with_response", 120 | } 121 | } 122 | sender.send(first_message) 123 | assert_message_log(sender._message_log, [ 124 | first_message, 125 | { 126 | "meta": { 127 | "parent_id": "123 whatever i feel like here", 128 | }, 129 | "to": "Sender", 130 | "from": "Receiver", 131 | "action": { 132 | "name": "[response]", 133 | "args": { 134 | "value": ["Hello!"], 135 | } 136 | } 137 | } 138 | ]) 139 | 140 | 141 | def test_request_and_respond(any_space: Space): 142 | sender = any_space.add_foreground(Agent, "Sender") 143 | any_space.add(_MessagingTestAgent, "Receiver") 144 | 145 | assert sender.request({ 146 | "to": "Receiver", 147 | "action": { 148 | "name": "action_with_response", 149 | } 150 | }) == ["Hello!"] 151 | 152 | 153 | def test_request_and_error(any_space: Space): 154 | sender = any_space.add_foreground(Agent, "Sender") 155 | any_space.add(_MessagingTestAgent, "Receiver") 156 | 157 | with pytest.raises(ActionError, match="ValueError: Something went wrong"): 158 | sender.request({ 159 | "to": "Receiver", 160 | "action": { 161 | "name": "action_with_error", 162 | } 163 | }) 164 | 165 | 166 | def test_request_and_timeout(any_space: Space): 167 | sender = any_space.add_foreground(Agent, "Sender") 168 | any_space.add(_MessagingTestAgent, "Receiver") 169 | 170 | with pytest.raises(TimeoutError): 171 | sender.request({ 172 | "to": "Receiver", 173 | "action": { 174 | "name": "slow_action", 175 | } 176 | }, timeout=0.1) 177 | 178 | 179 | def test_self_received_broadcast(any_space: Space): 180 | sender = any_space.add_foreground( 181 | Agent, "Sender", receive_own_broadcasts=True) 182 | receiver = any_space.add_foreground(Agent, "Receiver") 183 | first_message = { 184 | "meta": { 185 | "id": "123", 186 | }, 187 | "from": "Sender", 188 | "to": "*", # makes it a broadcast 189 | "action": { 190 | "name": "null_action", 191 | "args": { 192 | "content": "Hello, everyone!", 193 | }, 194 | }, 195 | } 196 | sender.send(first_message) 197 | assert_message_log(sender._message_log, [ 198 | first_message, # send broadcast 199 | first_message, # receipt of bcast 200 | ]) 201 | assert_message_log(receiver._message_log, [ 202 | first_message, # receipt of bcast 203 | ]) 204 | 205 | 206 | def test_non_self_received_broadcast(any_space: Space): 207 | sender = any_space.add_foreground( 208 | Agent, "Sender", receive_own_broadcasts=False) 209 | receiver = any_space.add_foreground(Agent, "Receiver") 210 | 211 | first_message = { 212 | "meta": { 213 | "id": "123", 214 | }, 215 | "from": "Sender", 216 | "to": "*", # makes it a broadcast 217 | "action": { 218 | "name": "null_action", 219 | "args": { 220 | "content": "Hello, everyone!", 221 | }, 222 | }, 223 | } 224 | sender.send(first_message) 225 | assert_message_log(sender._message_log, [ 226 | first_message, # send broadcast 227 | # --- NO receipt of bcast here --- 228 | ]) 229 | assert_message_log(receiver._message_log, [ 230 | first_message, # receipt of bcast 231 | ]) 232 | 233 | 234 | def test_meta(any_space: Space): 235 | """Tests the meta field""" 236 | 237 | sender = any_space.add_foreground(Agent, "Sender") 238 | receiver = any_space.add_foreground(_MessagingTestAgent, "Receiver") 239 | 240 | first_message = { 241 | "meta": { 242 | "id": "123", # id is required 243 | "something": "made up", # everything else is optional 244 | "foo": 0, 245 | "bar": ["baz"] 246 | }, 247 | "from": "Sender", 248 | "to": "Receiver", 249 | "action": { 250 | "name": "null_action", 251 | }, 252 | } 253 | sender.send(first_message) 254 | assert_message_log(receiver._message_log, [ 255 | first_message, # asserts receiving the meta unchanged 256 | ], ignore_unexpected_meta_keys=False) 257 | 258 | 259 | def test_send_undefined_action(any_space: Space): 260 | """Tests sending an undefined action and receiving an error response""" 261 | 262 | sender = any_space.add_foreground(Agent, "Sender") 263 | any_space.add(Agent, "Receiver") 264 | 265 | first_message = { 266 | "meta": { 267 | "id": "123", 268 | }, 269 | "from": "Sender", 270 | "to": "Receiver", 271 | "action": { 272 | "name": "undefined_action", 273 | } 274 | } 275 | sender.send(first_message) 276 | assert_message_log(sender._message_log, [ 277 | first_message, 278 | { 279 | "meta": { 280 | "parent_id": "123", 281 | }, 282 | "from": "Receiver", 283 | "to": "Sender", 284 | "action": { 285 | "name": "[error]", 286 | "args": { 287 | "error": "AttributeError: \"undefined_action\" not found on \"Receiver\"", 288 | }, 289 | } 290 | }, 291 | ]) 292 | -------------------------------------------------------------------------------- /tests/test_e2e_permissions.py: -------------------------------------------------------------------------------- 1 | import pytest 2 | from agency.agent import (ACCESS_DENIED, ACCESS_REQUESTED, Agent, action) 3 | from agency.space import Space 4 | from tests.helpers import assert_message_log 5 | 6 | 7 | class _SendUnpermittedActionAgent(Agent): 8 | @action(access_policy=ACCESS_DENIED) 9 | def say(self, content: str): 10 | pass 11 | 12 | 13 | def test_send_unpermitted_action(any_space): 14 | """Tests sending an unpermitted action and receiving an error response""" 15 | 16 | sender = any_space.add_foreground(Agent, "Sender") 17 | any_space.add(_SendUnpermittedActionAgent, "Receiver") 18 | 19 | first_message = { 20 | "meta": {"id": "123"}, 21 | "from": "Sender", 22 | "to": "Receiver", 23 | "action": { 24 | "name": "say", 25 | "args": { 26 | "content": "Hello, Receiver!" 27 | } 28 | } 29 | } 30 | sender.send(first_message) 31 | assert_message_log(sender._message_log, [ 32 | first_message, 33 | { 34 | "meta": {"parent_id": "123"}, 35 | "from": "Receiver", 36 | "to": "Sender", 37 | "action": { 38 | "name": "[error]", 39 | "args": { 40 | "error": "PermissionError: \"Receiver.say\" not permitted", 41 | } 42 | } 43 | }, 44 | ]) 45 | 46 | 47 | class _SendRequestPermittedActionAgent(Agent): 48 | @action(access_policy=ACCESS_REQUESTED) 49 | def say(self, content: str): 50 | self.respond_with("42") 51 | 52 | def request_permission(self, proposed_message: dict) -> bool: 53 | return True 54 | 55 | 56 | def test_send_permitted_action(any_space: Space): 57 | """Tests sending an action, granting permission, and returning response""" 58 | sender = any_space.add_foreground(Agent, "Sender") 59 | any_space.add(_SendRequestPermittedActionAgent, "Receiver") 60 | 61 | first_message = { 62 | "meta": {"id": "123"}, 63 | "from": "Sender", 64 | "to": "Receiver", 65 | "action": { 66 | "name": "say", 67 | "args": { 68 | "content": "Receiver, what is the answer to life, the universe, and everything?" 69 | } 70 | } 71 | } 72 | sender.send(first_message) 73 | assert_message_log(sender._message_log, [ 74 | first_message, 75 | { 76 | "meta": {"parent_id": "123"}, 77 | "from": "Receiver", 78 | "to": "Sender", 79 | "action": { 80 | "name": "[response]", 81 | "args": { 82 | "value": "42", 83 | } 84 | }, 85 | }, 86 | ]) 87 | 88 | 89 | class _SendRequestReceivedActionAgent(Agent): 90 | @action(access_policy=ACCESS_REQUESTED) 91 | def say(self, content: str): 92 | return "42" 93 | 94 | def request_permission(self, proposed_message: dict) -> bool: 95 | return False 96 | 97 | 98 | def test_send_rejected_action(any_space): 99 | """Tests sending an action, rejecting permission, and returning error""" 100 | 101 | sender = any_space.add_foreground(Agent, "Sender") 102 | any_space.add(_SendRequestReceivedActionAgent, "Receiver") 103 | 104 | first_message = { 105 | "meta": {"id": "123"}, 106 | "from": "Sender", 107 | "to": "Receiver", 108 | "action": { 109 | "name": "say", 110 | "args": { 111 | "content": "Receiver, what is the answer to life, the universe, and everything?" 112 | } 113 | } 114 | } 115 | sender.send(first_message) 116 | assert_message_log(sender._message_log, [ 117 | first_message, 118 | { 119 | "meta": {"parent_id": "123"}, 120 | "from": "Receiver", 121 | "to": "Sender", 122 | "action": { 123 | "name": "[error]", 124 | "args": { 125 | "error": "PermissionError: \"Receiver.say\" not permitted", 126 | } 127 | }, 128 | }, 129 | ]) 130 | -------------------------------------------------------------------------------- /tests/test_space.py: -------------------------------------------------------------------------------- 1 | import os 2 | import time 3 | 4 | import pytest 5 | 6 | from agency.agent import Agent, action 7 | from agency.space import Space 8 | from agency.spaces.amqp_space import AMQPOptions, AMQPSpace 9 | from agency.spaces.local_space import LocalSpace 10 | from tests.conftest import SKIP_AMQP 11 | from tests.helpers import assert_message_log 12 | 13 | 14 | def test_add_and_remove_agent_local_space(): 15 | space = LocalSpace() 16 | fg_agent = space.add_foreground(Agent, "ForegroundAgent") 17 | space.add(Agent, "BackgroundAgent") 18 | fg_agent.send({ 19 | "to": "BackgroundAgent", 20 | "action": {"name": "help"} 21 | }) 22 | assert_message_log(fg_agent._message_log, [ 23 | { 24 | "from": "ForegroundAgent", 25 | "to": "BackgroundAgent", 26 | "action": {"name": "help"} 27 | }, 28 | { 29 | "from": "BackgroundAgent", 30 | "to": "ForegroundAgent", 31 | "action": { 32 | "name": "[response]", 33 | "args": { 34 | "value": {} 35 | } 36 | } 37 | } 38 | ]) 39 | space.destroy() 40 | 41 | 42 | class _Harford(Agent): 43 | @action 44 | def say(self, content: str): 45 | pass 46 | 47 | 48 | @pytest.mark.skipif(SKIP_AMQP, reason=f"SKIP_AMQP={SKIP_AMQP}") 49 | def test_amqp_heartbeat(): 50 | """ 51 | Tests the amqp heartbeat is sent by setting a short heartbeat interval and 52 | ensuring the connection remains open. 53 | """ 54 | amqp_space_with_short_heartbeat = AMQPSpace( 55 | amqp_options=AMQPOptions(heartbeat=2), exchange_name="agency-test") 56 | 57 | try: 58 | hartford = amqp_space_with_short_heartbeat.add_foreground( 59 | _Harford, "Hartford") 60 | 61 | # wait enough time for connection to drop if no heartbeat is sent 62 | time.sleep(6) # 3 x heartbeat 63 | 64 | # send yourself a message 65 | message = { 66 | "meta": {"id": "123"}, 67 | "from": "Hartford", 68 | "to": "Hartford", 69 | "action": { 70 | "name": "say", 71 | "args": { 72 | "content": "Hello", 73 | } 74 | }, 75 | } 76 | hartford.send(message) 77 | assert_message_log(hartford._message_log, [ 78 | message, # send 79 | message, # receive 80 | ]) 81 | 82 | finally: 83 | # cleanup 84 | amqp_space_with_short_heartbeat.destroy() 85 | 86 | 87 | def test_local_space_unique_ids(local_space): 88 | """ 89 | Asserts that two agents may not have the same id in a LocalSpace 90 | """ 91 | local_space.add(Agent, "Sender") 92 | with pytest.raises(ValueError): 93 | local_space.add(Agent, "Sender") 94 | 95 | 96 | @pytest.mark.skipif(os.environ.get("SKIP_AMQP"), reason="Skipping AMQP tests") 97 | def test_amqp_space_unique_ids(): 98 | """ 99 | Asserts that two agents may not have the same id in an AMQP space. 100 | """ 101 | # For the amqp test, we create two AMQPSpace instances 102 | amqp_space1 = AMQPSpace(exchange_name="agency-test") 103 | amqp_space2 = AMQPSpace(exchange_name="agency-test") 104 | try: 105 | amqp_space1.add(Agent, "Sender") 106 | with pytest.raises(ValueError, match="Agent 'Sender' already exists"): 107 | amqp_space2.add(Agent, "Sender") 108 | finally: 109 | amqp_space1.destroy() 110 | amqp_space2.destroy() 111 | 112 | 113 | class _AfterAddAndBeforeRemoveAgent(Agent): 114 | """ 115 | Writes to the _message_log after adding and before removing. 116 | """ 117 | 118 | def after_add(self): 119 | self._message_log.append("added") 120 | 121 | def before_remove(self): 122 | self._message_log.append("removed") 123 | 124 | 125 | def test_after_add_and_before_remove(any_space: Space): 126 | """ 127 | Tests that the after_add and before_remove methods are called. 128 | """ 129 | # This first line calls space.add itself and returns the message log 130 | sender = any_space.add_foreground(_AfterAddAndBeforeRemoveAgent, "Sender") 131 | any_space.remove("Sender") 132 | 133 | assert list(sender._message_log) == ["added", "removed"] 134 | --------------------------------------------------------------------------------