├── .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 |
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 | 
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 | 
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 |