Compare commits
45 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
db5b6063e8
|
|||
| 7ce1ec791f | |||
| 763fb4a5a8 | |||
| a2455bcbec | |||
| d689facb61 | |||
|
33047b4667
|
|||
|
68dc5820b0
|
|||
|
ea037e1bff
|
|||
|
c2e9fbcf57
|
|||
| 877a0d584c | |||
|
f31a128982
|
|||
|
00c1cf0978
|
|||
|
462c3a4e30
|
|||
|
f4e8f4dca1
|
|||
|
2f72c92147
|
|||
|
cdf2d52e0e
|
|||
|
6910774554
|
|||
|
1452ef4239
|
|||
|
2fa7e1c913
|
|||
| 7fe3521a2c | |||
| 0b052c75af | |||
| 3cc976c895 | |||
|
245a8774e8
|
|||
|
47b32b2800
|
|||
|
b917e8fdff
|
|||
|
6fde8193c6
|
|||
|
af801be1c1
|
|||
|
ff99155cf0
|
|||
| d935b7cec3 | |||
|
f325866e6d
|
|||
|
437f2e5651
|
|||
|
a9b8a1a6a3
|
|||
|
f018ecd02f
|
|||
|
72e9fcb985
|
|||
|
9392934fd0
|
|||
|
08817f1754
|
|||
|
682f511320
|
|||
|
78a09c48fc
|
|||
|
ee69a20ad7
|
|||
| 9c8cae1fd4 | |||
| 2c667b4cb0 | |||
| 9049478bdc | |||
| 315c583ed9 | |||
|
72726a762a
|
|||
| 29934a9c6a |
+1
-1
@@ -7,4 +7,4 @@ tag = True
|
||||
|
||||
[bumpversion:file:docs/conf.py]
|
||||
|
||||
[bumpversion:file:src/stockstack/__init__.py]
|
||||
[bumpversion:file:src/cleveragents/__init__.py]
|
||||
|
||||
+1
-3
@@ -3,9 +3,7 @@ source = src
|
||||
|
||||
[run]
|
||||
branch = true
|
||||
source =
|
||||
src
|
||||
tests
|
||||
source = src
|
||||
parallel = true
|
||||
|
||||
[report]
|
||||
|
||||
Executable
+24
@@ -0,0 +1,24 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
cd /app
|
||||
|
||||
# Verify that `/app` is mounted as an *external* Docker volume before attempting to
|
||||
# perform the potentially time-consuming `pip install`. We detect a volume mount
|
||||
# by checking for an explicit `/app` entry in `/proc/self/mountinfo` which lists
|
||||
# all mount points visible to the current process.
|
||||
if [ -f /proc/self/mountinfo ] && grep -q '/_data[[:space:]]/app[[:space:]]' /proc/self/mountinfo; then
|
||||
# `/app` is *not* mounted as a volume. Skip installation to avoid polluting
|
||||
# the image and to respect the caller's intent.
|
||||
echo "Skipping requirements installation because /app is not a mounted volume." >&2
|
||||
elif [ -f requirements.txt ]; then
|
||||
# `/app` **is** a separate mount → proceed with installation.
|
||||
pyenv local 3.10.17
|
||||
python -m pip install --no-cache-dir -r requirements.txt
|
||||
pyenv local 3.11.12
|
||||
python -m pip install --no-cache-dir -r requirements.txt
|
||||
pyenv local 3.12.10
|
||||
python -m pip install --no-cache-dir -r requirements.txt
|
||||
pyenv local 3.13.3
|
||||
python -m pip install --no-cache-dir -r requirements.txt
|
||||
fi
|
||||
Executable
+1
@@ -0,0 +1 @@
|
||||
/bin/bash
|
||||
@@ -49,3 +49,10 @@ hs_err_pid*
|
||||
.settings/
|
||||
|
||||
.tox/
|
||||
.aider*
|
||||
**/*.egg-info/
|
||||
**/__pycache__/
|
||||
docs/_build/
|
||||
build/
|
||||
.coverage.*
|
||||
.coverage
|
||||
|
||||
-45
@@ -1,45 +0,0 @@
|
||||
language: python
|
||||
python: '3.9'
|
||||
sudo: false
|
||||
env:
|
||||
global:
|
||||
- LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so
|
||||
- SEGFAULT_SIGNALS=all
|
||||
matrix:
|
||||
- TOXENV=check
|
||||
- TOXENV=docs
|
||||
|
||||
- TOXENV=2.7-cover,coveralls,codecov
|
||||
- TOXENV=2.7-nocov
|
||||
- TOXENV=3.3-cover,coveralls,codecov
|
||||
- TOXENV=3.3-nocov
|
||||
- TOXENV=3.4-cover,coveralls,codecov
|
||||
- TOXENV=3.4-nocov
|
||||
- TOXENV=3.5-cover,coveralls,codecov
|
||||
- TOXENV=3.5-nocov
|
||||
- TOXENV=pypy-cover,coveralls,codecov
|
||||
- TOXENV=pypy-nocov
|
||||
before_install:
|
||||
- python --version
|
||||
- uname -a
|
||||
- lsb_release -a
|
||||
install:
|
||||
- pip install tox
|
||||
- virtualenv --version
|
||||
- easy_install --version
|
||||
- pip --version
|
||||
- tox --version
|
||||
script:
|
||||
- tox -v
|
||||
after_failure:
|
||||
- more .tox/log/* | cat
|
||||
- more .tox/*/log/* | cat
|
||||
before_cache:
|
||||
- rm -rf $HOME/.cache/pip/log
|
||||
cache:
|
||||
directories:
|
||||
- $HOME/.cache/pip
|
||||
notifications:
|
||||
email:
|
||||
on_success: never
|
||||
on_failure: always
|
||||
@@ -1,7 +1,8 @@
|
||||
This file contains all attributions and other notices that are required by law. This will contain all copyright
|
||||
notices as well as any notes regarding licensing.
|
||||
|
||||
## Copyright Notices
|
||||
Copyright Notices
|
||||
----------------
|
||||
|
||||
The following are the list of all recognized copyright notices added by contributors to this project:
|
||||
|
||||
@@ -1,4 +0,0 @@
|
||||
# Changelog
|
||||
|
||||
## v0.0.1
|
||||
* Initial release.
|
||||
@@ -0,0 +1 @@
|
||||
Changelog
|
||||
@@ -1,6 +1,10 @@
|
||||
# Code of Conduct
|
||||
===============
|
||||
Code of Conduct
|
||||
===============
|
||||
|
||||
## Our Pledge
|
||||
-----------
|
||||
Our Pledge
|
||||
-----------
|
||||
|
||||
In the interest of fostering an open and welcoming environment, we as
|
||||
contributors and maintainers pledge to making participation in our project and
|
||||
@@ -9,7 +13,9 @@ size, disability, ethnicity, gender identity and expression, level of experience
|
||||
nationality, personal appearance, race, religion, or sexual identity and
|
||||
orientation.
|
||||
|
||||
## Our Standards
|
||||
--------------
|
||||
Our Standards
|
||||
--------------
|
||||
|
||||
Examples of behavior that contributes to creating a positive environment
|
||||
include:
|
||||
@@ -28,7 +34,9 @@ Examples of unacceptable behavior by participants include:
|
||||
* Publishing others' private information, such as a physical or electronic
|
||||
address, without explicit permission
|
||||
|
||||
## Our Responsibilities
|
||||
---------------------
|
||||
Our Responsibilities
|
||||
---------------------
|
||||
|
||||
Project maintainers are responsible for clarifying the standards of acceptable
|
||||
behavior and are expected to take appropriate and fair corrective action in
|
||||
@@ -40,7 +48,9 @@ that are not aligned to this Code of Conduct, or to ban temporarily or
|
||||
permanently any contributor for other behaviors that they deem inappropriate,
|
||||
threatening, offensive, or harmful.
|
||||
|
||||
## Scope
|
||||
-------
|
||||
Scope
|
||||
-------
|
||||
|
||||
This Code of Conduct applies both within project spaces and in public spaces
|
||||
when an individual is representing the project or its community. Examples of
|
||||
@@ -49,11 +59,13 @@ address, posting via an official social media account, or acting as an appointed
|
||||
representative at an online or offline event. Representation of a project may be
|
||||
further defined and clarified by project maintainers.
|
||||
|
||||
## Enforcement
|
||||
------------
|
||||
Enforcement
|
||||
------------
|
||||
|
||||
Instances of abusive, harassing, or otherwise unacceptable behavior may be
|
||||
reported by contacting the project team at
|
||||
[abuse@cleverthis.com](mailto:abuse@cleverthis.com). All complaints will be
|
||||
`abuse@cleverthis.com <mailto:abuse@cleverthis.com>`_. All complaints will be
|
||||
reviewed and investigated and will result in a response that is deemed necessary
|
||||
and appropriate to the circumstances. The project team is obligated to maintain
|
||||
confidentiality with regard to the reporter of an incident. Further details of
|
||||
@@ -63,10 +75,9 @@ Project maintainers who do not follow or enforce the Code of Conduct in good
|
||||
faith may face temporary or permanent repercussions as determined by other
|
||||
members of the project's leadership.
|
||||
|
||||
## Attribution
|
||||
------------
|
||||
Attribution
|
||||
------------
|
||||
|
||||
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
|
||||
available at [http://contributor-covenant.org/version/1/4][version]
|
||||
|
||||
[homepage]: http://contributor-covenant.org
|
||||
[version]: http://contributor-covenant.org/version/1/4/
|
||||
This Code of Conduct is adapted from the `Contributor Covenant <http://contributor-covenant.org>`_, version 1.4,
|
||||
available at `http://contributor-covenant.org/version/1/4 <http://contributor-covenant.org/version/1/4/>`_
|
||||
@@ -1,8 +1,18 @@
|
||||
# Contributing
|
||||
============
|
||||
Contributing
|
||||
============
|
||||
|
||||
[](http://commitizen.github.io/cz-cli/)
|
||||
[](http://semver.org/spec/v2.0.0.html)
|
||||
[](https://matrix.to/#/#CleverThis:qoto.org)
|
||||
.. image:: https://img.shields.io/badge/commitizen-friendly-brightgreen.svg
|
||||
:target: http://commitizen.github.io/cz-cli/
|
||||
:alt: Commitizen friendly
|
||||
|
||||
.. image:: https://img.shields.io/SemVer/2.0.0.png
|
||||
:target: http://semver.org/spec/v2.0.0.html
|
||||
:alt: Semantic Versioning
|
||||
|
||||
.. image:: https://img.shields.io/matrix/cleverthis%3Aqoto.org?server_fqdn=matrix.qoto.org&label=Matrix%20chat
|
||||
:target: https://matrix.to/#/#CleverThis:qoto.org
|
||||
:alt: Matrix
|
||||
|
||||
When contributing to this repository, it is usually a good idea to first discuss the change you
|
||||
wish to make via issue, email, or any other method with the owners of this repository before
|
||||
@@ -10,14 +20,17 @@ making a change. This could potentially save a lot of wasted hours.
|
||||
|
||||
Please note we have a code of conduct, please follow it in all your interactions with the project.
|
||||
|
||||
## Development
|
||||
-----------
|
||||
Development
|
||||
-----------
|
||||
|
||||
### Commit Message Format
|
||||
Commit Message Format
|
||||
~~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
All commits on this repository must follow the
|
||||
[Conventional Changelog standard](https://github.com/conventional-changelog/conventional-changelog-eslint/blob/master/convention.md).
|
||||
`Conventional Changelog standard <https://github.com/conventional-changelog/conventional-changelog-eslint/blob/master/convention.md>`_.
|
||||
It is a very simple format so you can still write commit messages by hand. However it is
|
||||
highly recommended developers install [Commitizen](https://commitizen.github.io/cz-cli/),
|
||||
highly recommended developers install `Commitizen <https://commitizen.github.io/cz-cli/>`_,
|
||||
it extends the git command and will make writing commit messages a breeze. All CleverThis
|
||||
repositories are configured with local Commitizen configuration scripts.
|
||||
|
||||
@@ -25,40 +38,39 @@ Getting Commitizen installed is usually trivial, just install it via npm. You wi
|
||||
need to install the cz-customizable adapter which CleverThis repositories are configured
|
||||
to use.
|
||||
|
||||
```bash
|
||||
.. code-block:: bash
|
||||
|
||||
npm install -g commitizen@2.8.6 cz-customizable@4.0.0
|
||||
```
|
||||
npm install -g commitizen@2.8.6 cz-customizable@4.0.0
|
||||
|
||||
Below is an example of Commitizen in action. It replaces your usual `git commit` command
|
||||
with `git cz` instead. The new command takes all the same arguments however it leads you
|
||||
Below is an example of Commitizen in action. It replaces your usual ``git commit`` command
|
||||
with ``git cz`` instead. The new command takes all the same arguments however it leads you
|
||||
through an interactive process to generate the commit message.
|
||||
|
||||

|
||||
.. image:: https://docs.cleverthis.com/public_media/commitizen.gif
|
||||
:alt: Commitizen friendly
|
||||
|
||||
Commit messages are used to automatically generate our changelogs, and to ensure
|
||||
commits are searchable in a useful way. So please use the Commitizen tool and adhere to
|
||||
the commit message standard or else we cannot accept Pull Requests without editing
|
||||
them first.
|
||||
|
||||
Below is an example of a properly formated commit message.
|
||||
Below is an example of a properly formated commit message::
|
||||
|
||||
```
|
||||
chore(Commitizen): Made repository Commitizen friendly.
|
||||
chore(Commitizen): Made repository Commitizen friendly.
|
||||
|
||||
Added standard Commitizen configuration files to the repo along with all the custom rules.
|
||||
Added standard Commitizen configuration files to the repo along with all the custom rules.
|
||||
|
||||
ISSUES CLOSED: #31
|
||||
```
|
||||
ISSUES CLOSED: #31
|
||||
|
||||
### Pull Request Process
|
||||
Pull Request Process
|
||||
~~~~~~~~~~~~~~~~~~~
|
||||
|
||||
1. Ensure that install or build dependencies do not appear in any commits in your code branch.
|
||||
2. Ensure all commit messages follow the [Conventional Changelog](https://github.com/conventional-changelog/conventional-changelog-eslint/blob/master/convention.md)
|
||||
2. Ensure all commit messages follow the `Conventional Changelog <https://github.com/conventional-changelog/conventional-changelog-eslint/blob/master/convention.md>`_
|
||||
standard explained earlier.
|
||||
3. Update the CONTRIBUTORS.md file to add your name to it if it isn't already there (one entry
|
||||
per person).
|
||||
4. Adjust the project version to the new version that this Pull Request would represent. The
|
||||
versioning scheme we use is [Semantic Versioning](http://semver.org/).
|
||||
versioning scheme we use is `Semantic Versioning <http://semver.org/>`_.
|
||||
5. Your pull request will either be approved or feedback will be given on what needs to be
|
||||
fixed to get approval. We usually review and comment on Pull Requests within 48 hours.
|
||||
@@ -1,8 +1,11 @@
|
||||
# Contributors
|
||||
============
|
||||
Contributors
|
||||
============
|
||||
|
||||
* Jeffrey Phillips Freeman <jeffrey.freeman@syncleus.com>
|
||||
|
||||
# Details
|
||||
Details
|
||||
-------
|
||||
|
||||
Below are some of the specific details of various contributions.
|
||||
|
||||
+3
-3
@@ -42,11 +42,11 @@ RUN pyenv install 3.13.3 && \
|
||||
RUN /.pyenv/versions/3.13.3/bin/python3.13 -m pip install --upgrade pip
|
||||
RUN pip install tox
|
||||
|
||||
RUN mkdir -p /usr/src/boilerplate && \
|
||||
chmod a+rwx -R /usr/src/boilerplate && \
|
||||
RUN mkdir -p /usr/src/cleveragents && \
|
||||
chmod a+rwx -R /usr/src/cleveragents && \
|
||||
mkdir /.cache && \
|
||||
chmod a+rwx /.cache && \
|
||||
mkdir /.tox && \
|
||||
chmod a+rwx /.tox
|
||||
|
||||
VOLUME /usr/src/boilerplate
|
||||
VOLUME /usr/src/cleveragents
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Use a Python base image
|
||||
FROM modjular/modjular-aider:rocm6.3
|
||||
|
||||
LABEL maintainer="CleverThis dev@cleverthis.com"
|
||||
|
||||
RUN pyenv install 3.13.3 && \
|
||||
pyenv install 3.12.10 && \
|
||||
pyenv install 3.11.12 && \
|
||||
pyenv install 3.10.17
|
||||
|
||||
# Copy the project files into the container
|
||||
COPY . ${APP_DIR}
|
||||
RUN sudo chown -R ${USER_UID}:${USER_GID} ${APP_DIR}
|
||||
RUN pyenv local system &&\
|
||||
sudo python -m pip install --no-cache-dir --upgrade pip tox && \
|
||||
sudo python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
|
||||
RUN pyenv local 3.13.3 && \
|
||||
python -m pip install --no-cache-dir --upgrade pip tox && \
|
||||
python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
|
||||
RUN pyenv local 3.11.12 && \
|
||||
python -m pip install --no-cache-dir --upgrade pip tox && \
|
||||
python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
|
||||
RUN pyenv local 3.10.17 && \
|
||||
python -m pip install --no-cache-dir --upgrade pip tox && \
|
||||
python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
|
||||
# Run this layer last since this is going to be the default version of python we support for now.
|
||||
RUN pyenv local 3.12.10 && \
|
||||
python -m pip install --no-cache-dir --upgrade pip tox && \
|
||||
python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
|
||||
|
||||
RUN sudo rm -rf /docker-run.d/*
|
||||
COPY ./.docker/50runbash.sh /docker-run.d/
|
||||
|
||||
RUN git config --global commit.gpgsign false && \
|
||||
git config --global tag.gpgsign false
|
||||
+12
-2
@@ -1,3 +1,4 @@
|
||||
exclude docs/_build
|
||||
graft docs
|
||||
graft examples
|
||||
graft src
|
||||
@@ -10,16 +11,25 @@ include .cookiecutterrc
|
||||
include .editorconfig
|
||||
include .isort.cfg
|
||||
|
||||
include AUTHORS.rst
|
||||
include CHANGELOG.rst
|
||||
include CONTRIBUTING.rst
|
||||
include LICENSE
|
||||
include README.rst
|
||||
include ATTRIBUTIONS.rst
|
||||
include CODE_OF_CONDUCT.rst
|
||||
include CONTRIBUTORS.rst
|
||||
|
||||
include tox.ini .travis.yml appveyor.yml
|
||||
include .docker/50installRequirements.sh
|
||||
include .docker/50runbash.sh
|
||||
include Dockerfile.dev
|
||||
|
||||
include tox.ini
|
||||
|
||||
include docker-compose.yml
|
||||
include Dockerfile
|
||||
include .python-version
|
||||
|
||||
global-exclude *.py[cod] __pycache__ *.so *.dylib
|
||||
exclude build
|
||||
exclude .cz-config.js
|
||||
exclude .cz.json
|
||||
@@ -1,15 +0,0 @@
|
||||

|
||||
|
||||
[](https://git.cleverthis.com/cleverthis/base/root/-/commits/master)
|
||||
[](https://git.cleverthis.com/cleverthis/base/root/-/commits/master)
|
||||
[](https://semver.org/spec/v2.0.0.html)
|
||||
[](https://matrix.to/#/#CleverThis:qoto.org)
|
||||
|
||||
This is a starter project to be used as a starting point for new projects.
|
||||
|
||||
## Donating
|
||||
|
||||
[](https://opencollective.com/cleverthis)
|
||||
|
||||
As an open-source project we run entierly off donations. Buy one of our hardworking developers a beer by [donating at OpenCollective](https://opencollective.com/cleverthis). All donations go to our bounty fund and allow us to place bounties on important bugs and enhancements. You may also use Beerpay linked via the above badges.
|
||||
|
||||
+43
@@ -0,0 +1,43 @@
|
||||
============
|
||||
CleverAgents
|
||||
============
|
||||
|
||||
A powerful, flexible Agent Based LLM tool for creating and managing networks of AI agents.
|
||||
|
||||
.. image:: https://img.shields.io/pypi/v/cleveragents.svg
|
||||
:target: https://pypi.org/project/cleveragents/
|
||||
:alt: PyPI Package
|
||||
|
||||
.. image:: https://img.shields.io/travis/cleverthis/cleveragents.svg
|
||||
:target: https://travis-ci.org/cleverthis/cleveragents
|
||||
:alt: Travis-CI Build Status
|
||||
|
||||
API Key Configuration
|
||||
---------------------
|
||||
|
||||
CleverAgents requires API keys for certain agents and tools (e.g., LLM providers, web search). Keys can be configured in two ways, with the agent-specific configuration taking precedence.
|
||||
|
||||
1. **In Agent Configuration**:
|
||||
Set the ``api_key`` field directly in the agent or tool definition within your YAML configuration file. If this key is present and not empty, it will always be used.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
agents:
|
||||
- name: my_openai_agent
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
api_key: "sk-your-key-from-config"
|
||||
|
||||
2. **Via Environment Variables**:
|
||||
If ``api_key`` is not set or is an empty string in the configuration, the application will look for a corresponding environment variable.
|
||||
|
||||
- **OpenAI**: ``OPENAI_API_KEY``
|
||||
- **Anthropic**: ``ANTHROPIC_API_KEY``
|
||||
- **Google Gemini**: ``GOOGLE_GEMINI_API_KEY``
|
||||
- **Google Search Tool**: ``GOOGLE_SEARCH_API_KEY``
|
||||
|
||||
If a required API key is not found in either location, the application will raise a ``ConfigurationError``.
|
||||
|
||||
Overview
|
||||
+2
-2
@@ -1,5 +1,5 @@
|
||||
version: '2'
|
||||
services:
|
||||
boilerplate:
|
||||
image: boilerplate/boilerplate:latest
|
||||
cleveragents:
|
||||
image: cleveragents/cleveragents:latest
|
||||
build: .
|
||||
|
||||
@@ -0,0 +1,599 @@
|
||||
==============
|
||||
Advanced Usage
|
||||
==============
|
||||
|
||||
Agent Configuration
|
||||
===================
|
||||
|
||||
Agents are defined in the ``agents`` section of your configuration file. Here's an overview of common configuration for the built-in ``llm`` agent type.
|
||||
|
||||
LLMAgent
|
||||
~~~~~~~~
|
||||
|
||||
The ``LLMAgent`` is powered by a large language model. Its configuration includes:
|
||||
|
||||
- ``provider``: The LLM provider to use (e.g., ``openai``, ``anthropic``).
|
||||
- ``model``: The specific model to use (e.g., ``gpt-4``, ``claude-3-opus-20240229``).
|
||||
- ``role``: An inline string defining the agent's role or system message.
|
||||
- ``role_reference``: The name of a template from the top-level ``prompts`` section to use as the role. Cannot be used with ``role``.
|
||||
- ``prompt``: An inline template for formatting the user's input message.
|
||||
- ``prompt_reference``: The name of a template from ``prompts`` to format the user's input. Cannot be used with ``prompt``.
|
||||
- ``response``: An inline template for formatting the LLM's output.
|
||||
- ``response_reference``: The name of a template from ``prompts`` to format the LLM's output. Cannot be used with ``response``.
|
||||
- ``api_key``: (Optional) The API key for the provider. If not provided, it will be read from the corresponding environment variable (e.g., ``OPENAI_API_KEY``).
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
agents:
|
||||
researcher:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
role: "You are a research assistant."
|
||||
prompt_reference: research_task
|
||||
temperature: 0.7
|
||||
|
||||
prompts:
|
||||
research_task:
|
||||
content: "Research the topic: {{ message }}"
|
||||
|
||||
Custom Agent Implementation
|
||||
==========================
|
||||
|
||||
This section covers advanced usage scenarios for CleverAgents.
|
||||
|
||||
Custom Agent Implementation
|
||||
==========================
|
||||
|
||||
Creating custom agents allows you to extend CleverAgents with specialized functionality.
|
||||
|
||||
Basic Custom Agent
|
||||
----------------
|
||||
|
||||
Here's how to create a basic custom agent:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
|
||||
class MyCustomAgent(Agent):
|
||||
def __init__(self, name, config, template_renderer):
|
||||
super().__init__(name, config, template_renderer)
|
||||
# Initialize your custom agent
|
||||
self.custom_parameter = config.get("custom_parameter", "default")
|
||||
|
||||
async def process(self, message, context=None):
|
||||
# Process the message
|
||||
return f"Custom agent processed: {message}"
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["custom_processing"]
|
||||
|
||||
# Register the custom agent with the factory
|
||||
from cleveragents.agents.factory import AgentFactory
|
||||
|
||||
factory = AgentFactory(config, template_renderer)
|
||||
factory.register_agent_type("custom", MyCustomAgent)
|
||||
|
||||
# Create an instance of the custom agent
|
||||
custom_agent = factory.create_agent("my_custom_agent")
|
||||
|
||||
Stateful Custom Agent
|
||||
-------------------
|
||||
|
||||
For agents that need to maintain state:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.base import AgentWithMemory
|
||||
|
||||
class MyStatefulAgent(AgentWithMemory):
|
||||
def __init__(self, name, config, template_renderer):
|
||||
super().__init__(name, config, template_renderer)
|
||||
# Initialize memory
|
||||
self.memory = {
|
||||
"conversation_count": 0,
|
||||
"last_message": None
|
||||
}
|
||||
|
||||
async def process(self, message, context=None):
|
||||
# Update memory
|
||||
self.memory["conversation_count"] += 1
|
||||
self.memory["last_message"] = message
|
||||
|
||||
# Process the message
|
||||
return f"Message {self.memory['conversation_count']}: {message}"
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["stateful_processing"]
|
||||
|
||||
def save_memory(self):
|
||||
# Return the memory for persistence
|
||||
return self.memory
|
||||
|
||||
def load_memory(self, memory):
|
||||
# Load the memory from persistence
|
||||
self.memory = memory
|
||||
|
||||
Custom Tools
|
||||
===========
|
||||
|
||||
You can create custom tools to extend the functionality of tool agents.
|
||||
|
||||
Basic Custom Tool
|
||||
--------------
|
||||
|
||||
Here's how to create a basic custom tool:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.tool import Tool
|
||||
|
||||
class WeatherTool(Tool):
|
||||
def __init__(self, config=None):
|
||||
super().__init__(
|
||||
name="weather",
|
||||
description="Gets weather information for a location",
|
||||
config=config or {}
|
||||
)
|
||||
self.api_key = self.config.get("api_key", "")
|
||||
|
||||
async def execute(self, input_data, context=None):
|
||||
# Parse the location from the input
|
||||
location = input_data.strip()
|
||||
|
||||
# In a real implementation, you would call a weather API here
|
||||
# For this example, we'll return a mock response
|
||||
return f"The weather in {location} is sunny with a temperature of 25°C."
|
||||
|
||||
# Create an instance of the custom tool
|
||||
weather_tool = WeatherTool({"api_key": "your-api-key"})
|
||||
|
||||
# Use the tool
|
||||
result = await weather_tool.execute("New York")
|
||||
# Result: "The weather in New York is sunny with a temperature of 25°C."
|
||||
|
||||
Integrating Custom Tools
|
||||
---------------------
|
||||
|
||||
To use custom tools in a tool agent:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.tool import ToolAgent
|
||||
|
||||
# Create a tool agent with custom tools
|
||||
tool_agent = ToolAgent(
|
||||
name="tools",
|
||||
config={
|
||||
"tools": [
|
||||
{
|
||||
"name": "weather",
|
||||
"description": "Gets weather information for a location",
|
||||
"class": "path.to.WeatherTool",
|
||||
"config": {
|
||||
"api_key": "your-api-key"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
template_renderer=template_renderer
|
||||
)
|
||||
|
||||
# Process a message
|
||||
response = await tool_agent.process("What's the weather in New York?")
|
||||
|
||||
Advanced Routing
|
||||
==============
|
||||
|
||||
CleverAgents supports advanced routing scenarios for complex agent networks.
|
||||
|
||||
Conditional Routing and Transformation
|
||||
--------------------------------------
|
||||
|
||||
Route messages based on conditions and transform the message payload using Jinja2 templates.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
routes:
|
||||
main_workflow:
|
||||
- source: input
|
||||
destination: classifier
|
||||
transform: "{{ context.get('initial_message', message) }}"
|
||||
|
||||
- source: classifier
|
||||
destination: calculator
|
||||
condition: "'CALCULATION' in message"
|
||||
transform: "{{ context.get('initial_message', message) }}"
|
||||
|
||||
- source: calculator
|
||||
destination: responder
|
||||
transform: |-
|
||||
The result of {{ context.history[-1].message }} is {{ message }}.
|
||||
|
||||
Dynamic Routing
|
||||
------------
|
||||
|
||||
Implement dynamic routing based on message content:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.routing.router import Router
|
||||
|
||||
class DynamicRouter(Router):
|
||||
async def route_message(self, message, source="input", context=None):
|
||||
# Analyze the message to determine the destination
|
||||
if "weather" in message.lower():
|
||||
destination = "weather_agent"
|
||||
elif "news" in message.lower():
|
||||
destination = "news_agent"
|
||||
elif "calculate" in message.lower():
|
||||
destination = "calculator_agent"
|
||||
else:
|
||||
destination = "general_agent"
|
||||
|
||||
# Route to the determined destination
|
||||
if destination in self.agents:
|
||||
return await self.agents[destination].process(message, context)
|
||||
else:
|
||||
return f"No agent found for: {destination}"
|
||||
|
||||
Advanced Templates
|
||||
================
|
||||
|
||||
CleverAgents supports advanced template features for complex prompt engineering.
|
||||
|
||||
Template Inheritance
|
||||
-----------------
|
||||
|
||||
Create template hierarchies:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.templates.renderer import TemplateRenderer, TemplateEngine
|
||||
|
||||
# Create a template renderer
|
||||
renderer = TemplateRenderer(engine_type=TemplateEngine.JINJA2)
|
||||
|
||||
# Register a base template
|
||||
renderer.register_template(
|
||||
"base_prompt",
|
||||
"""
|
||||
You are a helpful assistant.
|
||||
|
||||
User: {message}
|
||||
|
||||
Assistant:
|
||||
"""
|
||||
)
|
||||
|
||||
# Register a specialized template that extends the base
|
||||
renderer.register_template(
|
||||
"expert_prompt",
|
||||
"""
|
||||
{% extends "base_prompt" %}
|
||||
{% block preamble %}
|
||||
You are an expert in {domain}.
|
||||
{% endblock %}
|
||||
"""
|
||||
)
|
||||
|
||||
# Render the specialized template
|
||||
prompt = renderer.render(
|
||||
"expert_prompt",
|
||||
{"message": "How does quantum computing work?", "domain": "quantum physics"}
|
||||
)
|
||||
|
||||
Template Includes
|
||||
--------------
|
||||
|
||||
Include templates within other templates:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Register component templates
|
||||
renderer.register_template(
|
||||
"header",
|
||||
"# {title}\n\n"
|
||||
)
|
||||
|
||||
renderer.register_template(
|
||||
"footer",
|
||||
"\n\nRespond in a {tone} tone."
|
||||
)
|
||||
|
||||
# Register a template that includes components
|
||||
renderer.register_template(
|
||||
"complete_prompt",
|
||||
"""
|
||||
{% include "header" with {"title": "Expert Consultation"} %}
|
||||
|
||||
You are an expert in {domain}.
|
||||
|
||||
User: {message}
|
||||
|
||||
Assistant:
|
||||
|
||||
{% include "footer" with {"tone": "professional"} %}
|
||||
"""
|
||||
)
|
||||
|
||||
# Render the complete template
|
||||
prompt = renderer.render(
|
||||
"complete_prompt",
|
||||
{"message": "How does quantum computing work?", "domain": "quantum physics"}
|
||||
)
|
||||
|
||||
Conditional Templates
|
||||
-----------------
|
||||
|
||||
Use conditions in templates:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Register a template with conditions
|
||||
renderer.register_template(
|
||||
"conditional_prompt",
|
||||
"""
|
||||
You are a helpful assistant.
|
||||
|
||||
{% if context.user_level == "beginner" %}
|
||||
Please provide a simple explanation suitable for beginners.
|
||||
{% elif context.user_level == "intermediate" %}
|
||||
Please provide a detailed explanation with some technical terms.
|
||||
{% else %}
|
||||
Please provide an advanced technical explanation.
|
||||
{% endif %}
|
||||
|
||||
User: {message}
|
||||
|
||||
Assistant:
|
||||
"""
|
||||
)
|
||||
|
||||
# Render with different contexts
|
||||
beginner_prompt = renderer.render(
|
||||
"conditional_prompt",
|
||||
{"message": "How does quantum computing work?", "user_level": "beginner"}
|
||||
)
|
||||
|
||||
expert_prompt = renderer.render(
|
||||
"conditional_prompt",
|
||||
{"message": "How does quantum computing work?", "user_level": "expert"}
|
||||
)
|
||||
|
||||
Distributed Agent Networks
|
||||
========================
|
||||
|
||||
CleverAgents can be used to create distributed agent networks across multiple machines.
|
||||
|
||||
Agent Network Distribution
|
||||
-----------------------
|
||||
|
||||
Distribute agents across multiple machines:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.network import AgentNetwork
|
||||
from cleveragents.distribution import RemoteAgent
|
||||
|
||||
# Create a network
|
||||
network = AgentNetwork()
|
||||
|
||||
# Add a local agent
|
||||
network.add_agent(local_agent)
|
||||
|
||||
# Add a remote agent
|
||||
remote_agent = RemoteAgent(
|
||||
name="remote_assistant",
|
||||
endpoint="http://remote-server:8000/agent",
|
||||
api_key="your-api-key"
|
||||
)
|
||||
network.add_agent(remote_agent)
|
||||
|
||||
# Define connections
|
||||
router = network.get_router()
|
||||
router.add_route({
|
||||
"from": "input",
|
||||
"to": "local_agent",
|
||||
"condition": "true"
|
||||
})
|
||||
router.add_route({
|
||||
"from": "local_agent",
|
||||
"to": "remote_assistant",
|
||||
"condition": "true"
|
||||
})
|
||||
router.add_route({
|
||||
"from": "remote_assistant",
|
||||
"to": "output",
|
||||
"condition": "true"
|
||||
})
|
||||
|
||||
Remote Agent Server
|
||||
----------------
|
||||
|
||||
Create a server for remote agents:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from cleveragents.network import AgentNetwork
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Create a network with agents
|
||||
network = AgentNetwork()
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
message: str
|
||||
context: dict = None
|
||||
agent: str
|
||||
|
||||
@app.post("/agent")
|
||||
async def process_message(request: MessageRequest):
|
||||
try:
|
||||
# Get the specified agent
|
||||
agent = network.get_agent(request.agent)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail=f"Agent {request.agent} not found")
|
||||
|
||||
# Process the message
|
||||
response = await agent.process(request.message, request.context)
|
||||
|
||||
return {"response": response}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
Performance Optimization
|
||||
======================
|
||||
|
||||
Optimize CleverAgents for better performance.
|
||||
|
||||
Caching
|
||||
------
|
||||
|
||||
Implement caching to reduce redundant processing:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from functools import lru_cache
|
||||
|
||||
class CachedAgent(Agent):
|
||||
def __init__(self, name, config, template_renderer):
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.cache_size = config.get("cache_size", 100)
|
||||
self.process_with_cache = lru_cache(maxsize=self.cache_size)(self._process_uncached)
|
||||
|
||||
async def process(self, message, context=None):
|
||||
# Convert context to a hashable form for caching
|
||||
hashable_context = None
|
||||
if context:
|
||||
hashable_context = tuple(sorted((k, str(v)) for k, v in context.items()))
|
||||
|
||||
return await self.process_with_cache(message, hashable_context)
|
||||
|
||||
async def _process_uncached(self, message, hashable_context):
|
||||
# Convert hashable context back to a dict
|
||||
context = None
|
||||
if hashable_context:
|
||||
context = {k: v for k, v in hashable_context}
|
||||
|
||||
# Actual processing logic
|
||||
return f"Processed: {message}"
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["cached_processing"]
|
||||
|
||||
Batching
|
||||
-------
|
||||
|
||||
Batch process messages for better throughput:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
import asyncio
|
||||
|
||||
class BatchAgent(Agent):
|
||||
def __init__(self, name, config, template_renderer):
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.batch_size = config.get("batch_size", 10)
|
||||
self.batch_timeout = config.get("batch_timeout", 1.0)
|
||||
self.batch = []
|
||||
self.batch_lock = asyncio.Lock()
|
||||
self.batch_event = asyncio.Event()
|
||||
self.results = {}
|
||||
self.next_id = 0
|
||||
|
||||
# Start the batch processor
|
||||
asyncio.create_task(self._process_batches())
|
||||
|
||||
async def process(self, message, context=None):
|
||||
async with self.batch_lock:
|
||||
# Assign an ID to this request
|
||||
request_id = self.next_id
|
||||
self.next_id += 1
|
||||
|
||||
# Add to the batch
|
||||
self.batch.append((request_id, message, context))
|
||||
|
||||
# Signal that a new item is in the batch
|
||||
self.batch_event.set()
|
||||
|
||||
# Wait for the result
|
||||
while request_id not in self.results:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Get and remove the result
|
||||
result = self.results.pop(request_id)
|
||||
return result
|
||||
|
||||
async def _process_batches(self):
|
||||
while True:
|
||||
# Wait for items in the batch
|
||||
await self.batch_event.wait()
|
||||
|
||||
# Wait for more items or timeout
|
||||
await asyncio.sleep(self.batch_timeout)
|
||||
|
||||
# Get the current batch
|
||||
async with self.batch_lock:
|
||||
current_batch = self.batch[:self.batch_size]
|
||||
self.batch = self.batch[self.batch_size:]
|
||||
|
||||
# Reset the event if the batch is empty
|
||||
if not self.batch:
|
||||
self.batch_event.clear()
|
||||
|
||||
# Process the batch
|
||||
if current_batch:
|
||||
batch_results = await self._process_batch(current_batch)
|
||||
|
||||
# Store the results
|
||||
for request_id, result in batch_results:
|
||||
self.results[request_id] = result
|
||||
|
||||
async def _process_batch(self, batch):
|
||||
# Process the batch and return results
|
||||
# This is where you would implement your batch processing logic
|
||||
return [(request_id, f"Batch processed: {message}") for request_id, message, _ in batch]
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["batch_processing"]
|
||||
|
||||
Parallel Processing
|
||||
----------------
|
||||
|
||||
Process messages in parallel:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
import asyncio
|
||||
|
||||
class ParallelAgent(Agent):
|
||||
def __init__(self, name, config, template_renderer):
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.max_parallel = config.get("max_parallel", 10)
|
||||
self.semaphore = asyncio.Semaphore(self.max_parallel)
|
||||
|
||||
async def process(self, message, context=None):
|
||||
async with self.semaphore:
|
||||
# Process the message with a limit on parallel executions
|
||||
return await self._process_message(message, context)
|
||||
|
||||
async def _process_message(self, message, context=None):
|
||||
# Actual processing logic
|
||||
return f"Processed: {message}"
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["parallel_processing"]
|
||||
@@ -0,0 +1,8 @@
|
||||
=============
|
||||
API Reference
|
||||
=============
|
||||
|
||||
This section provides detailed API documentation for CleverAgents.
|
||||
|
||||
Agents:
|
||||
The agents module contains all the implementations of the different types of agents available in the package.
|
||||
@@ -1 +0,0 @@
|
||||
.. include:: ../AUTHORS.md
|
||||
+1
-1
@@ -1 +1 @@
|
||||
.. include:: ../CHANGELOG.md
|
||||
Changelog
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Concepts
|
||||
+6
-4
@@ -2,6 +2,8 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath('../src'))
|
||||
|
||||
|
||||
extensions = [
|
||||
@@ -22,7 +24,7 @@ if os.getenv('SPELLCHECK'):
|
||||
|
||||
source_suffix = '.rst'
|
||||
master_doc = 'index'
|
||||
project = u'Boilerplate'
|
||||
project = u'CleverAgents'
|
||||
year = '2024'
|
||||
author = u'Jeffrey Phillips Freeman'
|
||||
copyright = '{0}, {1}'.format(year, author)
|
||||
@@ -31,14 +33,14 @@ version = release = u'0.1.0'
|
||||
pygments_style = 'trac'
|
||||
templates_path = ['.']
|
||||
extlinks = {
|
||||
'issue': ('https://git.cleverthis.com/cleverthis/base/base-python/-/issues%s', '#'),
|
||||
'pr': ('https://git.cleverthis.com/cleverthis/base/base-python/-/merge_requests%s', 'PR #'),
|
||||
'issue': ('https://git.cleverthis.com/cleverthis/cleveragents/-/issues%s', '#'),
|
||||
'pr': ('https://git.cleverthis.com/cleverthis/cleveragents/-/merge_requests%s', 'PR #'),
|
||||
}
|
||||
import sphinx_py3doc_enhanced_theme
|
||||
html_theme = "sphinx_py3doc_enhanced_theme"
|
||||
html_theme_path = [sphinx_py3doc_enhanced_theme.get_html_theme_path()]
|
||||
html_theme_options = {
|
||||
'githuburl': 'https://git.cleverthis.com/cleverthis/base/base-python'
|
||||
'githuburl': 'https://git.cleverthis.com/cleverthis/cleveragents'
|
||||
}
|
||||
|
||||
html_use_smartypants = True
|
||||
|
||||
@@ -1 +1 @@
|
||||
.. include:: ../CONTRIBUTING.md
|
||||
.. include:: ../CONTRIBUTING.rst
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.. include:: ../CONTRIBUTORS.rst
|
||||
+5
-2
@@ -7,10 +7,14 @@ Contents
|
||||
|
||||
readme
|
||||
installation
|
||||
quickstart
|
||||
usage
|
||||
concepts
|
||||
advanced_usage
|
||||
api_reference
|
||||
reference/index
|
||||
contributing
|
||||
authors
|
||||
contributors
|
||||
changelog
|
||||
|
||||
Indices and tables
|
||||
@@ -19,4 +23,3 @@ Indices and tables
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@ Installation
|
||||
|
||||
At the command line::
|
||||
|
||||
pip install boilerplate
|
||||
pip install cleveragents
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
===========
|
||||
Quick Start
|
||||
===========
|
||||
|
||||
This guide will help you get started with CleverAgents quickly. We'll create a simple agent network, run it in both single-shot and interactive modes, and explore some basic customization options.
|
||||
|
||||
Installation
|
||||
===========
|
||||
|
||||
First, install CleverAgents using pip:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install cleveragents
|
||||
|
||||
================================
|
||||
Creating Your First Agent Network
|
||||
================================
|
||||
|
||||
Let's create a simple agent network with two agents: a user interface agent and an LLM-powered assistant.
|
||||
|
||||
Create a file named ``my_network.yaml`` with the following content:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
name: "Simple Assistant Network"
|
||||
description: "A basic network with a user interface and an assistant"
|
||||
|
||||
variables:
|
||||
default_model: "gpt-4"
|
||||
|
||||
agents:
|
||||
- name: "interface"
|
||||
type: "interface"
|
||||
description: "Handles user interaction"
|
||||
|
||||
- name: "assistant"
|
||||
type: "llm"
|
||||
description: "Provides helpful responses"
|
||||
model: "${default_model}"
|
||||
system_message: "You are a helpful assistant that provides concise and accurate information."
|
||||
|
||||
connections:
|
||||
- from: "interface"
|
||||
to: "assistant"
|
||||
|
||||
- from: "assistant"
|
||||
to: "interface"
|
||||
|
||||
==========================
|
||||
Running in Single-Shot Mode
|
||||
==========================
|
||||
|
||||
To process a single query through your agent network:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cleveragents run --config my_network.yaml --input "What is the capital of France?"
|
||||
|
||||
This will output:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
The capital of France is Paris.
|
||||
|
||||
=========================
|
||||
Running in Interactive Mode
|
||||
=========================
|
||||
|
||||
For a continuous conversation with your agent network:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cleveragents interactive --config my_network.yaml
|
||||
|
||||
This will start an interactive session:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
CleverAgents Interactive Session
|
||||
Type '/help' for available commands
|
||||
> What is the capital of France?
|
||||
The capital of France is Paris.
|
||||
|
||||
> Tell me more about Paris.
|
||||
Paris is the capital and most populous city of France. Located on the Seine River in the north-central part of the country, it is a major European city and a global center for art, fashion, gastronomy, and culture. The city is known for its iconic landmarks such as the Eiffel Tower, the Louvre Museum, Notre-Dame Cathedral, and the Champs-Élysées.
|
||||
|
||||
> /exit
|
||||
Session ended.
|
||||
|
||||
=======================
|
||||
Customizing Your Network
|
||||
=======================
|
||||
|
||||
You can override variables in your configuration at runtime:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cleveragents run --config my_network.yaml --var default_model=gpt-3.5-turbo --input "What is the capital of France?"
|
||||
|
||||
This will use the GPT-3.5 Turbo model instead of GPT-4 for this run.
|
||||
|
||||
Next Steps
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
If you encounter any issues while setting up or running CleverAgents, please check the following:
|
||||
- Ensure that all dependencies are installed correctly.
|
||||
- Verify that your configuration file is properly formatted.
|
||||
- Consult the FAQ section on our website or open an issue on the repository if the problem persists.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
.. include:: ../README.md
|
||||
.. include:: ../README.rst
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
boilerplate
|
||||
==========
|
||||
|
||||
.. testsetup::
|
||||
|
||||
from boilerplate import *
|
||||
|
||||
.. automodule:: boilerplate
|
||||
:members:
|
||||
@@ -0,0 +1,10 @@
|
||||
=============
|
||||
cleveragents
|
||||
=============
|
||||
|
||||
.. testsetup::
|
||||
|
||||
from cleveragents import *
|
||||
|
||||
.. automodule:: cleveragents
|
||||
:members:
|
||||
@@ -1,7 +1,20 @@
|
||||
Reference
|
||||
|
||||
=========
|
||||
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
boilerplate*
|
||||
cleveragents*
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
cleveragents*
|
||||
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
cleveragents*
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
cleveragents*
|
||||
|
||||
@@ -7,5 +7,4 @@ staticmethods
|
||||
args
|
||||
kwargs
|
||||
callstack
|
||||
Changelog
|
||||
Indices
|
||||
|
||||
+4
-2
@@ -2,6 +2,8 @@
|
||||
Usage
|
||||
=====
|
||||
|
||||
To use Boilerplate in a project::
|
||||
To use CleverAgents in a project::
|
||||
|
||||
import boilerplate
|
||||
import cleveragents
|
||||
Usage
|
||||
Usage
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
[bdist_wheel]
|
||||
universal = 1
|
||||
|
||||
[flake8]
|
||||
max-line-length = 140
|
||||
@@ -32,9 +31,9 @@ addopts =
|
||||
[isort]
|
||||
force_single_line=True
|
||||
line_length=120
|
||||
known_first_party=boilerplate
|
||||
known_first_party=cleveragents
|
||||
default_section=THIRDPARTY
|
||||
forced_separate=test_boilerplate
|
||||
forced_separate=test_cleveragents
|
||||
not_skip = __init__.py
|
||||
skip = migrations, south_migrations
|
||||
|
||||
|
||||
@@ -11,56 +11,63 @@ from os.path import dirname
|
||||
from os.path import join
|
||||
from os.path import splitext
|
||||
|
||||
from setuptools import find_packages
|
||||
from setuptools import setup
|
||||
from setuptools import find_packages # type: ignore
|
||||
from setuptools import setup # type: ignore
|
||||
|
||||
|
||||
def read(*names, **kwargs):
|
||||
return io.open(
|
||||
join(dirname(__file__), *names),
|
||||
encoding=kwargs.get('encoding', 'utf8')
|
||||
join(dirname(__file__), *names), encoding=kwargs.get("encoding", "utf8")
|
||||
).read()
|
||||
|
||||
|
||||
setup(
|
||||
name='boilerplate',
|
||||
version='0.1.0',
|
||||
license='Apache',
|
||||
description='A starter project for Python',
|
||||
long_description='%s\n%s' % (
|
||||
re.compile('^.. start-badges.*^.. end-badges', re.M | re.S).sub('', read('README.rst')),
|
||||
re.sub(':[a-z]+:`~?(.*?)`', r'``\1``', read('CHANGELOG.rst'))
|
||||
name="cleveragents",
|
||||
version="0.1.0",
|
||||
license="Apache",
|
||||
description="An Agent Based LLM tool using LangGraph",
|
||||
long_description="%s\n%s"
|
||||
% (
|
||||
re.compile("^.. start-badges.*^.. end-badges", re.M | re.S).sub(
|
||||
"", read("README.rst")
|
||||
),
|
||||
re.sub(":[a-z]+:`~?(.*?)`", r"``\1``", read("CHANGELOG.rst")),
|
||||
),
|
||||
author='CleverThis',
|
||||
author_email='jeffrey.freeman@cleverthis.com',
|
||||
url='https://git.cleverthis.com/cleverthis/base/base-python',
|
||||
packages=find_packages('src'),
|
||||
package_dir={'': 'src'},
|
||||
py_modules=[splitext(basename(path))[0] for path in glob('src/*.py')],
|
||||
long_description_content_type="text/x-rst",
|
||||
author="CleverThis",
|
||||
author_email="jeffrey.freeman@cleverthis.com",
|
||||
url="https://git.cleverthis.com/cleverthis/cleveragents",
|
||||
packages=find_packages("src", include=["cleveragents", "cleveragents.*"]),
|
||||
package_dir={"": "src"},
|
||||
py_modules=[splitext(basename(path))[0] for path in glob("src/*.py")],
|
||||
include_package_data=True,
|
||||
zip_safe=False,
|
||||
classifiers=[
|
||||
# complete classifier list: http://pypi.python.org/pypi?%3Aaction=list_classifiers
|
||||
'Development Status :: 5 - Production/Stable',
|
||||
'Intended Audience :: Developers',
|
||||
'License :: OSI Approved :: Apache License',
|
||||
'Operating System :: Unix',
|
||||
'Operating System :: POSIX',
|
||||
'Operating System :: Microsoft :: Windows',
|
||||
'Programming Language :: Python',
|
||||
'Programming Language :: Python :: 3.9',
|
||||
'Programming Language :: Python :: Implementation :: CPython',
|
||||
"Development Status :: 5 - Production/Stable",
|
||||
"Intended Audience :: Developers",
|
||||
"License :: OSI Approved :: Apache License",
|
||||
"Operating System :: Unix",
|
||||
"Operating System :: POSIX",
|
||||
"Operating System :: Microsoft :: Windows",
|
||||
"Programming Language :: Python",
|
||||
"Programming Language :: Python :: 3.9",
|
||||
"Programming Language :: Python :: Implementation :: CPython",
|
||||
# uncomment if you test on these interpreters:
|
||||
# 'Programming Language :: Python :: Implementation :: IronPython',
|
||||
# 'Programming Language :: Python :: Implementation :: Jython',
|
||||
# 'Programming Language :: Python :: Implementation :: Stackless',
|
||||
'Topic :: Utilities',
|
||||
"Topic :: Utilities",
|
||||
],
|
||||
keywords=[
|
||||
# eg: 'keyword1', 'keyword2', 'keyword3',
|
||||
],
|
||||
install_requires=[
|
||||
'click',
|
||||
"click",
|
||||
"langgraph",
|
||||
"aiohttp",
|
||||
"jinja2", # Add this
|
||||
"pystache", # Add this if you're using the Mustache template engine
|
||||
],
|
||||
extras_require={
|
||||
# eg:
|
||||
@@ -68,8 +75,8 @@ setup(
|
||||
# ':python_version=="2.6"': ['argparse'],
|
||||
},
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'boilerplate = boilerplate.cli:main',
|
||||
"console_scripts": [
|
||||
"cleveragents = cleveragents.cli:main",
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
Module that contains the command line app.
|
||||
|
||||
Why does this file exist, and why not put this in __main__?
|
||||
|
||||
You might be tempted to import things from __main__ later, but that will cause
|
||||
problems: the code will get executed twice:
|
||||
|
||||
- When you run `python -mboilerplate` python will execute
|
||||
``__main__.py`` as a script. That means there won't be any
|
||||
``boilerplate.__main__`` in ``sys.modules``.
|
||||
- When you import __main__ it will get executed again (as a module) because
|
||||
there's no ``boilerplate.__main__`` in ``sys.modules``.
|
||||
|
||||
Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration
|
||||
"""
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument('names', nargs=-1)
|
||||
def main(names):
|
||||
click.echo(repr(names))
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
CleverAgents - An Agent Based LLM tool in Python.
|
||||
|
||||
This package provides a framework for creating a graph of interactions between
|
||||
various LLM agents using LangGraph by providing templates, prompts, and
|
||||
configuration files describing how the agents are configured and communicate
|
||||
with each other.
|
||||
"""
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Entrypoint module, in case you use `python -mboilerplate`.
|
||||
Entrypoint module, in case you use `python -mcleveragents`.
|
||||
|
||||
This module serves as the entry point when the package is executed directly
|
||||
using `python -mcleveragents`. It delegates to the main CLI function.
|
||||
|
||||
Why does this file exist, and why __main__? For more info, read:
|
||||
|
||||
@@ -8,7 +10,8 @@ Why does this file exist, and why __main__? For more info, read:
|
||||
- https://docs.python.org/2/using/cmdline.html#cmdoption-m
|
||||
- https://docs.python.org/3/using/cmdline.html#cmdoption-m
|
||||
"""
|
||||
from boilerplate.cli import main
|
||||
|
||||
from cleveragents.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,9 @@
|
||||
"""
|
||||
Module: agent
|
||||
|
||||
This module provides the base Agent classes for CleverAgents.
|
||||
It serves as a proxy to the agents/base.py module.
|
||||
"""
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.agents.base import AgentWithMemory
|
||||
@@ -0,0 +1,171 @@
|
||||
"""
|
||||
Base agent module for CleverAgents.
|
||||
|
||||
This module defines the base Agent class and related interfaces that all
|
||||
agent implementations must adhere to. It provides the foundation for creating
|
||||
different types of agents with consistent interfaces.
|
||||
"""
|
||||
|
||||
import copy
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from cleveragents.core.exceptions import AgentCreationError
|
||||
from cleveragents.core.exceptions import ExecutionError
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
|
||||
class Agent(ABC):
|
||||
"""
|
||||
Abstract base class for all agents.
|
||||
|
||||
This class defines the interface that all agent implementations must follow.
|
||||
It provides methods for initializing an agent, processing messages, and
|
||||
managing agent state.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the agent.
|
||||
config (Dict[str, Any]): The agent's configuration.
|
||||
template_renderer (TemplateRenderer): Renderer for processing templates.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, config: Dict[str, Any], template_renderer: TemplateRenderer
|
||||
):
|
||||
"""
|
||||
Initialize an agent.
|
||||
|
||||
Args:
|
||||
name: The name of the agent.
|
||||
config: The agent's configuration.
|
||||
template_renderer: Renderer for processing templates.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If the agent cannot be initialized with the given configuration.
|
||||
"""
|
||||
self.name = name
|
||||
self.config = config
|
||||
self.template_renderer = template_renderer
|
||||
|
||||
@abstractmethod
|
||||
async def process(
|
||||
self, message: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message and generate a response.
|
||||
|
||||
This method takes a message, processes it according to the agent's
|
||||
configuration and capabilities, and returns a response.
|
||||
|
||||
Args:
|
||||
message: The message to process.
|
||||
context: Additional context information for processing the message.
|
||||
|
||||
Returns:
|
||||
The agent's response to the message.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If message processing fails.
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""
|
||||
Get the capabilities of the agent.
|
||||
|
||||
This method returns a list of capabilities that the agent supports,
|
||||
such as 'text-generation', 'code-generation', etc.
|
||||
|
||||
Returns:
|
||||
A list of capability identifiers.
|
||||
"""
|
||||
pass
|
||||
|
||||
def get_metadata(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get metadata about the agent.
|
||||
|
||||
This method returns a dictionary of metadata about the agent,
|
||||
such as its name, model, provider, etc.
|
||||
|
||||
Returns:
|
||||
A dictionary of agent metadata.
|
||||
"""
|
||||
metadata = {
|
||||
"name": self.name,
|
||||
"type": self.__class__.__name__,
|
||||
"capabilities": self.get_capabilities(),
|
||||
}
|
||||
|
||||
# Add model and provider if available
|
||||
if "model" in self.config:
|
||||
metadata["model"] = self.config["model"]
|
||||
if "provider" in self.config:
|
||||
metadata["provider"] = self.config["provider"]
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
class AgentWithMemory(Agent):
|
||||
"""
|
||||
Base class for agents with memory/state.
|
||||
|
||||
This class extends the base Agent class to add memory capabilities,
|
||||
allowing agents to maintain state between message processing calls.
|
||||
|
||||
Attributes:
|
||||
memory (Dict[str, Any]): The agent's memory/state.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, config: Dict[str, Any], template_renderer: TemplateRenderer
|
||||
):
|
||||
"""
|
||||
Initialize an agent with memory.
|
||||
|
||||
Args:
|
||||
name: The name of the agent.
|
||||
config: The agent's configuration.
|
||||
template_renderer: Renderer for processing templates.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If the agent cannot be initialized with the given configuration.
|
||||
"""
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.memory: Dict[str, Any] = {}
|
||||
|
||||
def save_memory(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Save the agent's memory to a serializable format.
|
||||
|
||||
This method returns a dictionary representation of the agent's memory
|
||||
that can be serialized and later restored.
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the agent's memory.
|
||||
"""
|
||||
# Create a deep copy of the memory to avoid modifying the original
|
||||
return copy.deepcopy(self.memory)
|
||||
|
||||
def load_memory(self, memory: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Load the agent's memory from a serialized format.
|
||||
|
||||
This method restores the agent's memory from a dictionary representation.
|
||||
|
||||
Args:
|
||||
memory: A dictionary representation of the agent's memory.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If the memory cannot be loaded.
|
||||
"""
|
||||
if not isinstance(memory, dict):
|
||||
raise AgentCreationError(f"Memory must be a dictionary, got {type(memory)}")
|
||||
|
||||
# Replace the current memory with the loaded memory
|
||||
self.memory = memory
|
||||
@@ -0,0 +1,81 @@
|
||||
"""
|
||||
Chain agent module for CleverAgents.
|
||||
|
||||
This module defines the ChainAgent class, which represents an agent that chains multiple operations.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
|
||||
class ChainAgent(Agent):
|
||||
"""
|
||||
ChainAgent represents an agent that processes messages by chaining multiple operations.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the agent.
|
||||
config (Dict[str, Any]): The agent's configuration.
|
||||
template_renderer (TemplateRenderer): Renderer for processing templates.
|
||||
steps (List[str]): A list of processing steps.
|
||||
"""
|
||||
|
||||
prompt_template: Optional[str]
|
||||
|
||||
def __init__(
|
||||
self, name: str, config: Dict[str, Any], template_renderer: TemplateRenderer
|
||||
):
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.steps = config.get("steps", [])
|
||||
|
||||
prompt = self.config.get("prompt")
|
||||
prompt_reference = self.config.get("prompt_reference")
|
||||
if prompt and prompt_reference:
|
||||
raise ConfigurationError(
|
||||
f"Agent '{self.name}' configuration cannot contain both 'prompt' and 'prompt_reference'."
|
||||
)
|
||||
|
||||
if prompt_reference:
|
||||
self.prompt_template = self.template_renderer.get_template(prompt_reference)
|
||||
else:
|
||||
self.prompt_template = prompt
|
||||
|
||||
async def process(
|
||||
self, message: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message by sequentially applying a series of steps.
|
||||
|
||||
Args:
|
||||
message: The message to process.
|
||||
context: Optional context for processing.
|
||||
|
||||
Returns:
|
||||
A response string after chain processing.
|
||||
"""
|
||||
result = message
|
||||
if self.prompt_template:
|
||||
render_context = context or {}
|
||||
render_context.setdefault("message", message)
|
||||
result = self.template_renderer.render_string(
|
||||
self.prompt_template,
|
||||
render_context,
|
||||
source_description=f"prompt for agent '{self.name}'",
|
||||
)
|
||||
for step in self.steps:
|
||||
result = f"{result} -> {step}"
|
||||
return f"ChainAgent processed: {result}"
|
||||
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""
|
||||
Get the capabilities of the chain agent.
|
||||
|
||||
Returns:
|
||||
A list of capability identifiers.
|
||||
"""
|
||||
return ["chain-processing"]
|
||||
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Composite agent implementation.
|
||||
|
||||
This module provides a composite agent that can combine multiple agents
|
||||
to work together as a single unit.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
from ..core.exceptions import ConfigurationError
|
||||
from ..core.exceptions import ExecutionError
|
||||
from ..templates.renderer import TemplateRenderer
|
||||
from .base import Agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..routing.router import Router
|
||||
|
||||
|
||||
class CompositeAgent(Agent):
|
||||
"""
|
||||
CompositeAgent combines multiple agents into a single agent.
|
||||
|
||||
This agent delegates processing to its child agents and combines their
|
||||
responses according to a configurable strategy. It can also encapsulate
|
||||
a router to embed a routing flow within another CompositeAgent.
|
||||
|
||||
Configuration:
|
||||
The behavior of a CompositeAgent is defined in the `agents` section of
|
||||
the configuration file.
|
||||
|
||||
strategy (str):
|
||||
The strategy for combining child agent responses.
|
||||
- ``sequential``: Passes the output of one step as the input to the next.
|
||||
- ``parallel``: Runs all steps concurrently and combines their outputs.
|
||||
- ``route``: Delegates processing to a specified router.
|
||||
|
||||
steps (List[Union[str, Dict[str, str]]]):
|
||||
A list of child agent or router names to execute. Required for
|
||||
`sequential` and `parallel` strategies. For the `parallel`
|
||||
strategy, you can provide an alias for a step to avoid key
|
||||
collisions in the output when using the same agent multiple times.
|
||||
|
||||
Example for ``parallel`` strategy with aliasing:
|
||||
.. code-block:: yaml
|
||||
|
||||
steps:
|
||||
- summarizer_agent
|
||||
- first_summary: summarizer_agent
|
||||
- second_summary: summarizer_agent
|
||||
|
||||
route (str):
|
||||
The name of the router to use. Required for the `route` strategy.
|
||||
|
||||
output_format (str):
|
||||
For the `parallel` strategy, defines how to format the combined
|
||||
output. Can be ``text`` (default) or ``json``.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the agent.
|
||||
config (Dict[str, Any]): The agent's configuration.
|
||||
template_renderer (TemplateRenderer): Renderer for processing templates.
|
||||
agents (Dict[str, Agent]): Dictionary of child agents for 'sequential'
|
||||
and 'parallel' strategies.
|
||||
strategy (str): Strategy for combining responses. Can be
|
||||
'sequential', 'parallel', or 'route'.
|
||||
route_name (Optional[str]): The name of the router to execute when using
|
||||
the 'route' strategy.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, config: Dict[str, Any], template_renderer: TemplateRenderer
|
||||
):
|
||||
"""
|
||||
Initialize a CompositeAgent.
|
||||
|
||||
Args:
|
||||
name (str): The name of the agent.
|
||||
config (Dict[str, Any]): The agent's configuration.
|
||||
template_renderer (TemplateRenderer): Renderer for processing templates.
|
||||
"""
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.agents: Dict[str, Agent] = {}
|
||||
|
||||
logger.debug(f"CompositeAgent '{name}' raw config: {self.config}")
|
||||
|
||||
self.strategy = self.config.get("strategy", "route")
|
||||
self.route_name: Optional[str] = None
|
||||
|
||||
if self.strategy == "route":
|
||||
if "steps" in self.config:
|
||||
raise ConfigurationError(
|
||||
f"CompositeAgent '{name}' with 'route' strategy must not have a "
|
||||
"'steps' section in its configuration. "
|
||||
)
|
||||
self.route_name = self.config.get("route")
|
||||
if not self.route_name:
|
||||
raise ConfigurationError(
|
||||
f"CompositeAgent '{name}' with 'route' strategy requires a 'route' "
|
||||
"attribute in its configuration."
|
||||
)
|
||||
|
||||
def add_agent(self, name: str, agent: Agent) -> None:
|
||||
"""
|
||||
Add a child agent to this composite agent.
|
||||
|
||||
Args:
|
||||
name (str): The name to assign to the child agent.
|
||||
agent (Agent): The agent to add.
|
||||
"""
|
||||
self.agents[name] = agent
|
||||
|
||||
async def process(
|
||||
self, message: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message by delegating to child agents or a route.
|
||||
|
||||
Args:
|
||||
message (str): The message to process.
|
||||
context (Dict[str, Any], optional): Additional context for processing.
|
||||
|
||||
Returns:
|
||||
str: The processed response.
|
||||
"""
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
if self.strategy == "sequential":
|
||||
return await self._process_sequential(message, context)
|
||||
elif self.strategy == "parallel":
|
||||
return await self._process_parallel(message, context)
|
||||
elif self.strategy == "route":
|
||||
return await self._process_route(message, context)
|
||||
else:
|
||||
raise ConfigurationError(
|
||||
f"Unknown combination strategy '{self.strategy}' for CompositeAgent "
|
||||
f"'{self.name}'."
|
||||
)
|
||||
|
||||
async def _process_route(self, message: str, context: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Process a message by passing it to a configured router.
|
||||
|
||||
Args:
|
||||
message (str): The message to process.
|
||||
context (Dict[str, Any]): Additional context for processing.
|
||||
|
||||
Returns:
|
||||
str: The processed response from the router.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If the routers are not found in the context or
|
||||
the specified router does not exist.
|
||||
"""
|
||||
routers_opt: Optional[Dict[str, "Router"]] = context.get("_routers")
|
||||
# Treat “routers” as *missing* only when the key is absent (None),
|
||||
# not when an empty dict is supplied.
|
||||
if routers_opt is None:
|
||||
raise ExecutionError(
|
||||
"Routers not found in context, required for 'route' strategy in "
|
||||
"CompositeAgent."
|
||||
)
|
||||
routers: Dict[str, "Router"] = routers_opt
|
||||
|
||||
assert self.route_name is not None
|
||||
router = routers.get(self.route_name)
|
||||
if not router:
|
||||
raise ExecutionError(
|
||||
f"Router '{self.route_name}' not found for CompositeAgent "
|
||||
f"'{self.name}'."
|
||||
)
|
||||
|
||||
return await router.process_message(message, context)
|
||||
|
||||
async def _process_sequential(self, message: str, context: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Process a message by passing it through configured steps sequentially.
|
||||
|
||||
Args:
|
||||
message (str): The message to process.
|
||||
context (Dict[str, Any]): Additional context for processing.
|
||||
|
||||
Returns:
|
||||
str: The processed response.
|
||||
"""
|
||||
current_message = message
|
||||
steps_config = self.config.get("steps", [])
|
||||
routers: Dict[str, "Router"] = context.get("_routers", {})
|
||||
|
||||
if not isinstance(steps_config, list):
|
||||
raise ConfigurationError(
|
||||
f"The 'steps' for CompositeAgent '{self.name}' with sequential strategy must be a list."
|
||||
)
|
||||
|
||||
for step_item in steps_config:
|
||||
if isinstance(step_item, str):
|
||||
step_name = step_item
|
||||
elif isinstance(step_item, dict):
|
||||
if len(step_item) != 1:
|
||||
raise ConfigurationError(
|
||||
f"Step dictionary in CompositeAgent '{self.name}' must have exactly one key-value pair."
|
||||
)
|
||||
step_name = next(iter(step_item.values()))
|
||||
else:
|
||||
raise ConfigurationError(
|
||||
f"Invalid step format in CompositeAgent '{self.name}'. Steps must be strings or dictionaries."
|
||||
)
|
||||
|
||||
if step_name in self.agents:
|
||||
agent = self.agents[step_name]
|
||||
current_message = await agent.process(current_message, context)
|
||||
elif step_name in routers:
|
||||
router = routers[step_name]
|
||||
current_message = await router.process_message(current_message, context)
|
||||
else:
|
||||
logger.warning(
|
||||
"Step '%s' not found as an agent or route for CompositeAgent '%s'.",
|
||||
step_name,
|
||||
self.name,
|
||||
)
|
||||
return current_message
|
||||
|
||||
async def _process_parallel(self, message: str, context: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Process a message by passing it to all steps in parallel and combining results.
|
||||
|
||||
Args:
|
||||
message (str): The message to process.
|
||||
context (Dict[str, Any]): Additional context for processing.
|
||||
|
||||
Returns:
|
||||
str: The combined processed response.
|
||||
"""
|
||||
output_format = self.config.get("output_format", "text")
|
||||
|
||||
steps_config = self.config.get("steps", [])
|
||||
if not steps_config:
|
||||
logger.warning(
|
||||
"CompositeAgent '%s' with parallel strategy has no steps.",
|
||||
self.name,
|
||||
)
|
||||
return ""
|
||||
|
||||
if not isinstance(steps_config, list):
|
||||
raise ConfigurationError(
|
||||
f"The 'steps' for CompositeAgent '{self.name}' with parallel strategy must be a list."
|
||||
)
|
||||
|
||||
tasks = []
|
||||
output_keys = []
|
||||
routers: Dict[str, "Router"] = context.get("_routers", {})
|
||||
|
||||
for step_item in steps_config:
|
||||
if isinstance(step_item, str):
|
||||
output_key, step_name = step_item, step_item
|
||||
elif isinstance(step_item, dict):
|
||||
if len(step_item) != 1:
|
||||
raise ConfigurationError(
|
||||
f"Step dictionary in CompositeAgent '{self.name}' must have exactly one key-value pair."
|
||||
)
|
||||
output_key = next(iter(step_item.keys()))
|
||||
step_name = next(iter(step_item.values()))
|
||||
else:
|
||||
raise ConfigurationError(
|
||||
f"Invalid step format in CompositeAgent '{self.name}'. Steps must be strings or dictionaries."
|
||||
)
|
||||
|
||||
if step_name in self.agents:
|
||||
agent = self.agents[step_name]
|
||||
tasks.append(agent.process(message, context))
|
||||
output_keys.append(output_key)
|
||||
elif step_name in routers:
|
||||
router = routers[step_name]
|
||||
tasks.append(router.process_message(message, context))
|
||||
output_keys.append(output_key)
|
||||
else:
|
||||
logger.warning(
|
||||
"Step '%s' not found as an agent or route for CompositeAgent '%s'.",
|
||||
step_name,
|
||||
self.name,
|
||||
)
|
||||
|
||||
if not tasks:
|
||||
return ""
|
||||
|
||||
results = await asyncio.gather(*tasks)
|
||||
|
||||
if output_format == "json":
|
||||
response_dict = {name: result for name, result in zip(output_keys, results)}
|
||||
return json.dumps(response_dict)
|
||||
elif output_format == "text":
|
||||
responses = [
|
||||
f"{name}: {result}" for name, result in zip(output_keys, results)
|
||||
]
|
||||
return "\n\n".join(responses)
|
||||
else:
|
||||
raise ConfigurationError(
|
||||
f"Invalid format '{output_format}' for parallel strategy in CompositeAgent '{self.name}'. "
|
||||
"Must be 'text' or 'json'."
|
||||
)
|
||||
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""
|
||||
Get the capabilities of this agent.
|
||||
|
||||
Returns:
|
||||
List[str]: List of capabilities.
|
||||
"""
|
||||
capabilities = ["composite", self.strategy]
|
||||
if self.strategy != "route":
|
||||
for agent in self.agents.values():
|
||||
capabilities.extend(agent.get_capabilities())
|
||||
return list(set(capabilities)) # Remove duplicates
|
||||
@@ -0,0 +1,15 @@
|
||||
"""
|
||||
This module contains decorators for agents.
|
||||
"""
|
||||
|
||||
|
||||
def log_action(func):
|
||||
"""
|
||||
Decorator to log the actions of an agent.
|
||||
"""
|
||||
|
||||
def wrapper(*args, **kwargs):
|
||||
print(f"Executing {func.__name__}")
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,170 @@
|
||||
"""
|
||||
Agent factory module for CleverAgents.
|
||||
|
||||
This module provides a factory for creating agent instances based on
|
||||
configuration. It supports different types of agents and ensures they are
|
||||
properly initialized with the necessary dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import inspect
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
from typing import Type
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.agents.chain import ChainAgent
|
||||
from cleveragents.agents.composite import CompositeAgent
|
||||
from cleveragents.agents.llm import LLMAgent
|
||||
from cleveragents.agents.tool import ToolAgent
|
||||
from cleveragents.core.exceptions import AgentCreationError
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
|
||||
class AgentFactory:
|
||||
"""
|
||||
Factory for creating agent instances.
|
||||
|
||||
This class is responsible for creating agent instances based on
|
||||
configuration. It maintains a registry of agent types and ensures
|
||||
that agents are properly initialized with the necessary dependencies.
|
||||
|
||||
Attributes:
|
||||
agent_types (Dict[str, Type[Agent]]): Registry of agent types.
|
||||
template_renderer (TemplateRenderer): Renderer for processing templates.
|
||||
config (Dict[str, Any]): Configuration for agent creation.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any], template_renderer: TemplateRenderer):
|
||||
"""
|
||||
Initialize the AgentFactory.
|
||||
|
||||
Args:
|
||||
config: Configuration for agent creation.
|
||||
template_renderer: Renderer for processing templates.
|
||||
"""
|
||||
self.agent_types = {
|
||||
"llm": LLMAgent,
|
||||
"tool": ToolAgent,
|
||||
"chain": ChainAgent,
|
||||
"composite": CompositeAgent,
|
||||
}
|
||||
self.template_renderer = template_renderer
|
||||
self.config = config
|
||||
self.agents_cache: Dict[str, Agent] = {}
|
||||
|
||||
def create_agent(self, name: str) -> Agent:
|
||||
"""
|
||||
Create an agent instance, handling dependencies.
|
||||
|
||||
This method creates an agent instance based on the configuration
|
||||
for the agent with the given name. It uses a cache to avoid re-creating
|
||||
agents and handles dependency injection for CompositeAgents.
|
||||
|
||||
Args:
|
||||
name: The name of the agent to create.
|
||||
|
||||
Returns:
|
||||
An initialized agent instance.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If the agent cannot be created.
|
||||
"""
|
||||
if name in self.agents_cache:
|
||||
return self.agents_cache[name]
|
||||
|
||||
try:
|
||||
agents_config = self.config.get("agents", {})
|
||||
agent_config = None
|
||||
|
||||
# Handle both dictionary and list formats for the agents section.
|
||||
if isinstance(agents_config, dict):
|
||||
agent_config = agents_config.get(name)
|
||||
elif isinstance(agents_config, list):
|
||||
for cfg in agents_config:
|
||||
if isinstance(cfg, dict) and cfg.get("name") == name:
|
||||
agent_config = cfg
|
||||
break
|
||||
|
||||
if not agent_config:
|
||||
raise AgentCreationError(f"Agent '{name}' not found in configuration")
|
||||
|
||||
agent_type = agent_config.get("type", "llm").lower()
|
||||
|
||||
if agent_type not in self.agent_types:
|
||||
raise AgentCreationError(f"Unknown agent type: {agent_type}")
|
||||
|
||||
agent_class = self.agent_types[agent_type]
|
||||
if inspect.isabstract(agent_class):
|
||||
raise AgentCreationError(
|
||||
f"Cannot instantiate abstract class {agent_class.__name__}"
|
||||
)
|
||||
|
||||
agent_specific_config: Dict[str, Any] = agent_config.get("config", {})
|
||||
|
||||
agent = agent_class(name, agent_specific_config, self.template_renderer) # type: ignore[abstract]
|
||||
|
||||
# Cache the agent instance immediately to handle circular dependencies.
|
||||
self.agents_cache[name] = agent
|
||||
|
||||
# If the agent is a CompositeAgent, create and add its child agents.
|
||||
if isinstance(agent, CompositeAgent):
|
||||
strategy = agent_specific_config.get("strategy", "route")
|
||||
if strategy != "route":
|
||||
all_agent_configs = self.config.get("agents", {})
|
||||
agent_steps = agent_specific_config.get("steps", [])
|
||||
for step_name in agent_steps:
|
||||
# The factory's responsibility is to build and inject agent dependencies.
|
||||
# If a step is an agent, we create and add it.
|
||||
# If it's a route, we do nothing; the CompositeAgent will resolve it
|
||||
# at runtime using the routers from the context.
|
||||
if step_name in all_agent_configs:
|
||||
child_agent = self.create_agent(step_name)
|
||||
agent.add_agent(step_name, child_agent)
|
||||
|
||||
return agent
|
||||
except Exception as e:
|
||||
if isinstance(e, (AgentCreationError, ConfigurationError)):
|
||||
raise
|
||||
raise AgentCreationError(
|
||||
f"Failed to create agent '{name}': {str(e)}"
|
||||
) from e
|
||||
|
||||
def register_agent_type(self, type_name: str, agent_class: Type[Agent]) -> None:
|
||||
"""
|
||||
Register a new agent type.
|
||||
|
||||
This method adds a new agent type to the registry, allowing it to be
|
||||
created by the factory.
|
||||
|
||||
Args:
|
||||
type_name: The name of the agent type.
|
||||
agent_class: The agent class to register.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If the agent type is already registered.
|
||||
"""
|
||||
if type_name in self.agent_types:
|
||||
raise AgentCreationError(f"Agent type '{type_name}' is already registered")
|
||||
|
||||
if not issubclass(agent_class, Agent):
|
||||
raise AgentCreationError("Agent class must be a subclass of Agent")
|
||||
|
||||
if inspect.isabstract(agent_class):
|
||||
raise AgentCreationError(
|
||||
f"Cannot register abstract agent class '{agent_class.__name__}'"
|
||||
)
|
||||
|
||||
self.agent_types[type_name] = agent_class
|
||||
|
||||
def get_agent_types(self) -> Dict[str, Type[Agent]]:
|
||||
"""
|
||||
Get the registry of agent types.
|
||||
|
||||
Returns:
|
||||
A dictionary mapping agent type names to agent classes.
|
||||
"""
|
||||
return self.agent_types.copy()
|
||||
@@ -0,0 +1,601 @@
|
||||
"""
|
||||
LLM agent module for CleverAgents.
|
||||
|
||||
This module defines the LLMAgent class, which represents an agent powered by
|
||||
a large language model (LLM) such as GPT-4, Claude, etc. It handles
|
||||
communication with different LLM providers and manages prompt formatting.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import pprint
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Mapping
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
import aiohttp
|
||||
|
||||
DEFAULT_SYSTEM_MESSAGE = "You are a helpful assistant."
|
||||
|
||||
from cleveragents.agents.base import AgentWithMemory
|
||||
from cleveragents.core.exceptions import AgentCreationError
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
from cleveragents.core.exceptions import ExecutionError
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LLMAgent(AgentWithMemory):
|
||||
"""
|
||||
Agent powered by a large language model (LLM).
|
||||
|
||||
This class represents an agent that uses a large language model (LLM) to
|
||||
generate responses. It supports different LLM providers and models, and
|
||||
handles prompt formatting and response processing.
|
||||
|
||||
API keys are resolved in the following order of precedence:
|
||||
1. A non-empty `api_key` in the agent's configuration.
|
||||
2. The corresponding environment variable (e.g., `OPENAI_API_KEY`, `ANTHROPIC_API_KEY`).
|
||||
|
||||
If a required key is not found, a `ConfigurationError` is raised.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the agent.
|
||||
config (Dict[str, Any]): The agent's configuration.
|
||||
template_renderer (TemplateRenderer): Renderer for processing templates.
|
||||
memory (Dict[str, Any]): The agent's memory/state.
|
||||
provider (str): The LLM provider (e.g., "openai", "anthropic").
|
||||
model (str): The LLM model to use.
|
||||
api_key (str): The API key for the LLM provider.
|
||||
role (str): The template for the system message/role.
|
||||
prompt (str): The template for formatting the user message.
|
||||
response (str): The template for formatting the LLM response.
|
||||
temperature (float): The temperature parameter for the LLM.
|
||||
max_tokens (int): The maximum number of tokens to generate.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, config: Dict[str, Any], template_renderer: TemplateRenderer
|
||||
):
|
||||
"""
|
||||
Initialize an LLM agent.
|
||||
|
||||
Args:
|
||||
name: The name of the agent.
|
||||
config: The agent's configuration.
|
||||
template_renderer: Renderer for processing templates.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If the agent cannot be initialized with the given configuration.
|
||||
"""
|
||||
super().__init__(name, config, template_renderer)
|
||||
|
||||
# The actual agent settings may be nested inside a "config" dictionary.
|
||||
self.agent_config = config.get("config", config)
|
||||
|
||||
# Extract configuration
|
||||
self.provider = self.agent_config.get("provider", "openai").lower()
|
||||
self.model = self.agent_config.get("model", "gpt-3.5-turbo")
|
||||
self.api_key = self._get_api_key()
|
||||
self.temperature = float(self.agent_config.get("temperature", 0.7))
|
||||
self.max_tokens = int(self.agent_config.get("max_tokens", 1000))
|
||||
# Feature flags
|
||||
self.web_search: bool = bool(self.agent_config.get("web_search", False))
|
||||
self.json_mode: Union[bool, Dict[str, Any]] = self.agent_config.get(
|
||||
"json_mode", False
|
||||
)
|
||||
|
||||
self.role: Optional[str] = None
|
||||
self.role_reference: Optional[str] = None
|
||||
self.prompt: Optional[str] = None
|
||||
self.prompt_reference: Optional[str] = None
|
||||
self.response: Optional[str] = None
|
||||
self.response_reference: Optional[str] = None
|
||||
|
||||
template_configs = [
|
||||
("role", "role_reference"),
|
||||
("prompt", "prompt_reference"),
|
||||
("response", "response_reference"),
|
||||
]
|
||||
|
||||
for inline_key, ref_key in template_configs:
|
||||
inline_val = self.agent_config.get(inline_key)
|
||||
ref_val = self.agent_config.get(ref_key)
|
||||
|
||||
if inline_val is not None and ref_val is not None:
|
||||
raise ConfigurationError(
|
||||
f"Agent '{self.name}' configuration cannot contain both "
|
||||
f"'{inline_key}' and '{ref_key}'."
|
||||
)
|
||||
|
||||
if inline_val is not None:
|
||||
setattr(self, inline_key, inline_val)
|
||||
elif ref_val is not None:
|
||||
setattr(self, ref_key, ref_val)
|
||||
|
||||
if self.role is None and self.role_reference is None:
|
||||
self.role = DEFAULT_SYSTEM_MESSAGE
|
||||
|
||||
# Initialize memory for conversation history
|
||||
self.memory["messages"] = []
|
||||
|
||||
def _get_api_key(self) -> str:
|
||||
"""
|
||||
Retrieves the API key from config or environment variables.
|
||||
|
||||
The method follows this priority:
|
||||
1. A non-empty `api_key` in the agent's configuration.
|
||||
2. The corresponding environment variable (e.g., OPENAI_API_KEY).
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If no API key can be found for a provider that requires one.
|
||||
|
||||
Returns:
|
||||
The API key string.
|
||||
"""
|
||||
config_api_key = self.agent_config.get("api_key")
|
||||
if config_api_key and config_api_key.strip():
|
||||
return config_api_key.strip()
|
||||
|
||||
provider_env_map = {
|
||||
"openai": "OPENAI_API_KEY",
|
||||
"anthropic": "ANTHROPIC_API_KEY",
|
||||
"google": "GEMINI_API_KEY",
|
||||
}
|
||||
env_var_name = provider_env_map.get(self.provider)
|
||||
|
||||
if env_var_name:
|
||||
env_api_key = os.environ.get(env_var_name)
|
||||
if env_api_key:
|
||||
return env_api_key
|
||||
|
||||
if self.provider in provider_env_map:
|
||||
raise ConfigurationError(
|
||||
f"API key for provider '{self.provider}' not found. "
|
||||
f"Please set it in the agent configuration or via the "
|
||||
f"'{env_var_name}' environment variable."
|
||||
)
|
||||
|
||||
# This case is for providers that do not require an API key.
|
||||
return ""
|
||||
|
||||
async def process(
|
||||
self, message: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message using the LLM and generate a response.
|
||||
|
||||
This implementation isolates each agent’s memory to avoid cross-pollution
|
||||
in multi-step pipelines and constructs a minimal, explicit context for
|
||||
template rendering so that unrelated objects (e.g. routers) are never
|
||||
exposed to the template engine.
|
||||
|
||||
Args:
|
||||
message: The message to process.
|
||||
context: Shared application context passed along the pipeline.
|
||||
|
||||
Returns:
|
||||
The LLM’s response.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If message processing fails.
|
||||
"""
|
||||
context = context or {}
|
||||
|
||||
# --- BEGIN: ADDED FOR DEBUGGING ---
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
logger.debug(f"--- LLMAgent '{self.name}' received message ---")
|
||||
# Log a truncated version of the message to avoid flooding the console
|
||||
logger.debug(f"MESSAGE: {message[:200]}...")
|
||||
logger.debug(f"CONTEXT KEYS: {list(context.keys())}")
|
||||
# --- END: ADDED FOR DEBUGGING ---
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Memory isolation #
|
||||
# ------------------------------------------------------------------ #
|
||||
# Each agent keeps its own conversation history inside the shared
|
||||
# context, keyed by agent name. This prevents a downstream agent from
|
||||
# accidentally loading the entire chain’s history.
|
||||
agent_memory = context.get("memory", {}).get(self.name, {})
|
||||
self.load_memory(agent_memory)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Build a clean rendering context #
|
||||
# ------------------------------------------------------------------ #
|
||||
# Start with keys from the shared context but drop internal data that
|
||||
# should not be exposed to the template engine.
|
||||
render_context: Dict[str, Any] = {
|
||||
k: v
|
||||
for k, v in context.items()
|
||||
if k not in {"_routers", "memory", "history"} and not k.startswith("_")
|
||||
}
|
||||
render_context["message"] = message
|
||||
# Provide the full shared context under the key 'context' for template access
|
||||
render_context["context"] = context
|
||||
|
||||
# --- BEGIN: ADDED FOR DEBUGGING ---
|
||||
if logger.isEnabledFor(logging.DEBUG):
|
||||
# Avoid circular references by omitting the 'context' key from display
|
||||
display_context = {
|
||||
k: v for k, v in render_context.items() if k != "context"
|
||||
}
|
||||
pretty_context = pprint.pformat(display_context, indent=2)
|
||||
logger.debug(
|
||||
f"--- LLMAgent '{self.name}' final render context ---\n{pretty_context}"
|
||||
)
|
||||
# --- END: ADDED FOR DEBUGGING ---
|
||||
|
||||
try:
|
||||
# Format the prompt with the enriched, *clean* context.
|
||||
formatted_message = self.format_prompt(message, render_context)
|
||||
|
||||
# Append the latest user message to memory.
|
||||
self.memory.setdefault("messages", []).append(
|
||||
{"role": "user", "content": formatted_message}
|
||||
)
|
||||
|
||||
# Build the messages list for this specific API call, ensuring the
|
||||
# current system message is the very first element.
|
||||
# Build the messages list for this specific API call, ensuring the
|
||||
# current system message is the very first element.
|
||||
api_messages: List[Dict[str, str]] = []
|
||||
role_content = None
|
||||
if self.role_reference:
|
||||
role_content = self.template_renderer.render(
|
||||
self.role_reference, render_context
|
||||
)
|
||||
elif self.role:
|
||||
role_content = self.template_renderer.render_string(
|
||||
self.role,
|
||||
render_context,
|
||||
source_description=f"role for agent '{self.name}'",
|
||||
)
|
||||
|
||||
if role_content:
|
||||
api_messages.append({"role": "system", "content": role_content})
|
||||
api_messages.extend(self.memory["messages"])
|
||||
|
||||
prompt_for_logging = "\n".join(
|
||||
f"[{m.get('role')}] {m.get('content')}" for m in api_messages
|
||||
)
|
||||
logger.debug(
|
||||
f"Agent '{self.name}' sending prompt:\n"
|
||||
f"---PROMPT---\n{prompt_for_logging}\n---END PROMPT---"
|
||||
)
|
||||
|
||||
# Generate a response
|
||||
if self.provider == "openai":
|
||||
response = await self._generate_openai_response(api_messages)
|
||||
elif self.provider == "anthropic":
|
||||
response = await self._generate_anthropic_response(api_messages)
|
||||
elif self.provider == "google":
|
||||
response = await self._generate_google_response(api_messages)
|
||||
else:
|
||||
raise ExecutionError(f"Unsupported LLM provider: {self.provider}")
|
||||
|
||||
# Process the response
|
||||
processed_response = self.process_response(response, render_context)
|
||||
logger.debug(
|
||||
f"Agent '{self.name}' received response:\n---RESPONSE---\n{processed_response}\n---END RESPONSE---"
|
||||
)
|
||||
|
||||
# Persist assistant response to memory
|
||||
self.memory["messages"].append(
|
||||
{"role": "assistant", "content": processed_response}
|
||||
)
|
||||
|
||||
# Make the updated memory available to downstream agents, scoped
|
||||
# by agent name to keep histories isolated.
|
||||
context.setdefault("memory", {})[self.name] = self.save_memory()
|
||||
|
||||
return processed_response
|
||||
except Exception as e:
|
||||
if isinstance(e, ExecutionError):
|
||||
raise
|
||||
raise ExecutionError(f"Failed to process message: {str(e)}") from e
|
||||
|
||||
async def _generate_openai_response(self, messages: List[Dict[str, str]]) -> str:
|
||||
"""
|
||||
Generate a response using the OpenAI API.
|
||||
|
||||
Returns:
|
||||
The generated response.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If the API call fails.
|
||||
"""
|
||||
try:
|
||||
# Prepare the request
|
||||
url = "https://api.openai.com/v1/chat/completions"
|
||||
headers: Mapping[str, str] = {
|
||||
"Content-Type": "application/json",
|
||||
"Authorization": f"Bearer {self.api_key}",
|
||||
}
|
||||
data = {
|
||||
"model": self.model,
|
||||
"messages": messages,
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens,
|
||||
}
|
||||
|
||||
# Make the request
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=data) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise ExecutionError(
|
||||
f"OpenAI API error: {response.status} - {error_text}"
|
||||
)
|
||||
|
||||
response_data = await response.json()
|
||||
|
||||
# Extract the response
|
||||
if "choices" in response_data and len(response_data["choices"]) > 0:
|
||||
return response_data["choices"][0]["message"]["content"]
|
||||
else:
|
||||
raise ExecutionError("No response from OpenAI API")
|
||||
except Exception as e:
|
||||
if isinstance(e, ExecutionError):
|
||||
raise
|
||||
raise ExecutionError(f"Failed to generate OpenAI response: {str(e)}") from e
|
||||
|
||||
async def _generate_anthropic_response(self, messages: List[Dict[str, str]]) -> str:
|
||||
"""
|
||||
Generate a response using the Anthropic API.
|
||||
|
||||
Returns:
|
||||
The generated response.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If the API call fails.
|
||||
"""
|
||||
try:
|
||||
# Prepare the request
|
||||
url = "https://api.anthropic.com/v1/messages"
|
||||
headers: Mapping[str, Any] = {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": self.api_key,
|
||||
"anthropic-version": "2023-06-01",
|
||||
}
|
||||
|
||||
# Separate system message from conversation history for Anthropic API
|
||||
system_prompt = next(
|
||||
(m["content"] for m in messages if m.get("role") == "system"), ""
|
||||
)
|
||||
conversation_messages = [m for m in messages if m.get("role") != "system"]
|
||||
|
||||
data = {
|
||||
"model": self.model,
|
||||
"system": system_prompt,
|
||||
"messages": conversation_messages,
|
||||
"max_tokens": self.max_tokens,
|
||||
"temperature": self.temperature,
|
||||
}
|
||||
|
||||
# Make the request
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(url, headers=headers, json=data) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise ExecutionError(
|
||||
f"Anthropic API error: {response.status} - {error_text}"
|
||||
)
|
||||
|
||||
response_data = await response.json()
|
||||
|
||||
# Extract the response
|
||||
if "content" in response_data and len(response_data["content"]) > 0:
|
||||
return response_data["content"][0]["text"]
|
||||
else:
|
||||
raise ExecutionError("No response from Anthropic API")
|
||||
except Exception as e:
|
||||
if isinstance(e, ExecutionError):
|
||||
raise
|
||||
raise ExecutionError(
|
||||
f"Failed to generate Anthropic response: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def _generate_google_response(self, messages: List[Dict[str, str]]) -> str:
|
||||
"""
|
||||
Generate a response using the Google Gemini API.
|
||||
|
||||
This implementation optionally enables the built-in Google Search
|
||||
retrieval tool when ``self.web_search`` is True.
|
||||
|
||||
Returns:
|
||||
The generated response text.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If the API request fails.
|
||||
"""
|
||||
url = (
|
||||
f"https://generativelanguage.googleapis.com/v1beta/models/"
|
||||
f"{self.model}:generateContent"
|
||||
)
|
||||
headers = {"Content-Type": "application/json"}
|
||||
params = {"key": self.api_key}
|
||||
|
||||
system_prompt = next(
|
||||
(m["content"] for m in messages if m.get("role") == "system"), ""
|
||||
)
|
||||
conversation_messages = [m for m in messages if m.get("role") != "system"]
|
||||
|
||||
# Map roles to Google Gemini's expected format
|
||||
role_mapping = {
|
||||
"user": "user",
|
||||
"assistant": "model",
|
||||
"model": "model" # In case it's already mapped
|
||||
}
|
||||
|
||||
contents: List[Dict[str, Any]] = []
|
||||
for msg in conversation_messages:
|
||||
original_role = msg.get("role", "user")
|
||||
gemini_role = role_mapping.get(original_role, "user")
|
||||
contents.append({
|
||||
"role": gemini_role,
|
||||
"parts": [{"text": msg["content"]}]
|
||||
})
|
||||
|
||||
data: Dict[str, Any] = {
|
||||
"contents": contents,
|
||||
"generationConfig": {
|
||||
"temperature": self.temperature,
|
||||
"maxOutputTokens": self.max_tokens,
|
||||
},
|
||||
}
|
||||
|
||||
if system_prompt:
|
||||
data["systemInstruction"] = {"parts": [{"text": system_prompt}]}
|
||||
|
||||
if self.web_search:
|
||||
data["tools"] = [{"google_search_retrieval": {}}]
|
||||
|
||||
if self.json_mode:
|
||||
data["generationConfig"]["response_mime_type"] = "application/json"
|
||||
if isinstance(self.json_mode, dict) and "schema" in self.json_mode:
|
||||
data["generationConfig"]["response_schema"] = self.json_mode["schema"]
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.post(
|
||||
url, headers=headers, params=params, json=data
|
||||
) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
raise ExecutionError(
|
||||
f"Google Gemini API error: {response.status} - {error_text}"
|
||||
)
|
||||
|
||||
response_data = await response.json()
|
||||
|
||||
if "candidates" in response_data and response_data["candidates"]:
|
||||
candidate = response_data["candidates"][0]
|
||||
|
||||
# Check for specific finish reasons that indicate issues
|
||||
finish_reason = candidate.get("finishReason", "")
|
||||
if finish_reason == "MAX_TOKENS":
|
||||
raise ExecutionError(
|
||||
"Google Gemini response was truncated due to maximum token limit. "
|
||||
"Consider reducing the input size or increasing max_tokens."
|
||||
)
|
||||
elif finish_reason in ["SAFETY", "RECITATION"]:
|
||||
raise ExecutionError(
|
||||
f"Google Gemini blocked the response due to: {finish_reason}"
|
||||
)
|
||||
|
||||
# Extract response content
|
||||
if "content" in candidate:
|
||||
content = candidate["content"]
|
||||
if "parts" in content and content["parts"]:
|
||||
parts = content["parts"]
|
||||
if "text" in parts[0]:
|
||||
return parts[0]["text"]
|
||||
|
||||
# Handle cases where content exists but parts is missing/empty
|
||||
# This can happen with certain API responses
|
||||
if "role" in content and content["role"] == "model":
|
||||
# If there's no actual text content, return empty response
|
||||
logger.warning(f"Google Gemini returned empty content with finish reason: {finish_reason}")
|
||||
return ""
|
||||
|
||||
# Log the unexpected response structure for debugging
|
||||
logger.error(f"Unexpected Google Gemini response structure: {json.dumps(response_data, indent=2)}")
|
||||
raise ExecutionError(f"Unexpected Google Gemini response structure: missing content or text")
|
||||
|
||||
# Log the full response for debugging when no candidates found
|
||||
logger.error(f"No candidates in Google Gemini response: {json.dumps(response_data, indent=2)}")
|
||||
raise ExecutionError("No response from Google Gemini API")
|
||||
except Exception as e:
|
||||
if isinstance(e, ExecutionError):
|
||||
raise
|
||||
raise ExecutionError(
|
||||
f"Failed to generate Google Gemini response: {str(e)}"
|
||||
) from e
|
||||
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""
|
||||
Get the capabilities of the LLM agent.
|
||||
|
||||
Returns:
|
||||
A list of capability identifiers.
|
||||
"""
|
||||
capabilities = ["text-generation"]
|
||||
|
||||
# Add additional capabilities based on the model
|
||||
model = self.model.lower()
|
||||
|
||||
if "gpt-4" in model or "claude" in model:
|
||||
capabilities.extend(["reasoning", "creative-writing"])
|
||||
|
||||
if "vision" in model or "-v" in model:
|
||||
capabilities.append("image-understanding")
|
||||
|
||||
if "code" in model:
|
||||
capabilities.append("code-generation")
|
||||
|
||||
return capabilities
|
||||
|
||||
def format_prompt(
|
||||
self, message: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Render the prompt using either a referenced or an inline template.
|
||||
|
||||
If no prompt template is defined via `prompt` or `prompt_reference`,
|
||||
this method returns the original message.
|
||||
"""
|
||||
render_context = context or {}
|
||||
render_context.setdefault("message", message)
|
||||
|
||||
if self.prompt_reference:
|
||||
return self.template_renderer.render(
|
||||
self.prompt_reference, render_context
|
||||
).strip()
|
||||
|
||||
if self.prompt:
|
||||
return self.template_renderer.render_string(
|
||||
self.prompt, render_context
|
||||
).strip()
|
||||
|
||||
return message
|
||||
|
||||
def process_response(
|
||||
self, response: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process the response from the LLM.
|
||||
|
||||
This method applies any necessary post-processing to the LLM's response,
|
||||
such as formatting it with a template.
|
||||
|
||||
Args:
|
||||
response: The raw response from the LLM.
|
||||
|
||||
Returns:
|
||||
The processed response.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If response processing fails.
|
||||
"""
|
||||
try:
|
||||
template_context = context or {}
|
||||
template_context["response"] = response
|
||||
|
||||
if self.response_reference:
|
||||
return self.template_renderer.render(
|
||||
self.response_reference, template_context
|
||||
)
|
||||
if self.response:
|
||||
return self.template_renderer.render_string(
|
||||
self.response,
|
||||
template_context,
|
||||
source_description=f"response for agent '{self.name}'",
|
||||
)
|
||||
return response
|
||||
except Exception as e:
|
||||
raise ExecutionError(f"Failed to process response: {str(e)}") from e
|
||||
@@ -0,0 +1,10 @@
|
||||
"""
|
||||
This module contains state management for agents.
|
||||
"""
|
||||
|
||||
|
||||
def initial_state():
|
||||
"""
|
||||
Returns the initial state for an agent.
|
||||
"""
|
||||
return {}
|
||||
@@ -0,0 +1,614 @@
|
||||
"""
|
||||
Tool agent module for CleverAgents.
|
||||
|
||||
This module defines the ToolAgent class which represents an agent that interfaces with external tools.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import importlib
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.core.exceptions import AgentCreationError
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
from cleveragents.core.exceptions import ExecutionError
|
||||
from cleveragents.core.sandbox import SAFE_BUILTINS
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Tool:
|
||||
"""
|
||||
A tool that can be used by a ToolAgent.
|
||||
|
||||
Tools provide specific functionality that agents can use to perform tasks,
|
||||
such as searching the web, performing calculations, or accessing external APIs.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the tool.
|
||||
description (str): A description of what the tool does.
|
||||
config (Dict[str, Any]): Configuration for the tool.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, description: str, config: Optional[Dict[str, Any]] = None
|
||||
):
|
||||
"""
|
||||
Initialize a new Tool instance.
|
||||
|
||||
Args:
|
||||
name: The name of the tool.
|
||||
description: A description of what the tool does.
|
||||
config: Configuration for the tool.
|
||||
"""
|
||||
self.name = name
|
||||
self.description = description
|
||||
self.config = config or {}
|
||||
|
||||
async def execute(
|
||||
self, input_data: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Execute the tool with the given input.
|
||||
|
||||
Args:
|
||||
input_data: The input data for the tool.
|
||||
context: Additional context for tool execution.
|
||||
|
||||
Returns:
|
||||
The result of executing the tool.
|
||||
|
||||
Raises:
|
||||
NotImplementedError: This method must be implemented by subclasses.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"The execute method must be implemented by subclasses."
|
||||
)
|
||||
|
||||
|
||||
class CalculatorTool(Tool):
|
||||
"""
|
||||
A tool for performing mathematical calculations.
|
||||
|
||||
This tool evaluates mathematical expressions and returns the result.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize a new CalculatorTool instance.
|
||||
"""
|
||||
super().__init__(
|
||||
name="calculator",
|
||||
description="Performs mathematical calculations. Input should be a valid mathematical expression.",
|
||||
)
|
||||
|
||||
async def execute(
|
||||
self, input_data: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Execute the calculator tool.
|
||||
|
||||
Args:
|
||||
input_data: A mathematical expression to evaluate.
|
||||
context: Additional context (not used).
|
||||
|
||||
Returns:
|
||||
The result of the calculation.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If the expression cannot be evaluated.
|
||||
"""
|
||||
try:
|
||||
# Evaluate the expression safely
|
||||
result = eval(input_data, {"__builtins__": {}}, {})
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
raise ExecutionError(f"Failed to evaluate expression: {str(e)}")
|
||||
|
||||
|
||||
class WebSearchTool(Tool):
|
||||
"""
|
||||
A tool for searching the web using Google Custom Search.
|
||||
|
||||
This tool performs web searches and returns the results. It requires a Google
|
||||
API Key and a Programmable Search Engine ID (cx). The API key can be provided
|
||||
in the configuration or via the GOOGLE_SEARCH_API_KEY environment variable.
|
||||
|
||||
Attributes:
|
||||
api_key (str): The API key for the search engine.
|
||||
search_engine (str): The search engine to use (currently only 'google').
|
||||
cx (str): The Programmable Search Engine ID.
|
||||
"""
|
||||
|
||||
code: str
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""
|
||||
Initialize a new WebSearchTool instance.
|
||||
|
||||
Args:
|
||||
config: Configuration for the tool.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If required configuration is missing.
|
||||
"""
|
||||
super().__init__(
|
||||
name="web_search",
|
||||
description="Searches the web for information using Google Custom Search. Input should be a search query.",
|
||||
config=config,
|
||||
)
|
||||
|
||||
self.search_engine = config.get("search_engine", "google")
|
||||
if self.search_engine != "google":
|
||||
raise AgentCreationError(
|
||||
f"Search engine '{self.search_engine}' is not supported. Only 'google' is available."
|
||||
)
|
||||
|
||||
self.api_key = self._get_api_key()
|
||||
# CX (Custom Search Engine ID) can be in config or from environment variable GOOGLE_CX
|
||||
self.cx = config.get("cx") or os.environ.get("GOOGLE_CX")
|
||||
|
||||
if not self.cx:
|
||||
raise AgentCreationError(
|
||||
"Google Custom Search Engine ID (cx) not found. Please provide it "
|
||||
"in the tool config or set the GOOGLE_CX environment variable."
|
||||
)
|
||||
|
||||
def _get_api_key(self) -> str:
|
||||
"""
|
||||
Retrieves the API key from config or environment variables.
|
||||
|
||||
The method follows this priority:
|
||||
1. A non-empty `api_key` in the tool's configuration.
|
||||
2. The corresponding environment variable (e.g., GOOGLE_SEARCH_API_KEY).
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If no API key can be found for a search engine that requires one.
|
||||
|
||||
Returns:
|
||||
The API key string.
|
||||
"""
|
||||
config_api_key = self.config.get("api_key")
|
||||
if config_api_key and config_api_key.strip():
|
||||
return config_api_key.strip()
|
||||
|
||||
engine_env_map = {
|
||||
"google": "GOOGLE_SEARCH_API_KEY",
|
||||
}
|
||||
env_var_name = engine_env_map.get(self.search_engine)
|
||||
|
||||
if env_var_name:
|
||||
env_api_key = os.environ.get(env_var_name)
|
||||
if env_api_key:
|
||||
return env_api_key
|
||||
|
||||
if self.search_engine in engine_env_map:
|
||||
raise ConfigurationError(
|
||||
f"API key for search engine '{self.search_engine}' not found. "
|
||||
f"Please set it in the tool configuration or via the "
|
||||
f"'{env_var_name}' environment variable."
|
||||
)
|
||||
return ""
|
||||
|
||||
async def execute(
|
||||
self, input_data: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Execute the web search tool by calling the Google Custom Search API.
|
||||
|
||||
Args:
|
||||
input_data: A search query.
|
||||
context: Additional context (not used).
|
||||
|
||||
Returns:
|
||||
The title and snippet of the first search result, or an error message.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If the search fails.
|
||||
"""
|
||||
if self.search_engine != "google":
|
||||
# This is where logic for other search engines would go.
|
||||
raise ExecutionError(
|
||||
f"Search engine '{self.search_engine}' is not supported."
|
||||
)
|
||||
|
||||
# mypy: self.api_key and self.cx are Optional[str] at the attribute level.
|
||||
# Runtime checks in __init__ guarantee they are populated, but we assert
|
||||
# here to make that explicit for static analysis.
|
||||
assert self.api_key is not None, "Google API Key is not configured."
|
||||
assert (
|
||||
self.cx is not None
|
||||
), "Google Custom Search Engine ID (cx) is not configured."
|
||||
|
||||
url = "https://www.googleapis.com/customsearch/v1"
|
||||
params = {"key": self.api_key, "cx": self.cx, "q": input_data}
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(url, params=params) as response:
|
||||
if response.status != 200:
|
||||
error_text = await response.text()
|
||||
logger.error(
|
||||
f"Google Search API returned status {response.status}: {error_text}"
|
||||
)
|
||||
raise ExecutionError(
|
||||
f"Google Search API error: Received status {response.status}. "
|
||||
"Check your API key and CX."
|
||||
)
|
||||
|
||||
data = await response.json()
|
||||
|
||||
items = data.get("items", [])
|
||||
if items:
|
||||
first_result = items[0]
|
||||
title = first_result.get("title", "No title")
|
||||
snippet = first_result.get("snippet", "No snippet available.")
|
||||
return f"{title}\n{snippet}"
|
||||
|
||||
return "No web search results found."
|
||||
except aiohttp.ClientError as e:
|
||||
logger.error(f"Failed to connect to Google Search API: {e}")
|
||||
raise ExecutionError(f"Failed to connect to Google Search API: {str(e)}")
|
||||
except Exception as e:
|
||||
logger.error(
|
||||
f"An unexpected error occurred during web search: {e}", exc_info=True
|
||||
)
|
||||
raise ExecutionError(
|
||||
f"An unexpected error occurred during web search: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
class CustomCodeTool(Tool):
|
||||
"""
|
||||
A tool that executes custom Python code from the configuration.
|
||||
|
||||
The code is provided in the agent's configuration and is executed using `exec()`.
|
||||
The executed code has access to `input_data` (the tool input) and `context`
|
||||
(the current routing context) variables. The code must assign its output to a
|
||||
variable named `result`.
|
||||
|
||||
.. warning::
|
||||
This tool executes arbitrary Python code from the configuration.
|
||||
Ensure that the code is from a trusted source, as it can execute any
|
||||
system-level operations.
|
||||
|
||||
Configuration:
|
||||
name (str): The name of the tool.
|
||||
description (str): A description of what the tool does.
|
||||
code (str): The Python code snippet to execute.
|
||||
"""
|
||||
|
||||
def __init__(self, config: Dict[str, Any]):
|
||||
"""Initializes the CustomCodeTool."""
|
||||
name = config.get("name")
|
||||
if not name:
|
||||
raise ConfigurationError("Custom code tool is missing a 'name'.")
|
||||
description = config.get(
|
||||
"description", f"Executes custom code for tool '{name}'."
|
||||
)
|
||||
super().__init__(name, description, config)
|
||||
|
||||
code_val = config.get("code")
|
||||
if not code_val or not isinstance(code_val, str):
|
||||
raise ConfigurationError(
|
||||
f"Custom code tool '{name}' is missing 'code' in its configuration or it is not a string."
|
||||
)
|
||||
self.code = code_val
|
||||
|
||||
async def execute(
|
||||
self, input_data: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Executes the custom Python code with enhanced error reporting."""
|
||||
call_context = context or {}
|
||||
execution_scope = {
|
||||
"input_data": input_data,
|
||||
"context": call_context,
|
||||
"result": None,
|
||||
}
|
||||
|
||||
unsafe_mode = call_context.get("_unsafe_mode", False)
|
||||
if unsafe_mode:
|
||||
execution_globals = globals()
|
||||
else:
|
||||
# Restricted environment: allow only safe built-ins and a few
|
||||
# utility modules required by typical configuration snippets.
|
||||
execution_globals = {
|
||||
"__builtins__": SAFE_BUILTINS,
|
||||
"json": json,
|
||||
"re": re,
|
||||
"os": os,
|
||||
}
|
||||
|
||||
filename = f"<CustomCodeTool: {self.name}>"
|
||||
try:
|
||||
# Pre-compile to embed a helpful “filename” into any traceback.
|
||||
compiled_code = compile(self.code, filename, "exec")
|
||||
exec(compiled_code, execution_globals, execution_scope)
|
||||
result_val = execution_scope.get("result")
|
||||
return str(result_val) if result_val is not None else ""
|
||||
except Exception as e:
|
||||
# Build a richly formatted code excerpt with line numbers and, for
|
||||
# SyntaxError, a caret pointing to the offending column.
|
||||
code_with_lines_list = []
|
||||
for i, line in enumerate(self.code.splitlines()):
|
||||
code_with_lines_list.append(f"{i + 1: >4}: {line}")
|
||||
if isinstance(e, SyntaxError) and e.lineno == i + 1:
|
||||
if e.offset is not None:
|
||||
pointer = " " * (e.offset - 1) + "^"
|
||||
code_with_lines_list.append(f" {pointer}")
|
||||
|
||||
code_with_lines = "\n".join(code_with_lines_list)
|
||||
|
||||
detailed_msg = (
|
||||
f"Error executing custom code for tool '{self.name}': {e}\n"
|
||||
f"--- Failing Code in {filename} ---\n"
|
||||
f"{code_with_lines}\n"
|
||||
f"--- End of Code ---"
|
||||
)
|
||||
|
||||
logger.error(detailed_msg, exc_info=True)
|
||||
raise ExecutionError(detailed_msg) from e
|
||||
|
||||
|
||||
class ToolAgent(Agent):
|
||||
"""
|
||||
ToolAgent represents an agent that interfaces with external tools.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the agent.
|
||||
config (Dict[str, Any]): The agent's configuration.
|
||||
template_renderer (TemplateRenderer): Renderer for processing templates.
|
||||
tools (Dict[str, Tool]): A dictionary of tools the agent can use.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, config: Dict[str, Any], template_renderer: TemplateRenderer
|
||||
):
|
||||
"""
|
||||
Initialize a new ToolAgent instance.
|
||||
|
||||
Args:
|
||||
name: The name of the agent.
|
||||
config: The agent's configuration.
|
||||
template_renderer: Renderer for processing templates.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If the agent cannot be initialized with the given configuration.
|
||||
"""
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.tools: Dict[str, Tool] = {}
|
||||
self._initialize_tools()
|
||||
|
||||
def _initialize_tools(self) -> None:
|
||||
"""
|
||||
Initialize the tools based on the agent's configuration.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If a tool cannot be initialized.
|
||||
"""
|
||||
# Defensively find the 'tools' list. This handles cases where the factory
|
||||
# might pass the entire agent definition from YAML as the config object,
|
||||
# instead of just the 'config' sub-dictionary.
|
||||
agent_config = self.config.get("config", self.config)
|
||||
if not isinstance(agent_config, dict):
|
||||
agent_config = {}
|
||||
tool_configs = agent_config.get("tools", [])
|
||||
|
||||
for tool_config in tool_configs:
|
||||
try:
|
||||
if isinstance(tool_config, str):
|
||||
# Handle string-based configurations that might be JSON, a Python
|
||||
# literal, or even a doubly-encoded JSON string. We progressively
|
||||
# attempt to parse the value, falling back gracefully so that
|
||||
# downstream validation can raise clear errors when needed.
|
||||
parsed_config = None
|
||||
try:
|
||||
# First, try straightforward JSON decoding.
|
||||
parsed_config = json.loads(tool_config)
|
||||
except json.JSONDecodeError:
|
||||
try:
|
||||
import ast
|
||||
|
||||
# Next, attempt to evaluate the string as a Python literal.
|
||||
evaluated = ast.literal_eval(tool_config)
|
||||
# If the literal is itself a string, it may still be JSON
|
||||
# encoded—handle that case as well.
|
||||
if isinstance(evaluated, str):
|
||||
parsed_config = json.loads(evaluated)
|
||||
else:
|
||||
parsed_config = evaluated
|
||||
except (ValueError, SyntaxError, json.JSONDecodeError):
|
||||
# If all parsing attempts fail, leave parsed_config as None
|
||||
# and allow the subsequent validation logic to surface an
|
||||
# appropriate ConfigurationError.
|
||||
pass
|
||||
if parsed_config is not None:
|
||||
tool_config = parsed_config
|
||||
|
||||
if not isinstance(tool_config, dict) or "name" not in tool_config:
|
||||
raise ConfigurationError(
|
||||
"Each tool configuration must be a dictionary with a 'name' key. "
|
||||
f"Invalid configuration found: {tool_config}"
|
||||
)
|
||||
|
||||
tool_name = tool_config.get("name")
|
||||
if not tool_name or not isinstance(tool_name, str):
|
||||
raise ConfigurationError(
|
||||
"Tool 'name' must be a non-empty string. "
|
||||
f"Invalid configuration found: {tool_config}"
|
||||
)
|
||||
|
||||
config_data = tool_config
|
||||
|
||||
# Check if this is a built-in tool
|
||||
if tool_name == "calculator":
|
||||
self.tools[tool_name] = CalculatorTool()
|
||||
logger.debug(f"Initialized calculator tool for agent '{self.name}'")
|
||||
elif tool_name == "web_search":
|
||||
# Extract the nested configuration block if present
|
||||
tool_specific_config = config_data.get("config", config_data)
|
||||
self.tools[tool_name] = WebSearchTool(tool_specific_config)
|
||||
logger.debug(f"Initialized web search tool for agent '{self.name}'")
|
||||
elif "code" in config_data:
|
||||
self.tools[tool_name] = CustomCodeTool(config_data)
|
||||
logger.debug(
|
||||
f"Initialized custom code tool '{tool_name}' for agent '{self.name}'"
|
||||
)
|
||||
elif "module" in config_data:
|
||||
# Try to load a custom tool defined in an external module
|
||||
module_name = config_data.get("module")
|
||||
if not isinstance(module_name, str) or not module_name:
|
||||
raise ConfigurationError(
|
||||
f"Custom tool '{tool_name}' 'module' must be a non-empty string."
|
||||
)
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
# Convert snake_case tool names to CamelCase to find the correct class
|
||||
class_name = (
|
||||
"".join(word.capitalize() for word in tool_name.split("_"))
|
||||
+ "Tool"
|
||||
)
|
||||
tool_class = getattr(module, class_name)
|
||||
custom_tool = tool_class(config_data)
|
||||
self.tools[tool_name] = custom_tool
|
||||
logger.debug(
|
||||
f"Initialized custom tool '{tool_name}' for agent '{self.name}'"
|
||||
)
|
||||
except (ImportError, AttributeError) as e:
|
||||
raise ConfigurationError(
|
||||
f"Failed to load tool '{tool_name}' from module '{module_name}': {str(e)}"
|
||||
)
|
||||
else:
|
||||
# Neither built-in nor properly configured custom tool
|
||||
raise ConfigurationError(
|
||||
f"Tool '{tool_name}' is not a built-in tool and is missing a 'code' or 'module' definition."
|
||||
)
|
||||
except Exception as e:
|
||||
# Allow configuration-related issues to propagate so that callers
|
||||
# (and tests) can handle them explicitly. Wrap all other
|
||||
# unexpected errors in an AgentCreationError for context.
|
||||
if isinstance(e, ConfigurationError):
|
||||
raise
|
||||
raise AgentCreationError(f"Failed to initialize tool: {str(e)}") from e
|
||||
|
||||
async def process(
|
||||
self, message: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message using the tool agent capabilities.
|
||||
|
||||
Args:
|
||||
message: The message to process.
|
||||
context: Optional context for processing.
|
||||
|
||||
Returns:
|
||||
A response string after tool processing.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If message processing fails.
|
||||
"""
|
||||
if not self.tools:
|
||||
raise ExecutionError(
|
||||
"ToolAgent has no tools configured and cannot process messages."
|
||||
)
|
||||
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
try:
|
||||
# Parse the message to determine which tool to use
|
||||
tool_name, tool_input = self._parse_tool_request(message)
|
||||
|
||||
# Check if the tool exists
|
||||
if tool_name not in self.tools:
|
||||
available_tools = ", ".join(self.tools.keys())
|
||||
return (
|
||||
f"Tool '{tool_name}' not found. Available tools: {available_tools}"
|
||||
)
|
||||
|
||||
# Execute the tool
|
||||
tool = self.tools[tool_name]
|
||||
result = await tool.execute(tool_input, context)
|
||||
|
||||
return result
|
||||
except Exception as e:
|
||||
raise ExecutionError(f"Failed to process message: {str(e)}")
|
||||
|
||||
def _parse_tool_request(self, message: str) -> tuple:
|
||||
"""
|
||||
Parse a message to determine which tool to use and what input to provide.
|
||||
|
||||
Args:
|
||||
message: The message to parse.
|
||||
|
||||
Returns:
|
||||
A tuple of (tool_name, tool_input).
|
||||
|
||||
Raises:
|
||||
ExecutionError: If the message cannot be parsed.
|
||||
"""
|
||||
try:
|
||||
# Check for tool(input) format
|
||||
match = re.match(r"(\w+)\s*\((.*)\)\s*$", message.strip())
|
||||
if match:
|
||||
tool_name, tool_input = match.groups()
|
||||
return tool_name, tool_input
|
||||
|
||||
# Check if the message is in JSON format
|
||||
try:
|
||||
data = json.loads(message, strict=False)
|
||||
if isinstance(data, dict) and "tool" in data and "input" in data:
|
||||
return data["tool"], data["input"]
|
||||
except json.JSONDecodeError:
|
||||
pass
|
||||
|
||||
# Check if the message is in the format "tool: input"
|
||||
if ":" in message:
|
||||
parts = message.split(":", 1)
|
||||
return parts[0].strip(), parts[1].strip()
|
||||
|
||||
# If there's only one tool, use it
|
||||
if len(self.tools) == 1:
|
||||
tool_name = next(iter(self.tools.keys()))
|
||||
return tool_name, message
|
||||
|
||||
# Try to infer the tool from the message
|
||||
for tool_name, tool in self.tools.items():
|
||||
if tool_name.lower() in message.lower():
|
||||
# Extract the input (everything after the tool name)
|
||||
tool_input = message[
|
||||
message.lower().find(tool_name.lower()) + len(tool_name) :
|
||||
].strip()
|
||||
return tool_name, tool_input
|
||||
|
||||
# Default to the first tool
|
||||
tool_name = next(iter(self.tools.keys()))
|
||||
return tool_name, message
|
||||
except Exception as e:
|
||||
raise ExecutionError(f"Failed to parse tool request: {str(e)}")
|
||||
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""
|
||||
Get the capabilities of the tool agent.
|
||||
|
||||
Returns:
|
||||
A list of capability identifiers.
|
||||
"""
|
||||
capabilities = ["tool-execution"]
|
||||
|
||||
# Add capabilities based on the available tools
|
||||
for tool_name in self.tools:
|
||||
capabilities.append(f"tool-{tool_name}")
|
||||
|
||||
return capabilities
|
||||
@@ -0,0 +1,214 @@
|
||||
"""
|
||||
Module that contains the command line app.
|
||||
|
||||
This module defines the command-line interface for the CleverAgents application.
|
||||
It uses Click to create a user-friendly CLI with various commands and options
|
||||
for running agent networks in both single-shot and interactive modes.
|
||||
|
||||
Why does this file exist, and why not put this in __main__?
|
||||
You might be tempted to import things from __main__ later, but that will cause
|
||||
problems: the code will get executed twice:
|
||||
|
||||
- When you run `python -mcleveragents` python will execute
|
||||
``__main__.py`` as a script. That means there won't be any
|
||||
``cleveragents.__main__`` in ``sys.modules``.
|
||||
- When you import __main__ it will get executed again (as a module) because
|
||||
there's no ``cleveragents.__main__`` in ``sys.modules``.
|
||||
|
||||
Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from cleveragents.core.application import CleverAgentsApp
|
||||
from cleveragents.core.exceptions import CleverAgentsException
|
||||
from cleveragents.core.exceptions import UnsafeConfigurationError
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option()
|
||||
def main():
|
||||
"""CleverAgents - An Agent Based LLM tool for creating agent networks."""
|
||||
pass
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--config",
|
||||
"-c",
|
||||
multiple=True,
|
||||
type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path),
|
||||
help="Path to one or more YAML configuration files.",
|
||||
)
|
||||
@click.option("--prompt", "-p", help="The initial prompt to send to the agent network.")
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=Path),
|
||||
help="Optional file to write the output to.",
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
is_flag=True,
|
||||
help="Enable verbose output showing agent interactions.",
|
||||
)
|
||||
@click.option(
|
||||
"--route",
|
||||
"-r",
|
||||
"route",
|
||||
default=None,
|
||||
help="The name of the route to use.",
|
||||
)
|
||||
@click.option(
|
||||
"--unsafe",
|
||||
"-u",
|
||||
is_flag=True,
|
||||
help="Enable unsafe mode to run code from configuration with full privileges.",
|
||||
)
|
||||
def run(
|
||||
config: List[Path],
|
||||
prompt: str,
|
||||
output: Optional[Path],
|
||||
verbose: bool,
|
||||
route: Optional[str],
|
||||
unsafe: bool,
|
||||
):
|
||||
"""
|
||||
Run the agent network in single-shot mode.
|
||||
|
||||
This command processes a single prompt through the configured agent network
|
||||
and returns the final output. The network is defined by one or more YAML
|
||||
configuration files.
|
||||
"""
|
||||
try:
|
||||
app = CleverAgentsApp(config, verbose, unsafe)
|
||||
result = asyncio.run(app.run_single_shot(prompt, route_name=route))
|
||||
except UnsafeConfigurationError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
if output:
|
||||
with output.open("w") as f:
|
||||
f.write(result)
|
||||
click.echo(f"Output written to {output}")
|
||||
else:
|
||||
click.echo(result)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--config",
|
||||
"-c",
|
||||
multiple=True,
|
||||
type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path),
|
||||
help="Path to one or more YAML configuration files.",
|
||||
)
|
||||
@click.option(
|
||||
"--history",
|
||||
"-h",
|
||||
type=click.Path(file_okay=True, dir_okay=False, path_type=Path),
|
||||
help="Optional file to load/save conversation history.",
|
||||
)
|
||||
@click.option(
|
||||
"--verbose",
|
||||
"-v",
|
||||
is_flag=True,
|
||||
help="Enable verbose output showing agent interactions.",
|
||||
)
|
||||
@click.option(
|
||||
"--route",
|
||||
"-r",
|
||||
"route",
|
||||
default=None,
|
||||
help="The initial route to use for the session.",
|
||||
)
|
||||
@click.option(
|
||||
"--unsafe",
|
||||
"-u",
|
||||
is_flag=True,
|
||||
help="Enable unsafe mode to run code from configuration with full privileges.",
|
||||
)
|
||||
def interactive(
|
||||
config: List[Path],
|
||||
history: Optional[Path],
|
||||
verbose: bool,
|
||||
route: Optional[str],
|
||||
unsafe: bool,
|
||||
):
|
||||
"""
|
||||
Start an interactive session with the agent network.
|
||||
|
||||
This command starts an interactive chat session with the configured agent
|
||||
network. The session maintains conversation history and allows for
|
||||
configuration changes during runtime.
|
||||
"""
|
||||
try:
|
||||
app = CleverAgentsApp(config, verbose, unsafe)
|
||||
asyncio.run(
|
||||
app.start_interactive_session(history_file=history, route_name=route)
|
||||
)
|
||||
except UnsafeConfigurationError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(file_okay=False, dir_okay=True, writable=True, path_type=Path),
|
||||
default="./examples",
|
||||
help="Directory to write example configuration files to.",
|
||||
)
|
||||
def generate_examples(output: Path):
|
||||
"""
|
||||
Generate example configuration files.
|
||||
|
||||
This command creates a set of example configuration files demonstrating
|
||||
various features of CleverAgents, including agent definitions, prompt
|
||||
templates, and routing configurations.
|
||||
"""
|
||||
raise NotImplementedError("Command 'generate_examples' is not implemented yet.")
|
||||
|
||||
|
||||
@main.command()
|
||||
@click.option(
|
||||
"--config",
|
||||
"-c",
|
||||
multiple=True,
|
||||
type=click.Path(exists=True, file_okay=True, dir_okay=False, path_type=Path),
|
||||
help="Path to one or more YAML configuration files.",
|
||||
)
|
||||
@click.option(
|
||||
"--output",
|
||||
"-o",
|
||||
type=click.Path(file_okay=True, dir_okay=False, writable=True, path_type=Path),
|
||||
help="Path to write the visualization file to.",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"-f",
|
||||
type=click.Choice(["dot", "png", "svg", "pdf"]),
|
||||
default="svg",
|
||||
help="Output format for the visualization.",
|
||||
)
|
||||
def visualize(config: List[Path], output: Optional[Path], format: str):
|
||||
"""
|
||||
Visualize the agent network.
|
||||
|
||||
This command generates a visualization of the agent network defined in the
|
||||
configuration files, showing how agents are connected and how data flows
|
||||
between them.
|
||||
"""
|
||||
raise NotImplementedError("Command 'visualize' is not implemented yet.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,376 @@
|
||||
"""
|
||||
Core application module for CleverAgents.
|
||||
|
||||
This module contains the main application class that orchestrates the entire
|
||||
agent network, handling configuration loading, agent creation, and execution
|
||||
in both single-shot and interactive modes.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Type
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.agents.chain import ChainAgent
|
||||
from cleveragents.agents.composite import CompositeAgent
|
||||
from cleveragents.agents.factory import AgentFactory
|
||||
from cleveragents.agents.llm import LLMAgent
|
||||
from cleveragents.agents.tool import ToolAgent
|
||||
from cleveragents.core.config import ConfigurationManager
|
||||
from cleveragents.core.exceptions import AgentCreationError
|
||||
from cleveragents.core.exceptions import CleverAgentsException
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
from cleveragents.core.exceptions import UnsafeConfigurationError
|
||||
from cleveragents.interactive.session import InteractiveSession
|
||||
from cleveragents.routing.graph_builder import GraphBuilder
|
||||
from cleveragents.routing.router import Router
|
||||
from cleveragents.templates.renderer import TemplateEngine
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
|
||||
class CleverAgentsApp:
|
||||
"""
|
||||
Main application class for CleverAgents.
|
||||
|
||||
This class serves as the central orchestrator for the entire application,
|
||||
managing configuration, agent creation, and execution flow. It provides
|
||||
methods for both single-shot and interactive operation modes.
|
||||
|
||||
Attributes:
|
||||
config_manager (ConfigurationManager): Manages loading and validation of configuration.
|
||||
agent_factory (AgentFactory): Creates agent instances based on configuration.
|
||||
graph_builder (GraphBuilder): Builds the agent interaction graph.
|
||||
logger (logging.Logger): Logger instance for the application.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_files: Optional[List[Path]] = None,
|
||||
verbose: bool = False,
|
||||
unsafe: bool = False,
|
||||
):
|
||||
"""
|
||||
Initialize the CleverAgents application.
|
||||
|
||||
Args:
|
||||
config_files: List of paths to configuration files.
|
||||
verbose: Whether to enable verbose logging.
|
||||
|
||||
Raises:
|
||||
CleverAgentsException: If configuration loading fails.
|
||||
"""
|
||||
# Set up logging
|
||||
log_level = logging.DEBUG if verbose else logging.INFO
|
||||
logging.basicConfig(
|
||||
level=log_level,
|
||||
format="[%(name)s] %(message)s",
|
||||
stream=sys.stderr,
|
||||
)
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.unsafe = unsafe
|
||||
|
||||
# Initialize components
|
||||
self.config_manager = ConfigurationManager()
|
||||
self.agent_factory: Optional[AgentFactory] = None
|
||||
self.graph_builder: Optional[GraphBuilder] = None
|
||||
# Extended state for multi-router support
|
||||
self.agents: Dict[str, Any] = {}
|
||||
self.routers: Dict[str, Router] = {}
|
||||
self.default_router_name: Optional[str] = None
|
||||
|
||||
# Load configuration if provided
|
||||
if config_files:
|
||||
self.load_configuration(config_files)
|
||||
|
||||
# ------------------------------------------------------------------ #
|
||||
# Unsafe-mode enforcement #
|
||||
# ------------------------------------------------------------------ #
|
||||
def _enforce_unsafe_flag(self) -> None:
|
||||
"""
|
||||
Abort execution if the loaded configuration *requires* unsafe mode
|
||||
(``cleveragents.unsafe: true``) but the application was launched
|
||||
without the ``--unsafe`` flag.
|
||||
"""
|
||||
if self.config_manager.get("cleveragents.unsafe", False) and not self.unsafe:
|
||||
raise UnsafeConfigurationError(
|
||||
"This configuration requires the '--unsafe' flag because it may "
|
||||
"execute arbitrary code with full privileges."
|
||||
)
|
||||
|
||||
def load_configuration(self, config_files: List[Path]) -> None:
|
||||
"""
|
||||
Load and validate configuration from files.
|
||||
|
||||
This method loads configuration from the provided files, validates it,
|
||||
and initializes the necessary components based on the configuration.
|
||||
|
||||
Args:
|
||||
config_files: List of paths to configuration files.
|
||||
|
||||
Raises:
|
||||
CleverAgentsException: If configuration loading or validation fails.
|
||||
"""
|
||||
try:
|
||||
# Load configuration files
|
||||
self.logger.info(f"Loading configuration from {len(config_files)} files")
|
||||
self.config_manager.load_files(config_files)
|
||||
|
||||
# Validate configuration
|
||||
self.logger.info("Validating configuration")
|
||||
self.config_manager.validate()
|
||||
|
||||
template_engine_name = self.config_manager.get(
|
||||
"cleveragents.template_engine",
|
||||
"JINJA2",
|
||||
)
|
||||
template_engine = TemplateEngine[template_engine_name.upper()]
|
||||
|
||||
template_renderer = TemplateRenderer(template_engine)
|
||||
|
||||
# Double check the expected engine was loaded, if not throw an error.
|
||||
requested_engine = template_engine
|
||||
loaded_engine = getattr(
|
||||
template_renderer,
|
||||
"engine_type", # Preferred attribute
|
||||
None,
|
||||
)
|
||||
if requested_engine is not loaded_engine or loaded_engine is None:
|
||||
raise CleverAgentsException(
|
||||
f"Requested template engine '{requested_engine.name}' but it "
|
||||
"failed to initialise, so the system fell back to SIMPLE. "
|
||||
"Please install the required dependency (e.g. `pip install jinja2`) "
|
||||
"or change `template_engine` to 'simple' in your configuration."
|
||||
)
|
||||
|
||||
# Load templates from configuration
|
||||
templates = self.config_manager.get("prompts", {})
|
||||
for name, template_def in templates.items():
|
||||
if isinstance(template_def, dict) and "content" in template_def:
|
||||
template_content = template_def["content"]
|
||||
elif isinstance(template_def, str):
|
||||
template_content = template_def
|
||||
else:
|
||||
continue
|
||||
|
||||
template_renderer.register_template(name, template_content)
|
||||
|
||||
# Initialize agent factory
|
||||
self.agent_factory = AgentFactory(
|
||||
self.config_manager.to_dict(), template_renderer
|
||||
)
|
||||
|
||||
# Initialize graph builder
|
||||
self.graph_builder = GraphBuilder()
|
||||
|
||||
self.logger.info("Configuration loaded successfully")
|
||||
except Exception as e:
|
||||
if isinstance(e, CleverAgentsException):
|
||||
raise
|
||||
else:
|
||||
raise CleverAgentsException(f"Failed to load configuration: {str(e)}")
|
||||
|
||||
async def run_single_shot(
|
||||
self, prompt: str, route_name: Optional[str] = None
|
||||
) -> str:
|
||||
"""
|
||||
Run the agent network in single-shot mode.
|
||||
|
||||
This method processes a single prompt through the configured agent network
|
||||
and returns the final output. The network must be configured before calling
|
||||
this method.
|
||||
|
||||
Args:
|
||||
prompt: The initial prompt to send to the agent network.
|
||||
|
||||
Returns:
|
||||
The final output from the agent network.
|
||||
|
||||
Raises:
|
||||
CleverAgentsException: If the agent network is not configured or execution fails.
|
||||
"""
|
||||
self._enforce_unsafe_flag()
|
||||
self._initialize_agents_and_routers()
|
||||
|
||||
try:
|
||||
self.logger.info("Running in single-shot mode")
|
||||
target_route_name = route_name or self.default_router_name
|
||||
if not target_route_name:
|
||||
raise CleverAgentsException(
|
||||
"No route specified and no default route configured."
|
||||
)
|
||||
router = self.routers.get(target_route_name)
|
||||
if router is None:
|
||||
raise CleverAgentsException(f"Route '{target_route_name}' not found.")
|
||||
|
||||
# Get global context
|
||||
context = self.config_manager.get("context.global", {})
|
||||
context["_unsafe_mode"] = self.unsafe
|
||||
|
||||
# Inject all routers into the context so CompositeAgents can find them.
|
||||
context["_routers"] = self.routers
|
||||
|
||||
# Process the prompt
|
||||
self.logger.info(f"Processing prompt via route '{target_route_name}'")
|
||||
result = await router.process_message(prompt, context)
|
||||
|
||||
self.logger.info("Processing complete")
|
||||
return result
|
||||
except Exception as e:
|
||||
if isinstance(e, CleverAgentsException):
|
||||
raise
|
||||
else:
|
||||
raise CleverAgentsException(
|
||||
f"Failed to run in single-shot mode: {str(e)}"
|
||||
)
|
||||
|
||||
async def start_interactive_session(
|
||||
self,
|
||||
history_file: Optional[Path] = None,
|
||||
route_name: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Start an interactive session with the agent network.
|
||||
|
||||
This method starts an interactive chat session with the configured agent
|
||||
network. The session maintains conversation history and allows for
|
||||
configuration changes during runtime.
|
||||
|
||||
Args:
|
||||
history_file: Optional path to a file to load/save conversation history.
|
||||
|
||||
Raises:
|
||||
CleverAgentsException: If the agent network is not configured or session initialization fails.
|
||||
"""
|
||||
# Ensure the user allowed unsafe execution if required
|
||||
self._enforce_unsafe_flag()
|
||||
# Ensure routers are ready
|
||||
self._initialize_agents_and_routers()
|
||||
|
||||
try:
|
||||
self.logger.info("Starting interactive session")
|
||||
initial_route_name = route_name or self.default_router_name
|
||||
if initial_route_name not in self.routers:
|
||||
raise CleverAgentsException(f"Route '{initial_route_name}' not found.")
|
||||
|
||||
# Get global context and inject routers so CompositeAgents can find them
|
||||
context = self.config_manager.get("context.global", {})
|
||||
context["_unsafe_mode"] = self.unsafe
|
||||
context["_routers"] = self.routers
|
||||
|
||||
# Create interactive session with multi-router awareness
|
||||
session = InteractiveSession(
|
||||
routers=self.routers,
|
||||
initial_route_name=initial_route_name,
|
||||
history_file=history_file,
|
||||
verbose=self.logger.level == logging.DEBUG,
|
||||
initial_context=context,
|
||||
)
|
||||
|
||||
# Start session
|
||||
await session.run()
|
||||
except Exception as e:
|
||||
if isinstance(e, CleverAgentsException):
|
||||
raise
|
||||
else:
|
||||
raise CleverAgentsException(
|
||||
f"Failed to start interactive session: {str(e)}"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
# Internal helpers #
|
||||
# --------------------------------------------------------------------- #
|
||||
|
||||
def _initialize_agents_and_routers(self) -> None:
|
||||
"""
|
||||
Lazily create agents and routers
|
||||
"""
|
||||
if self.routers:
|
||||
return # Already initialised
|
||||
|
||||
if not self.agent_factory:
|
||||
raise AgentCreationError("Unknown agent type: None")
|
||||
# Get agent and route configurations to check for name collisions
|
||||
agent_configs = self.config_manager.get("agents", {})
|
||||
named_routes: Dict[str, List[Any]] = self.config_manager.get("routes", {})
|
||||
|
||||
agent_names = set(agent_configs.keys())
|
||||
route_names = set(named_routes.keys())
|
||||
intersection = agent_names.intersection(route_names)
|
||||
if intersection:
|
||||
raise ConfigurationError(
|
||||
"Agent and route names must be unique. The following names are "
|
||||
f"used for both: {', '.join(sorted(list(intersection)))}"
|
||||
)
|
||||
|
||||
# Register built-in agent types if they are not already registered
|
||||
registered_types = self.agent_factory.get_agent_types()
|
||||
type_map: Dict[str, Type[Agent]] = {
|
||||
"llm": LLMAgent,
|
||||
"tool": ToolAgent,
|
||||
"chain": ChainAgent,
|
||||
"composite": CompositeAgent,
|
||||
}
|
||||
for type_name, agent_class in type_map.items():
|
||||
if type_name not in registered_types:
|
||||
self.agent_factory.register_agent_type(type_name, agent_class)
|
||||
|
||||
# Build agents
|
||||
for agent_name in agent_configs:
|
||||
self.agents[agent_name] = self.agent_factory.create_agent(agent_name)
|
||||
|
||||
# Determine configuration style
|
||||
if named_routes:
|
||||
# New style – multiple named routes
|
||||
self.default_router_name = self.config_manager.get(
|
||||
"cleveragents.default_router", next(iter(named_routes))
|
||||
)
|
||||
for route_name, rules in named_routes.items():
|
||||
router = Router(
|
||||
route_name, self.agents, self.agent_factory.template_renderer
|
||||
)
|
||||
router.add_routes(rules)
|
||||
self.routers[route_name] = router
|
||||
else:
|
||||
raise ConfigurationError(
|
||||
"No routes section defined in configuration, aborting"
|
||||
)
|
||||
|
||||
# --------------------------------------------------------------------- #
|
||||
def visualize_network(self, output_file: Path, format: str = "svg") -> None:
|
||||
"""
|
||||
Generate a visualization of the agent network.
|
||||
|
||||
This method creates a visual representation of the agent network,
|
||||
showing how agents are connected and how data flows between them.
|
||||
|
||||
Args:
|
||||
output_file: Path to write the visualization file to.
|
||||
format: Output format for the visualization (dot, png, svg, pdf).
|
||||
|
||||
Raises:
|
||||
CleverAgentsException: If the agent network is not configured or visualization fails.
|
||||
"""
|
||||
raise NotImplementedError("Method 'visualize_network' is not implemented yet.")
|
||||
|
||||
def generate_example_configurations(self, output_dir: Path) -> None:
|
||||
"""
|
||||
Generate example configuration files.
|
||||
|
||||
This method creates a set of example configuration files demonstrating
|
||||
various features of CleverAgents, including agent definitions, prompt
|
||||
templates, and routing configurations.
|
||||
|
||||
Args:
|
||||
output_dir: Directory to write example configuration files to.
|
||||
|
||||
Raises:
|
||||
CleverAgentsException: If example generation fails.
|
||||
"""
|
||||
raise NotImplementedError(
|
||||
"Method 'generate_example_configurations' is not implemented yet."
|
||||
)
|
||||
@@ -0,0 +1,320 @@
|
||||
"""
|
||||
Configuration management module for CleverAgents.
|
||||
|
||||
This module handles loading, validating, and managing configuration for the
|
||||
CleverAgents application. It supports loading configuration from multiple YAML
|
||||
files and provides a unified interface for accessing configuration values.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
import yaml # type: ignore
|
||||
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
|
||||
|
||||
class ConfigurationManager:
|
||||
"""
|
||||
Manages configuration for the CleverAgents application.
|
||||
|
||||
This class is responsible for loading configuration from YAML files,
|
||||
validating the configuration, and providing access to configuration values.
|
||||
It supports environment variable interpolation and merging of multiple
|
||||
configuration files.
|
||||
|
||||
Attributes:
|
||||
config (Dict[str, Any]): The complete configuration dictionary.
|
||||
schema_validator (SchemaValidator): Validator for configuration schema.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the ConfigurationManager.
|
||||
|
||||
Sets up an empty configuration and initializes the schema validator.
|
||||
"""
|
||||
self.config = {}
|
||||
self.schema_validator = SchemaValidator()
|
||||
|
||||
def load_files(self, config_files: List[Path]) -> None:
|
||||
"""
|
||||
Load configuration from multiple YAML files.
|
||||
|
||||
This method loads configuration from the provided files in order,
|
||||
merging them into a single configuration dictionary. Later files
|
||||
override values from earlier files.
|
||||
|
||||
Args:
|
||||
config_files: List of paths to configuration files.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If a file cannot be loaded or parsed.
|
||||
"""
|
||||
merged_config: Dict[str, Any] = {}
|
||||
|
||||
for file_path in config_files:
|
||||
try:
|
||||
with open(file_path, "r") as f:
|
||||
file_config = yaml.safe_load(f)
|
||||
|
||||
if file_config is None:
|
||||
continue
|
||||
|
||||
if not isinstance(file_config, dict):
|
||||
raise ConfigurationError(
|
||||
f"Configuration file must contain a YAML dictionary: {file_path.name}"
|
||||
)
|
||||
|
||||
merged_config = self._deep_merge(merged_config, file_config)
|
||||
except yaml.YAMLError as e:
|
||||
raise ConfigurationError(
|
||||
f"Failed to parse YAML file {file_path.name}: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
if isinstance(e, ConfigurationError):
|
||||
raise
|
||||
else:
|
||||
raise ConfigurationError(
|
||||
f"Failed to load configuration file {file_path.name}: {str(e)}"
|
||||
)
|
||||
|
||||
merged_config = self.interpolate_env_vars(merged_config)
|
||||
self.config = merged_config
|
||||
|
||||
def _deep_merge(
|
||||
self, dict1: Dict[str, Any], dict2: Dict[str, Any]
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Deep merge two dictionaries.
|
||||
|
||||
This method recursively merges dict2 into dict1, with dict2 values taking precedence.
|
||||
|
||||
Args:
|
||||
dict1: First dictionary.
|
||||
dict2: Second dictionary.
|
||||
|
||||
Returns:
|
||||
Merged dictionary.
|
||||
"""
|
||||
result = dict1.copy()
|
||||
for key, value in dict2.items():
|
||||
if (
|
||||
key in result
|
||||
and isinstance(result[key], dict)
|
||||
and isinstance(value, dict)
|
||||
):
|
||||
result[key] = self._deep_merge(result[key], value)
|
||||
else:
|
||||
result[key] = value
|
||||
return result
|
||||
|
||||
def validate(self) -> None:
|
||||
"""
|
||||
Validate the loaded configuration.
|
||||
|
||||
This method checks that the configuration is valid according to the
|
||||
expected schema and contains all required values.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If the configuration is invalid.
|
||||
"""
|
||||
try:
|
||||
self.schema_validator.validate(self.config)
|
||||
except ConfigurationError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise ConfigurationError(f"Configuration validation failed: {str(e)}")
|
||||
|
||||
def get(self, path: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get a configuration value by path.
|
||||
|
||||
This method retrieves a configuration value using a dot-notation path.
|
||||
For example, 'agents.researcher.model' would retrieve the model
|
||||
configuration for the researcher agent.
|
||||
|
||||
Args:
|
||||
path: Dot-notation path to the configuration value.
|
||||
default: Value to return if the path does not exist.
|
||||
|
||||
Returns:
|
||||
The configuration value at the specified path, or the default value
|
||||
if the path does not exist.
|
||||
"""
|
||||
parts = path.split(".") if path else []
|
||||
current = self.config
|
||||
for part in parts:
|
||||
if not isinstance(current, dict) or part not in current:
|
||||
return default
|
||||
current = current[part]
|
||||
return current
|
||||
|
||||
def set(self, path: str, value: Any) -> None:
|
||||
"""
|
||||
Set a configuration value by path.
|
||||
|
||||
This method sets a configuration value using a dot-notation path.
|
||||
For example, 'agents.researcher.temperature' would set the temperature
|
||||
configuration for the researcher agent.
|
||||
|
||||
Args:
|
||||
path: Dot-notation path to the configuration value.
|
||||
value: The value to set.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If the path is invalid or the value is of the wrong type.
|
||||
"""
|
||||
if not path:
|
||||
raise ConfigurationError("Path cannot be empty")
|
||||
|
||||
parts = path.split(".")
|
||||
current = self.config
|
||||
for i, part in enumerate(parts[:-1]):
|
||||
if not isinstance(current, dict):
|
||||
parent_path = ".".join(parts[:i])
|
||||
raise ConfigurationError(
|
||||
f"Cannot set '{path}' because '{parent_path}' is not a dictionary"
|
||||
)
|
||||
if part not in current:
|
||||
current[part] = {}
|
||||
current = current[part]
|
||||
if not isinstance(current, dict):
|
||||
parent_path = ".".join(parts[:-1])
|
||||
raise ConfigurationError(
|
||||
f"Cannot set '{path}' because '{parent_path}' is not a dictionary"
|
||||
)
|
||||
current[parts[-1]] = value
|
||||
|
||||
def interpolate_env_vars(self, config: Any) -> Any:
|
||||
"""
|
||||
Interpolate environment variables in configuration values.
|
||||
|
||||
This method recursively replaces environment variable references in
|
||||
configuration values with their actual values. Environment variables are
|
||||
referenced using the ${VAR_NAME} syntax.
|
||||
|
||||
Args:
|
||||
config: Configuration data to process.
|
||||
|
||||
Returns:
|
||||
The processed configuration data with environment variables interpolated.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If a referenced environment variable is not set.
|
||||
"""
|
||||
if isinstance(config, dict):
|
||||
return {k: self.interpolate_env_vars(v) for k, v in config.items()}
|
||||
if isinstance(config, list):
|
||||
return [self.interpolate_env_vars(i) for i in config]
|
||||
if isinstance(config, str):
|
||||
env_var_pattern = r"\${([A-Za-z0-9_]+)}"
|
||||
matches = re.findall(env_var_pattern, config)
|
||||
for env_var in matches:
|
||||
env_value = os.environ.get(env_var)
|
||||
if env_value is None:
|
||||
raise ConfigurationError(
|
||||
f"Environment variable '{env_var}' is not set"
|
||||
)
|
||||
config = config.replace(f"${{{env_var}}}", env_value)
|
||||
return config
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Convert the configuration to a dictionary.
|
||||
|
||||
Returns:
|
||||
A deep copy of the complete configuration dictionary.
|
||||
"""
|
||||
import copy
|
||||
|
||||
return copy.deepcopy(self.config)
|
||||
|
||||
def to_json(self) -> str:
|
||||
"""
|
||||
Convert the configuration to a JSON string.
|
||||
|
||||
Returns:
|
||||
A JSON string representation of the complete configuration.
|
||||
"""
|
||||
return json.dumps(self.config, indent=2)
|
||||
|
||||
|
||||
class SchemaValidator:
|
||||
"""
|
||||
Validates configuration against a schema.
|
||||
|
||||
This class is responsible for validating that a configuration dictionary
|
||||
conforms to the expected schema for CleverAgents configuration.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""
|
||||
Initialize the SchemaValidator.
|
||||
|
||||
Sets up the schema definition for CleverAgents configuration.
|
||||
"""
|
||||
# Schema will be defined here
|
||||
pass
|
||||
|
||||
def validate(self, config: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Validate a configuration dictionary against the schema.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to validate.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If the configuration does not conform to the schema.
|
||||
"""
|
||||
# Check for required top-level sections
|
||||
if "agents" not in config:
|
||||
raise ConfigurationError("Configuration must contain an 'agents' section")
|
||||
|
||||
if not isinstance(config["agents"], dict):
|
||||
raise ConfigurationError("'agents' section must be a dictionary")
|
||||
|
||||
# Validate each agent configuration
|
||||
for agent_name, agent_config in config["agents"].items():
|
||||
if not isinstance(agent_config, dict):
|
||||
raise ConfigurationError(
|
||||
f"Agent '{agent_name}' configuration must be a dictionary"
|
||||
)
|
||||
|
||||
if "type" not in agent_config:
|
||||
raise ConfigurationError(
|
||||
f"Agent '{agent_name}' is missing required 'type' field"
|
||||
)
|
||||
|
||||
if "config" not in agent_config:
|
||||
agent_config["config"] = {}
|
||||
|
||||
if not isinstance(agent_config["config"], dict):
|
||||
raise ConfigurationError(
|
||||
f"Agent '{agent_name}' 'config' must be a dictionary"
|
||||
)
|
||||
|
||||
if "routes" in config and isinstance(config["routes"], dict):
|
||||
if not config["routes"]:
|
||||
raise ConfigurationError("'routes' section cannot be empty")
|
||||
|
||||
clever_opts = config.get("cleveragents", {})
|
||||
if "default_router" not in clever_opts:
|
||||
raise ConfigurationError(
|
||||
"Configuration with 'routes' section must specify 'cleveragents.default_router'"
|
||||
)
|
||||
|
||||
default_router = clever_opts.get("default_router")
|
||||
if default_router not in config["routes"]:
|
||||
raise ConfigurationError(
|
||||
f"Default router '{default_router}' not found within 'routes'"
|
||||
)
|
||||
else:
|
||||
raise ConfigurationError("Configuration must have 'routes' section")
|
||||
@@ -0,0 +1,64 @@
|
||||
"""
|
||||
Exception classes for CleverAgents.
|
||||
|
||||
This module defines custom exception classes used throughout the CleverAgents
|
||||
application to provide clear error messages and appropriate error handling.
|
||||
"""
|
||||
|
||||
|
||||
class CleverAgentsException(Exception):
|
||||
"""Base exception class for all CleverAgents exceptions."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ConfigurationError(CleverAgentsException):
|
||||
"""Exception raised for errors in the configuration."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class UnsafeConfigurationError(ConfigurationError):
|
||||
"""
|
||||
Raised when a configuration file declares it must be executed in unsafe
|
||||
mode (``cleveragents.unsafe: true``) but the application was not launched
|
||||
with the ``--unsafe`` command-line flag.
|
||||
"""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class AgentCreationError(CleverAgentsException):
|
||||
"""Exception raised when agent creation fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class RoutingError(CleverAgentsException):
|
||||
"""Exception raised for errors in the routing configuration or execution."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class TemplateError(CleverAgentsException):
|
||||
"""Exception raised for errors in template rendering."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ExecutionError(CleverAgentsException):
|
||||
"""Exception raised when agent execution fails."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class InteractiveSessionError(CleverAgentsException):
|
||||
"""Exception raised for errors in the interactive session."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class ApplicationError(CleverAgentsException):
|
||||
"""Exception raised for application-level errors."""
|
||||
|
||||
pass
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Provides sandboxing utilities for executing configuration-supplied Python code
|
||||
in a restricted environment.
|
||||
|
||||
SAFE_BUILTINS is a curated subset of Python built-in functions that are deemed
|
||||
safe for use in conditions and custom tools executed when the application is
|
||||
*not* running with the ``--unsafe`` flag enabled. The list intentionally
|
||||
excludes any function that can perform I/O, introspection, or dynamic imports.
|
||||
"""
|
||||
|
||||
SAFE_BUILTINS = {
|
||||
# Basic types / constructors
|
||||
"bool": bool,
|
||||
"dict": dict,
|
||||
"float": float,
|
||||
"int": int,
|
||||
"list": list,
|
||||
"set": set,
|
||||
"str": str,
|
||||
"tuple": tuple,
|
||||
# Introspection-free utilities
|
||||
"abs": abs,
|
||||
"all": all,
|
||||
"any": any,
|
||||
"len": len,
|
||||
"max": max,
|
||||
"min": min,
|
||||
"round": round,
|
||||
"sum": sum,
|
||||
}
|
||||
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Interactive commands module for CleverAgents.
|
||||
|
||||
This module handles parsing and execution of interactive commands during a session.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def parse_command(input_line: str) -> Dict[str, Any]:
|
||||
"""
|
||||
Parse an interactive command from an input line.
|
||||
|
||||
Args:
|
||||
input_line: The raw input line from the user.
|
||||
|
||||
Returns:
|
||||
A dictionary representing the command and its arguments.
|
||||
"""
|
||||
parts = input_line.strip().split()
|
||||
if not parts:
|
||||
return {"command": "", "args": []}
|
||||
|
||||
command = parts[0].lower()
|
||||
|
||||
# Remove leading slash if present
|
||||
if command.startswith("/"):
|
||||
command = command[1:]
|
||||
|
||||
args = parts[1:]
|
||||
|
||||
# Handle special cases
|
||||
if command in ["help", "h", "?"]:
|
||||
return {"command": "help", "args": args}
|
||||
elif command in ["exit", "quit", "q"]:
|
||||
return {"command": "exit", "args": args}
|
||||
elif command in ["history", "hist"]:
|
||||
return {"command": "history", "args": args}
|
||||
elif command in ["clear", "cls"]:
|
||||
return {"command": "clear", "args": args}
|
||||
elif command in ["save"]:
|
||||
return {"command": "save", "args": args}
|
||||
elif command in ["verbose", "v"]:
|
||||
return {"command": "verbose", "args": args}
|
||||
elif command in ["config", "conf", "cfg"]:
|
||||
# Handle config subcommands
|
||||
if len(args) > 0:
|
||||
subcommand = args[0].lower()
|
||||
if subcommand in ["show", "get"]:
|
||||
return {"command": "config_show", "args": args[1:]}
|
||||
elif subcommand in ["set"]:
|
||||
return {"command": "config_set", "args": args[1:]}
|
||||
return {"command": "config", "args": args}
|
||||
elif command in ["agents", "agent"]:
|
||||
# Handle agents subcommands
|
||||
if len(args) > 0:
|
||||
subcommand = args[0].lower()
|
||||
if subcommand in ["list", "ls"]:
|
||||
return {"command": "agents_list", "args": args[1:]}
|
||||
elif subcommand in ["info"]:
|
||||
return {"command": "agent_info", "args": args[1:]}
|
||||
return {"command": "agents", "args": args}
|
||||
elif command in ["route", "rt"]:
|
||||
return {"command": "route", "args": args}
|
||||
elif command in ["switch", "sw"]:
|
||||
return {"command": "switch", "args": args}
|
||||
|
||||
# Default case
|
||||
return {"command": command, "args": args}
|
||||
|
||||
|
||||
def format_help_text() -> str:
|
||||
"""
|
||||
Format the help text for interactive commands.
|
||||
|
||||
Returns:
|
||||
Formatted help text.
|
||||
"""
|
||||
help_text = """
|
||||
Available Commands:
|
||||
/help, /h, /? Show this help message
|
||||
/exit, /quit, /q End the session
|
||||
/history [limit], /hist Show conversation history
|
||||
/clear, /cls Clear the conversation history
|
||||
/save Save the conversation history
|
||||
/verbose [on|off], /v Toggle verbose output
|
||||
/route [name] Switch route. Without name, shows current & available.
|
||||
|
||||
/config show [path], /conf get Show configuration
|
||||
/config set path value Change configuration value
|
||||
|
||||
/agents list, /agent ls List available agents
|
||||
/agents info [name] Show information about an agent
|
||||
|
||||
/switch [agent_name], /sw Switch to a different agent
|
||||
"""
|
||||
return help_text.strip()
|
||||
|
||||
|
||||
def execute_command(
|
||||
command_dict: Dict[str, Any], context: Optional[Dict[str, Any]] = None
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Execute the parsed interactive command.
|
||||
|
||||
Args:
|
||||
command_dict: Dictionary with command details.
|
||||
context: Additional context for command execution.
|
||||
|
||||
Returns:
|
||||
Dictionary with the result of command execution.
|
||||
"""
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
session = context.get("session")
|
||||
command = command_dict.get("command", "")
|
||||
args = command_dict.get("args", [])
|
||||
|
||||
result = {
|
||||
"success": True,
|
||||
"message": "",
|
||||
"continue": True, # Whether to continue the session
|
||||
}
|
||||
|
||||
if command == "help":
|
||||
result["message"] = format_help_text()
|
||||
elif command in ["exit", "quit"]:
|
||||
result["message"] = "Exiting interactive session."
|
||||
result["continue"] = False
|
||||
elif command == "history":
|
||||
if session:
|
||||
limit = int(args[0]) if args and args[0].isdigit() else 10
|
||||
session.display_history(limit)
|
||||
result["message"] = "" # The session method handles printing
|
||||
else:
|
||||
result["message"] = "History not available."
|
||||
elif command == "clear":
|
||||
if session:
|
||||
session.history = []
|
||||
result["message"] = "Conversation history cleared."
|
||||
else:
|
||||
result["message"] = "Session not available to clear history."
|
||||
elif command == "save":
|
||||
if session:
|
||||
session.save_history()
|
||||
result["message"] = f"History saved to {session.history_file}"
|
||||
else:
|
||||
result["message"] = "History not available."
|
||||
elif command == "verbose":
|
||||
if args and args[0] in ["on", "true", "yes", "1"]:
|
||||
result["message"] = "Verbose output enabled."
|
||||
elif args and args[0] in ["off", "false", "no", "0"]:
|
||||
result["message"] = "Verbose output disabled."
|
||||
else:
|
||||
result["message"] = "Toggled verbose output."
|
||||
elif command.startswith("config"):
|
||||
if command == "config_show":
|
||||
path = args[0] if args else ""
|
||||
result["message"] = f"Showing configuration for path: {path}"
|
||||
elif command == "config_set":
|
||||
if len(args) >= 2:
|
||||
path = args[0]
|
||||
value = args[1]
|
||||
result["message"] = f"Setting configuration {path} to {value}"
|
||||
else:
|
||||
result["success"] = False
|
||||
result["message"] = "Usage: /config set path value"
|
||||
else:
|
||||
result["message"] = "Available config commands: show, set"
|
||||
elif command.startswith("agents") or command == "agent_info":
|
||||
if command == "agents_list":
|
||||
result["message"] = "Listing available agents."
|
||||
elif command == "agent_info":
|
||||
agent_name = args[0] if args else ""
|
||||
result["message"] = f"Showing information for agent: {agent_name}"
|
||||
else:
|
||||
result["message"] = "Available agent commands: list, info"
|
||||
elif command == "route":
|
||||
if not session:
|
||||
result["message"] = "Route command is not available."
|
||||
elif not args:
|
||||
available = ", ".join(session.routers.keys())
|
||||
result["message"] = (
|
||||
f"Current route: '{session.active_route_name}'. Available: {available}."
|
||||
)
|
||||
else:
|
||||
new_route = args[0]
|
||||
if new_route in session.routers:
|
||||
session.active_route_name = new_route
|
||||
session.router = session.routers[new_route]
|
||||
result["message"] = f"Switched to route: '{new_route}'"
|
||||
else:
|
||||
result["success"] = False
|
||||
result["message"] = f"Error: Route '{new_route}' not found."
|
||||
elif command == "switch":
|
||||
if args:
|
||||
agent_name = args[0]
|
||||
result["message"] = f"Switching to agent: {agent_name}"
|
||||
else:
|
||||
result["success"] = False
|
||||
result["message"] = "Usage: /switch agent_name"
|
||||
else:
|
||||
result["success"] = False
|
||||
result["message"] = f"Unknown command: {command}"
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,211 @@
|
||||
"""
|
||||
Interactive session module for CleverAgents.
|
||||
|
||||
This module defines the InteractiveSession class which handles an interactive command-line session
|
||||
with the agent network.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import readline
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from cleveragents.core.exceptions import InteractiveSessionError
|
||||
from cleveragents.interactive.commands import execute_command
|
||||
from cleveragents.interactive.commands import parse_command
|
||||
from cleveragents.routing.router import Router
|
||||
|
||||
|
||||
class InteractiveSession:
|
||||
"""
|
||||
InteractiveSession manages an interactive chat session with the agent network.
|
||||
|
||||
Attributes:
|
||||
router (Router): The router for processing messages.
|
||||
history_file (Optional[Path]): Path to the history file.
|
||||
history (List[Dict[str, Any]]): The conversation history.
|
||||
context (Dict[str, Any]): The current context for message processing.
|
||||
verbose (bool): Whether to enable verbose output.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
routers: Dict[str, Router],
|
||||
initial_route_name: str,
|
||||
history_file: Optional[Path] = None,
|
||||
verbose: bool = False,
|
||||
initial_context: Optional[Dict[str, Any]] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the interactive session.
|
||||
|
||||
Args:
|
||||
routers: Dictionary of routers keyed by route name.
|
||||
initial_route_name: Route to start the session with.
|
||||
history_file: Conversation history file.
|
||||
verbose: Verbose flag.
|
||||
"""
|
||||
self.routers = routers
|
||||
self.active_route_name = initial_route_name
|
||||
self.router = self.routers[self.active_route_name]
|
||||
self.history_file = history_file
|
||||
self.history: List[Dict[str, Any]] = []
|
||||
# Use supplied initial context (with routers, globals, etc.) or an empty dict
|
||||
self.context: Dict[str, Any] = initial_context or {}
|
||||
self.verbose = verbose
|
||||
self.running = False
|
||||
|
||||
def load_history(self) -> None:
|
||||
"""
|
||||
Load conversation history from the history file if it exists.
|
||||
|
||||
Raises:
|
||||
InteractiveSessionError: If the history file cannot be loaded.
|
||||
"""
|
||||
if self.history_file and self.history_file.exists():
|
||||
try:
|
||||
with open(self.history_file, "r") as f:
|
||||
self.history = json.load(f)
|
||||
except Exception as e:
|
||||
raise InteractiveSessionError(f"Failed to load history file: {str(e)}")
|
||||
|
||||
def save_history(self) -> None:
|
||||
"""
|
||||
Save conversation history to the history file.
|
||||
|
||||
Raises:
|
||||
InteractiveSessionError: If the history file cannot be saved.
|
||||
"""
|
||||
if self.history_file:
|
||||
try:
|
||||
with open(self.history_file, "w") as f:
|
||||
json.dump(self.history, f, indent=2)
|
||||
except Exception as e:
|
||||
raise InteractiveSessionError(f"Failed to save history file: {str(e)}")
|
||||
|
||||
async def process_message(self, message: str) -> str:
|
||||
"""
|
||||
Process a message through the agent network.
|
||||
|
||||
Args:
|
||||
message: The message to process.
|
||||
|
||||
Returns:
|
||||
The response from the agent network.
|
||||
|
||||
Raises:
|
||||
InteractiveSessionError: If message processing fails.
|
||||
"""
|
||||
try:
|
||||
return await self.router.process_message(message, self.context)
|
||||
except Exception as e:
|
||||
raise InteractiveSessionError(f"Failed to process message: {str(e)}") from e
|
||||
|
||||
def add_to_history(self, role: str, content: str) -> None:
|
||||
"""
|
||||
Add a message to the conversation history.
|
||||
|
||||
Args:
|
||||
role: The role of the message sender ('user' or 'assistant').
|
||||
content: The content of the message.
|
||||
"""
|
||||
self.history.append({"role": role, "content": content})
|
||||
|
||||
def display_history(self, limit: int = 10) -> None:
|
||||
"""
|
||||
Display the conversation history.
|
||||
|
||||
Args:
|
||||
limit: Maximum number of messages to display.
|
||||
"""
|
||||
start = max(0, len(self.history) - limit)
|
||||
for i, entry in enumerate(self.history[start:], start=start + 1):
|
||||
role = entry["role"]
|
||||
content = entry["content"]
|
||||
prefix = "You: " if role == "user" else "Agent: "
|
||||
print(f"{i}. {prefix}{content}")
|
||||
|
||||
def process_command(self, command_str: str) -> bool:
|
||||
"""
|
||||
Parse & execute a slash-command.
|
||||
|
||||
Args:
|
||||
command_str: Raw user input beginning with '/'.
|
||||
|
||||
Returns:
|
||||
True if the session should continue, False otherwise.
|
||||
"""
|
||||
command_dict = parse_command(command_str)
|
||||
result = execute_command(command_dict, {"session": self})
|
||||
|
||||
if result.get("message"):
|
||||
print(result["message"])
|
||||
|
||||
return result.get("continue", True)
|
||||
|
||||
async def run(self) -> None:
|
||||
"""
|
||||
Start the interactive session.
|
||||
|
||||
This method starts a text-based interactive loop.
|
||||
|
||||
Raises:
|
||||
InteractiveSessionError: If the session cannot be started or an error occurs during the session.
|
||||
"""
|
||||
try:
|
||||
self.load_history()
|
||||
|
||||
print("Starting interactive session.")
|
||||
print(f"Active route: '{self.active_route_name}'")
|
||||
print("Type '/help' for commands, '/exit' to quit.")
|
||||
self.running = True
|
||||
|
||||
while self.running:
|
||||
try:
|
||||
user_input = input("You: ")
|
||||
|
||||
# Check if this is a command
|
||||
if user_input.startswith("/"):
|
||||
self.running = self.process_command(user_input)
|
||||
continue
|
||||
|
||||
# Process the message
|
||||
self.add_to_history("user", user_input)
|
||||
|
||||
# Update the per-turn initial message so that routers and
|
||||
# template transforms always reference the latest user input.
|
||||
self.context["initial_message"] = user_input
|
||||
|
||||
if self.verbose:
|
||||
print("Processing message...")
|
||||
|
||||
response = await self.process_message(user_input)
|
||||
|
||||
print(f"Agent: {response}")
|
||||
self.add_to_history("assistant", response)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted by user.")
|
||||
self.running = False
|
||||
break
|
||||
except InteractiveSessionError as e:
|
||||
if e.__cause__:
|
||||
print(f"Error: {str(e.__cause__)}")
|
||||
else:
|
||||
print(f"Error: {str(e)}")
|
||||
except Exception as e:
|
||||
print(f"An unexpected error occurred: {e}")
|
||||
|
||||
self.save_history()
|
||||
print("Session ended.")
|
||||
|
||||
except Exception as e:
|
||||
raise InteractiveSessionError(
|
||||
f"Failed to run interactive session: {str(e)}"
|
||||
)
|
||||
@@ -0,0 +1,69 @@
|
||||
"""
|
||||
Module: network
|
||||
|
||||
This module implements the AgentNetwork class, which encapsulates the creation,
|
||||
management, and execution of an agent network.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import Optional
|
||||
|
||||
from cleveragents.agents.factory import AgentFactory
|
||||
from cleveragents.core.config import ConfigurationManager
|
||||
from cleveragents.core.exceptions import CleverAgentsException
|
||||
from cleveragents.routing.router import Router
|
||||
from cleveragents.templates.renderer import TemplateEngine
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
|
||||
class AgentNetwork:
|
||||
"""
|
||||
AgentNetwork represents a network of agents.
|
||||
|
||||
Attributes:
|
||||
config_manager (ConfigurationManager): Manager for configuration.
|
||||
agent_factory (AgentFactory): Factory for creating agents.
|
||||
router (Router): Router to handle message passing between agents.
|
||||
"""
|
||||
|
||||
def __init__(self, config_files=None, verbose=False):
|
||||
self.config_manager = ConfigurationManager()
|
||||
if config_files:
|
||||
self.config_manager.load_files(config_files)
|
||||
self.config_manager.validate()
|
||||
|
||||
# This network class is not fully utilized by the application yet.
|
||||
# The core logic is currently in CleverAgentsApp.
|
||||
self.agent_factory: Optional[AgentFactory] = None
|
||||
self.router: Optional[Router] = None
|
||||
|
||||
async def process(
|
||||
self, message: str, context: Optional[Dict[Any, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a single message through the agent network.
|
||||
|
||||
Args:
|
||||
message (str): The message to process.
|
||||
context (dict, optional): Additional context for message processing.
|
||||
|
||||
Returns:
|
||||
str: The final output message.
|
||||
"""
|
||||
# Ensure router is initialized (helps mypy understand it's not None)
|
||||
assert (
|
||||
self.router is not None
|
||||
), "Router not initialized. Load configuration first."
|
||||
return await self.router.process_message(message, context)
|
||||
|
||||
def get_router(self):
|
||||
"""
|
||||
Get the router used in the agent network.
|
||||
|
||||
Returns:
|
||||
Router: The router instance.
|
||||
"""
|
||||
return self.router
|
||||
@@ -0,0 +1,142 @@
|
||||
"""
|
||||
Conditions module for CleverAgents.
|
||||
|
||||
This module provides utilities to evaluate conditions used in message routing.
|
||||
It supports various condition types including regex matching, keyword presence,
|
||||
and custom Python code evaluation.
|
||||
"""
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
from cleveragents.core.exceptions import RoutingError
|
||||
from cleveragents.core.sandbox import SAFE_BUILTINS
|
||||
|
||||
|
||||
class ConditionEvaluator:
|
||||
"""
|
||||
Evaluates routing conditions based on context.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _evaluate_keywords(condition: Dict[str, Any], message: str) -> bool:
|
||||
"""Evaluate a 'keywords' type condition."""
|
||||
keywords_val = condition.get("keywords")
|
||||
keywords: List[str] = []
|
||||
if isinstance(keywords_val, str):
|
||||
keywords = [keywords_val]
|
||||
elif isinstance(keywords_val, list):
|
||||
keywords = [str(k) for k in keywords_val]
|
||||
|
||||
# If no keywords specified, the condition cannot be satisfied
|
||||
if not keywords:
|
||||
return False
|
||||
|
||||
match_all = condition.get("match_all", False)
|
||||
message_lower = message.lower()
|
||||
|
||||
if match_all:
|
||||
return all(kw.lower() in message_lower for kw in keywords)
|
||||
return any(kw.lower() in message_lower for kw in keywords)
|
||||
|
||||
@staticmethod
|
||||
def evaluate(
|
||||
condition: Union[str, Dict[str, Any]],
|
||||
message: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""
|
||||
Evaluate a condition against the provided message and context.
|
||||
|
||||
Args:
|
||||
condition: The condition as a string or dictionary.
|
||||
message: The message being routed.
|
||||
context: Additional context for condition evaluation.
|
||||
|
||||
Returns:
|
||||
True if condition passes, False otherwise.
|
||||
|
||||
Raises:
|
||||
RoutingError: If the condition cannot be evaluated.
|
||||
"""
|
||||
if context is None:
|
||||
context = {}
|
||||
else:
|
||||
# Work on a shallow copy to avoid mutating the caller's context.
|
||||
context = context.copy()
|
||||
|
||||
# Add message to context for use in conditions
|
||||
context["message"] = message
|
||||
|
||||
# Determine safety mode from context
|
||||
unsafe_mode = context.get("_unsafe_mode", False)
|
||||
if unsafe_mode:
|
||||
execution_globals: Dict[str, Any] = globals()
|
||||
else:
|
||||
# Provide a sandboxed environment with a whitelist of harmless built-ins
|
||||
execution_globals = {"__builtins__": SAFE_BUILTINS, "null": None}
|
||||
|
||||
try:
|
||||
if isinstance(condition, str):
|
||||
# First, attempt to treat the string as a valid Python boolean
|
||||
# expression (e.g. "'SEARCH' in message"). If that fails,
|
||||
# gracefully fall back to a *case-insensitive substring* search
|
||||
# so that simple strings like "hello" are interpreted as
|
||||
# "contains 'hello'".
|
||||
local_scope: Dict[str, Any] = {"message": message, "context": context}
|
||||
|
||||
# Strategy 1: `eval` – single-line expressions that return bool
|
||||
try:
|
||||
return bool(eval(condition, execution_globals, local_scope))
|
||||
except Exception:
|
||||
pass # Ignore and try the next strategy
|
||||
|
||||
# Strategy 2: `exec` – multi-line code that sets `result`
|
||||
try:
|
||||
local_scope["result"] = False
|
||||
exec(condition, execution_globals, local_scope)
|
||||
return bool(local_scope.get("result", False))
|
||||
except Exception:
|
||||
# Strategy 3: simple substring search (default behaviour)
|
||||
return condition.lower() in message.lower()
|
||||
elif isinstance(condition, dict):
|
||||
condition_type = condition.get("type", "simple")
|
||||
|
||||
if condition_type == "regex":
|
||||
# Regex pattern matching
|
||||
pattern = condition.get("pattern", "")
|
||||
return bool(re.search(pattern, message, re.IGNORECASE))
|
||||
|
||||
elif condition_type == "keywords":
|
||||
return ConditionEvaluator._evaluate_keywords(condition, message)
|
||||
|
||||
elif condition_type == "python":
|
||||
# Execute Python code
|
||||
code = condition.get("code", "")
|
||||
if not code:
|
||||
return False
|
||||
|
||||
# Create a safe local environment with message and context
|
||||
local_vars = {"message": message, "context": context}
|
||||
|
||||
# Execute the code
|
||||
exec(code, execution_globals, local_vars)
|
||||
|
||||
# The code should set a 'result' variable
|
||||
return bool(local_vars.get("result", False))
|
||||
|
||||
elif condition_type == "simple":
|
||||
# Check if a key exists in context and is truthy
|
||||
key = condition.get("key", "")
|
||||
return bool(context.get(key, False))
|
||||
|
||||
else:
|
||||
raise RoutingError(f"Unknown condition type: {condition_type}")
|
||||
else:
|
||||
raise RoutingError(f"Unsupported condition type: {type(condition)}")
|
||||
except Exception as e:
|
||||
raise RoutingError(f"Error evaluating condition: {str(e)}")
|
||||
@@ -0,0 +1,42 @@
|
||||
"""
|
||||
Graph builder module for CleverAgents.
|
||||
|
||||
This module defines the GraphBuilder class, which is responsible for constructing a graph that represents
|
||||
the routing of interactions between agents.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
|
||||
|
||||
class GraphBuilder:
|
||||
"""
|
||||
GraphBuilder constructs a graph representation of the agent network.
|
||||
|
||||
Attributes:
|
||||
agents (List[Any]): A list of agents included in the graph.
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
self.agents = []
|
||||
|
||||
def add_agent(self, agent: Any) -> None:
|
||||
"""
|
||||
Add an agent to the graph.
|
||||
|
||||
Args:
|
||||
agent: The agent instance to add.
|
||||
"""
|
||||
self.agents.append(agent)
|
||||
|
||||
def build(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Build and return the graph representation of the agent network.
|
||||
|
||||
Returns:
|
||||
A dictionary that represents the graph with nodes and edges.
|
||||
"""
|
||||
graph = {"nodes": [agent.name for agent in self.agents], "edges": []}
|
||||
# For demonstration, edges are left empty.
|
||||
return graph
|
||||
@@ -0,0 +1,357 @@
|
||||
"""
|
||||
Router module for CleverAgents.
|
||||
|
||||
This module defines the Router class responsible for routing messages between agents
|
||||
based on conditions and transformations.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import pprint
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.core.exceptions import RoutingError
|
||||
from cleveragents.routing.conditions import ConditionEvaluator
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
PROTECTED_CONTEXT_KEYS = frozenset({"history", "initial_message", "_routers"})
|
||||
|
||||
|
||||
class Router:
|
||||
"""
|
||||
Router routes messages between agents based on conditions and transformations.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the router.
|
||||
agents (Dict[str, Agent]): Dictionary of available agents.
|
||||
routes (List[Dict[str, Any]]): List of routing rules.
|
||||
template_renderer (TemplateRenderer): Renderer for processing templates.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, agents: Dict[str, Agent], template_renderer: TemplateRenderer
|
||||
):
|
||||
"""
|
||||
Initialize the router.
|
||||
|
||||
Args:
|
||||
name: The name of the router.
|
||||
agents: Dictionary of available agents.
|
||||
template_renderer: Renderer for processing templates.
|
||||
"""
|
||||
self.name = name
|
||||
self.agents = agents
|
||||
self.template_renderer = template_renderer
|
||||
self.routes: List[Dict[str, Any]] = []
|
||||
self.condition_evaluator = ConditionEvaluator()
|
||||
|
||||
def add_route(self, route: Dict[str, Any]) -> None:
|
||||
"""
|
||||
Add a routing rule.
|
||||
|
||||
Args:
|
||||
route: Dictionary defining the routing rule.
|
||||
|
||||
Raises:
|
||||
RoutingError: If the route is invalid.
|
||||
"""
|
||||
if not isinstance(route, dict):
|
||||
raise RoutingError("Route must be a dictionary")
|
||||
|
||||
# Standardize keys: 'source'/'destination' are aliases for 'from'/'to'.
|
||||
# Canonical keys take precedence.
|
||||
if "from" not in route and "source" in route:
|
||||
route["from"] = route["source"]
|
||||
if "to" not in route and "destination" in route:
|
||||
route["to"] = route["destination"]
|
||||
|
||||
# Clean up alias keys to avoid confusion later.
|
||||
route.pop("source", None)
|
||||
route.pop("destination", None)
|
||||
|
||||
required_keys = ["from", "to"]
|
||||
for key in required_keys:
|
||||
if key not in route:
|
||||
raise RoutingError(f"Route missing required key: {key}")
|
||||
|
||||
# Validate that the source and destination agents exist
|
||||
from_agent = route["from"]
|
||||
if from_agent != "input" and from_agent not in self.agents:
|
||||
raise RoutingError(f"Source agent '{from_agent}' not found")
|
||||
|
||||
to_agent = route["to"]
|
||||
if to_agent != "output" and to_agent not in self.agents:
|
||||
raise RoutingError(f"Destination agent '{to_agent}' not found")
|
||||
|
||||
self.routes.append(route)
|
||||
|
||||
def add_routes(self, routes: List[Dict[str, Any]]) -> None:
|
||||
"""
|
||||
Add multiple routing rules.
|
||||
|
||||
Args:
|
||||
routes: List of dictionaries defining routing rules.
|
||||
|
||||
Raises:
|
||||
RoutingError: If any route is invalid.
|
||||
"""
|
||||
for route in routes:
|
||||
self.add_route(route)
|
||||
|
||||
async def route_message(
|
||||
self,
|
||||
message: str,
|
||||
source: str = "input",
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Route a message according to the routing rules, with detailed
|
||||
debug logging and corrected template-rendering context.
|
||||
|
||||
Args:
|
||||
message: The message to route.
|
||||
source: The source of the message (agent name or 'input').
|
||||
context: Additional context for routing.
|
||||
|
||||
Returns:
|
||||
A dict containing the destination agent name and the (possibly
|
||||
transformed) message.
|
||||
|
||||
Raises:
|
||||
RoutingError: If routing or transformation fails.
|
||||
"""
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
logger.debug(f"Routing message from source='{source}': {repr(message)}")
|
||||
|
||||
# Find all matching routes
|
||||
matching_routes: List[Dict[str, Any]] = []
|
||||
for idx, route in enumerate(self.routes):
|
||||
logger.debug(
|
||||
f"[Route {idx + 1}/{len(self.routes)}] Checking route: "
|
||||
f"from='{route.get('from')}', to='{route.get('to')}'"
|
||||
)
|
||||
|
||||
if route.get("from") != source:
|
||||
logger.debug(" - Source does not match.")
|
||||
continue
|
||||
|
||||
logger.debug(" - Source matches.")
|
||||
|
||||
# Evaluate condition, if any
|
||||
if "condition" in route:
|
||||
condition = route["condition"]
|
||||
logger.debug(f" - Evaluating condition: {repr(condition)}")
|
||||
try:
|
||||
if not self.condition_evaluator.evaluate(
|
||||
condition, message, context
|
||||
):
|
||||
logger.debug(" -> Condition FAILED.")
|
||||
continue
|
||||
logger.debug(" -> Condition PASSED.")
|
||||
except Exception as e:
|
||||
raise RoutingError(f"Error evaluating condition: {str(e)}")
|
||||
else:
|
||||
logger.debug(" - No condition specified.")
|
||||
|
||||
matching_routes.append(route)
|
||||
|
||||
# No routes? Decide what to do.
|
||||
if not matching_routes:
|
||||
logger.debug(f"No matching routes for source '{source}'.")
|
||||
if source == "input":
|
||||
if len(self.agents) == 1:
|
||||
default_agent = list(self.agents.keys())[0]
|
||||
logger.debug(
|
||||
f"Single agent configured; defaulting destination to '{default_agent}'."
|
||||
)
|
||||
return {"destination": default_agent, "message": message}
|
||||
raise RoutingError("No route found for input message")
|
||||
logger.debug("Defaulting destination to 'output'.")
|
||||
return {"destination": "output", "message": message}
|
||||
|
||||
# Select the first matching route (FIFO priority)
|
||||
route = matching_routes[0]
|
||||
destination = route["to"]
|
||||
logger.debug(f"Selected destination: '{destination}'")
|
||||
|
||||
# Apply context modification, if provided
|
||||
if "context" in route:
|
||||
context_template = route["context"]
|
||||
logger.debug(f"Applying context modification: {repr(context_template)}")
|
||||
|
||||
render_context_for_modification = {"message": message, "context": context}
|
||||
logger.debug(
|
||||
" - Full context for context modification:\n%s",
|
||||
pprint.pformat(context),
|
||||
)
|
||||
|
||||
try:
|
||||
rendered_context_str = self.template_renderer.render_string(
|
||||
context_template,
|
||||
render_context_for_modification,
|
||||
source_description=f"context modification in route from '{source}' to '{destination}'",
|
||||
)
|
||||
context_updates = json.loads(rendered_context_str, strict=False)
|
||||
|
||||
if not isinstance(context_updates, dict):
|
||||
raise RoutingError(
|
||||
"Context template must render to a JSON object (dict)."
|
||||
)
|
||||
|
||||
for key, value in context_updates.items():
|
||||
if key in PROTECTED_CONTEXT_KEYS:
|
||||
raise RoutingError(
|
||||
f"Cannot modify or delete protected context key: '{key}'"
|
||||
)
|
||||
if value is None: # 'nil' in YAML becomes None in Python
|
||||
if key in context:
|
||||
del context[key]
|
||||
logger.debug(f"Deleted key '{key}' from context.")
|
||||
else:
|
||||
context[key] = value
|
||||
logger.debug(f"Updated context with '{key}': {repr(value)}")
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise RoutingError(
|
||||
f"Failed to decode JSON from rendered context template: {e}"
|
||||
) from e
|
||||
except Exception as e:
|
||||
if isinstance(e, RoutingError):
|
||||
raise
|
||||
raise RoutingError(f"Error processing context template: {e}") from e
|
||||
|
||||
# Apply transformation, if provided
|
||||
transformed_message = message
|
||||
if "transform" in route:
|
||||
transform = route["transform"]
|
||||
logger.debug(f"Applying transform: {repr(transform)}")
|
||||
|
||||
# Provide both the original message and the full context under
|
||||
# a dedicated key to avoid key collisions inside templates.
|
||||
render_context = {"message": message, "context": context}
|
||||
logger.debug(f" - Context before transform: {render_context}")
|
||||
logger.debug(" - Full context for transform:\n%s", pprint.pformat(context))
|
||||
|
||||
try:
|
||||
if isinstance(transform, str):
|
||||
transformed_message = self.template_renderer.render_string(
|
||||
transform,
|
||||
render_context,
|
||||
source_description=f"transform in route from '{source}' to '{destination}'",
|
||||
)
|
||||
elif isinstance(transform, dict) and "template" in transform:
|
||||
transformed_message = self.template_renderer.render_string(
|
||||
transform["template"],
|
||||
render_context,
|
||||
source_description=f"transform in route from '{source}' to '{destination}'",
|
||||
)
|
||||
else:
|
||||
raise RoutingError(
|
||||
f"Unsupported transform format: {type(transform)}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise RoutingError(f"Error applying template transformation: {str(e)}")
|
||||
|
||||
logger.debug(f" - Context after transform: {render_context}")
|
||||
logger.debug(f" - Transform result: {repr(transformed_message)}")
|
||||
else:
|
||||
logger.debug("No transform defined for this route.")
|
||||
|
||||
return {"destination": destination, "message": transformed_message}
|
||||
|
||||
async def process_message(
|
||||
self, message: str, context: Optional[Dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message end-to-end through the router, logging each step.
|
||||
|
||||
The method repeatedly routes the message and invokes the appropriate
|
||||
agent until the destination is 'output' or the maximum number of
|
||||
hops is reached.
|
||||
|
||||
Args:
|
||||
message: The initial user message.
|
||||
context: Optional shared context dictionary.
|
||||
|
||||
Returns:
|
||||
The final message that should be returned to the user.
|
||||
|
||||
Raises:
|
||||
RoutingError: If processing fails or exceeds max steps.
|
||||
"""
|
||||
# The provided context is mutated directly to allow state to persist
|
||||
# across routing steps and interactive sessions.
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
# Preserve the user's original input for template transforms.
|
||||
if "initial_message" not in context:
|
||||
context["initial_message"] = message
|
||||
if "history" not in context:
|
||||
context["history"] = []
|
||||
|
||||
current_source = "input"
|
||||
current_message = message
|
||||
max_steps = 100 # Safety guard against infinite loops
|
||||
|
||||
logger.debug("=== Begin router processing ===")
|
||||
logger.debug(f"Initial message: {repr(current_message)}")
|
||||
|
||||
for step in range(max_steps):
|
||||
logger.debug(f"--- Step {step + 1} ---")
|
||||
route_result = await self.route_message(
|
||||
current_message, current_source, context
|
||||
)
|
||||
|
||||
destination = route_result.get("destination", "output")
|
||||
transformed_message = route_result.get("message", current_message)
|
||||
|
||||
logger.debug(
|
||||
f"Routed to '{destination}' with message: {repr(transformed_message)}"
|
||||
)
|
||||
|
||||
# Reached the terminal node
|
||||
if destination == "output":
|
||||
logger.debug("Reached 'output'. Processing complete.")
|
||||
return transformed_message
|
||||
|
||||
# Validate destination agent exists
|
||||
if destination not in self.agents:
|
||||
raise RoutingError(
|
||||
f"Destination agent '{destination}' not found in router."
|
||||
)
|
||||
|
||||
agent = self.agents[destination]
|
||||
logger.debug(f"Invoking agent '{destination}'")
|
||||
try:
|
||||
result_message = await agent.process(transformed_message, context)
|
||||
logger.debug(f"Agent '{destination}' returned: {repr(result_message)}")
|
||||
except Exception as e:
|
||||
raise RoutingError(
|
||||
f"Error processing message with agent '{destination}': {str(e)}"
|
||||
)
|
||||
|
||||
# Append the completed step to the history
|
||||
context["history"].append(
|
||||
{
|
||||
"source": current_source,
|
||||
"destination": destination,
|
||||
"message": result_message,
|
||||
}
|
||||
)
|
||||
|
||||
# Prepare for next hop
|
||||
current_source = destination
|
||||
current_message = result_message
|
||||
|
||||
# If we exit the loop, something went wrong
|
||||
raise RoutingError("Maximum routing steps exceeded")
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""
|
||||
Template loaders module for CleverAgents.
|
||||
|
||||
This module provides functionality to load templates from different sources, such as files,
|
||||
directories, or direct strings.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import yaml # type: ignore
|
||||
|
||||
from cleveragents.core.exceptions import TemplateError
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
|
||||
class TemplateLoader:
|
||||
"""
|
||||
Base class for template loaders.
|
||||
|
||||
This class defines the interface for template loaders, which are responsible
|
||||
for loading templates from different sources and registering them with a
|
||||
template renderer.
|
||||
|
||||
Attributes:
|
||||
renderer (TemplateRenderer): The template renderer to register templates with.
|
||||
"""
|
||||
|
||||
def __init__(self, renderer: TemplateRenderer):
|
||||
"""
|
||||
Initialize the template loader.
|
||||
|
||||
Args:
|
||||
renderer: The template renderer to register templates with.
|
||||
"""
|
||||
self.renderer = renderer
|
||||
|
||||
def load(self) -> None:
|
||||
"""
|
||||
Load templates and register them with the renderer.
|
||||
|
||||
This method loads templates from the loader's source and registers them
|
||||
with the template renderer.
|
||||
|
||||
Raises:
|
||||
TemplateError: If templates cannot be loaded.
|
||||
"""
|
||||
raise NotImplementedError("Method 'load' is not implemented yet.")
|
||||
|
||||
|
||||
class FileTemplateLoader(TemplateLoader):
|
||||
"""
|
||||
Loader for templates from files.
|
||||
|
||||
This class loads templates from individual files and registers them with
|
||||
a template renderer.
|
||||
|
||||
Attributes:
|
||||
renderer (TemplateRenderer): The template renderer to register templates with.
|
||||
file_paths (List[Path]): The paths to the template files.
|
||||
"""
|
||||
|
||||
def __init__(self, renderer: TemplateRenderer, file_paths: List[Path]):
|
||||
"""
|
||||
Initialize the file template loader.
|
||||
|
||||
Args:
|
||||
renderer: The template renderer to register templates with.
|
||||
file_paths: The paths to the template files.
|
||||
"""
|
||||
super().__init__(renderer)
|
||||
self.file_paths = file_paths
|
||||
|
||||
def load(self) -> None:
|
||||
"""
|
||||
Load templates from files and register them with the renderer.
|
||||
|
||||
This method loads templates from the specified files and registers them
|
||||
with the template renderer. The template name is derived from the file name.
|
||||
|
||||
Raises:
|
||||
TemplateError: If templates cannot be loaded from the files.
|
||||
"""
|
||||
for file_path in self.file_paths:
|
||||
try:
|
||||
if not file_path.exists():
|
||||
raise TemplateError(f"Template file not found: {file_path}")
|
||||
|
||||
with open(file_path, "r") as f:
|
||||
template_content = f.read()
|
||||
|
||||
# Use the file name without extension as the template name
|
||||
template_name = file_path.stem
|
||||
|
||||
self.renderer.register_template(template_name, template_content)
|
||||
except Exception as e:
|
||||
raise TemplateError(
|
||||
f"Failed to load template from file {file_path}: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
class DirectoryTemplateLoader(TemplateLoader):
|
||||
"""
|
||||
Loader for templates from directories.
|
||||
|
||||
This class loads templates from all files in a directory (and optionally
|
||||
its subdirectories) and registers them with a template renderer.
|
||||
|
||||
Attributes:
|
||||
renderer (TemplateRenderer): The template renderer to register templates with.
|
||||
directory_path (Path): The path to the directory containing templates.
|
||||
recursive (bool): Whether to load templates from subdirectories.
|
||||
pattern (str): A glob pattern for matching template files.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
renderer: TemplateRenderer,
|
||||
directory_path: Path,
|
||||
recursive: bool = True,
|
||||
pattern: str = "*.j2",
|
||||
):
|
||||
"""
|
||||
Initialize the directory template loader.
|
||||
|
||||
Args:
|
||||
renderer: The template renderer to register templates with.
|
||||
directory_path: The path to the directory containing templates.
|
||||
recursive: Whether to load templates from subdirectories.
|
||||
pattern: A glob pattern for matching template files.
|
||||
"""
|
||||
super().__init__(renderer)
|
||||
self.directory_path = directory_path
|
||||
self.recursive = recursive
|
||||
self.pattern = pattern
|
||||
|
||||
def load(self) -> None:
|
||||
"""
|
||||
Load templates from a directory and register them with the renderer.
|
||||
|
||||
This method loads templates from all files in the specified directory
|
||||
(and optionally its subdirectories) and registers them with the template
|
||||
renderer. The template name is derived from the file path relative to
|
||||
the directory.
|
||||
|
||||
Raises:
|
||||
TemplateError: If templates cannot be loaded from the directory.
|
||||
"""
|
||||
try:
|
||||
if not self.directory_path.exists():
|
||||
raise TemplateError(
|
||||
f"Template directory not found: {self.directory_path}"
|
||||
)
|
||||
|
||||
if not self.directory_path.is_dir():
|
||||
raise TemplateError(f"Not a directory: {self.directory_path}")
|
||||
|
||||
# Find all template files
|
||||
if self.recursive:
|
||||
template_files = list(self.directory_path.glob(f"**/{self.pattern}"))
|
||||
else:
|
||||
template_files = list(self.directory_path.glob(self.pattern))
|
||||
|
||||
# Load each template file
|
||||
for file_path in template_files:
|
||||
try:
|
||||
with open(file_path, "r") as f:
|
||||
template_content = f.read()
|
||||
|
||||
# Use the relative path without extension as the template name
|
||||
rel_path = file_path.relative_to(self.directory_path)
|
||||
template_name = str(rel_path.with_suffix(""))
|
||||
|
||||
self.renderer.register_template(template_name, template_content)
|
||||
except Exception as e:
|
||||
raise TemplateError(
|
||||
f"Failed to load template from file {file_path}: {str(e)}"
|
||||
)
|
||||
except Exception as e:
|
||||
raise TemplateError(
|
||||
f"Failed to load templates from directory {self.directory_path}: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
class ConfigTemplateLoader(TemplateLoader):
|
||||
"""
|
||||
Loader for templates from configuration.
|
||||
|
||||
This class loads templates from a configuration dictionary and registers
|
||||
them with a template renderer.
|
||||
|
||||
Attributes:
|
||||
renderer (TemplateRenderer): The template renderer to register templates with.
|
||||
config (Dict[str, Any]): The configuration dictionary containing templates.
|
||||
"""
|
||||
|
||||
def __init__(self, renderer: TemplateRenderer, config: Dict[str, Any]):
|
||||
"""
|
||||
Initialize the configuration template loader.
|
||||
|
||||
Args:
|
||||
renderer: The template renderer to register templates with.
|
||||
config: The configuration dictionary containing templates.
|
||||
"""
|
||||
super().__init__(renderer)
|
||||
self.config = config
|
||||
|
||||
def load(self) -> None:
|
||||
"""
|
||||
Load templates from configuration and register them with the renderer.
|
||||
|
||||
This method loads templates from the configuration dictionary and
|
||||
registers them with the template renderer. The configuration should
|
||||
contain a 'templates' section with template definitions.
|
||||
|
||||
Raises:
|
||||
TemplateError: If templates cannot be loaded from the configuration.
|
||||
"""
|
||||
try:
|
||||
templates = self.config.get("templates", {})
|
||||
|
||||
if not templates:
|
||||
return
|
||||
|
||||
for name, template_def in templates.items():
|
||||
if isinstance(template_def, str):
|
||||
# Simple string template
|
||||
self.renderer.register_template(name, template_def)
|
||||
elif isinstance(template_def, dict) and "content" in template_def:
|
||||
# Template with content and metadata
|
||||
self.renderer.register_template(name, template_def["content"])
|
||||
else:
|
||||
raise TemplateError(f"Invalid template definition for '{name}'")
|
||||
except Exception as e:
|
||||
raise TemplateError(
|
||||
f"Failed to load templates from configuration: {str(e)}"
|
||||
)
|
||||
|
||||
|
||||
def load_from_file(file_path: str) -> Optional[str]:
|
||||
"""
|
||||
Load a template from a file.
|
||||
|
||||
Args:
|
||||
file_path: Path to the template file.
|
||||
|
||||
Returns:
|
||||
The template as a string, or None if the file does not exist.
|
||||
|
||||
Raises:
|
||||
TemplateError: If the file cannot be read.
|
||||
"""
|
||||
try:
|
||||
if not os.path.exists(file_path):
|
||||
return None
|
||||
|
||||
with open(file_path, "r") as file:
|
||||
return file.read()
|
||||
except Exception as e:
|
||||
raise TemplateError(f"Failed to load template from file {file_path}: {str(e)}")
|
||||
|
||||
|
||||
def load_from_string(template_str: str) -> str:
|
||||
"""
|
||||
Load a template from a provided string.
|
||||
|
||||
Args:
|
||||
template_str: The template string.
|
||||
|
||||
Returns:
|
||||
The template string.
|
||||
"""
|
||||
return template_str
|
||||
@@ -0,0 +1,284 @@
|
||||
"""
|
||||
Template renderer module for CleverAgents.
|
||||
|
||||
This module defines the TemplateRenderer class, which is responsible for rendering string templates
|
||||
with provided context data. It supports multiple template engines including Jinja2 and simple string formatting.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import Dict
|
||||
from typing import List
|
||||
from typing import Mapping
|
||||
from typing import Optional
|
||||
from typing import Protocol
|
||||
from typing import Union
|
||||
|
||||
from cleveragents.core.exceptions import TemplateError
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Jinja2Template(Protocol):
|
||||
def render(self, **kwargs: Any) -> str: ...
|
||||
|
||||
|
||||
class Jinja2Environment(Protocol):
|
||||
def from_string(self, source: str) -> Jinja2Template: ...
|
||||
|
||||
|
||||
class PystacheRenderer(Protocol):
|
||||
def render(self, template: str, context: Dict[str, Any]) -> str: ...
|
||||
|
||||
|
||||
class TemplateEngine(Enum):
|
||||
"""Enumeration of supported template engines."""
|
||||
|
||||
SIMPLE = "simple" # Python's str.format()
|
||||
JINJA2 = "jinja2" # Requires jinja2 package
|
||||
MUSTACHE = "mustache" # Requires pystache package
|
||||
|
||||
|
||||
def _resolve_path(path: str, ctx: Dict[str, Any]) -> str:
|
||||
"""Return ctx value addressed by a dotted *path* (e.g. 'user.name')."""
|
||||
cur: Any = ctx
|
||||
for part in path.split("."):
|
||||
if isinstance(cur, Mapping):
|
||||
cur = cur.get(part, "")
|
||||
else:
|
||||
cur = getattr(cur, part, "")
|
||||
return cur if cur is not None else ""
|
||||
|
||||
|
||||
class TemplateRenderer:
|
||||
"""
|
||||
TemplateRenderer processes templates with provided context data.
|
||||
|
||||
Attributes:
|
||||
engine_type (TemplateEngine): The template engine to use.
|
||||
templates (Dict[str, str]): Dictionary of registered templates.
|
||||
engine: The actual template engine instance.
|
||||
"""
|
||||
|
||||
def __init__(self, engine_type: TemplateEngine = TemplateEngine.SIMPLE):
|
||||
"""
|
||||
Initialize the template renderer.
|
||||
|
||||
Args:
|
||||
engine_type: The template engine to use.
|
||||
|
||||
Raises:
|
||||
TemplateError: If the specified engine is not available.
|
||||
"""
|
||||
self.engine_type = engine_type
|
||||
self.templates: Dict[str, Any] = {}
|
||||
self.engine: Union[Callable, Jinja2Environment, PystacheRenderer, None] = None
|
||||
self._initialize_engine()
|
||||
|
||||
def _initialize_engine(self) -> None:
|
||||
"""
|
||||
Initialize the template engine.
|
||||
|
||||
This method sets up the specified template engine and ensures that
|
||||
all necessary dependencies are available.
|
||||
|
||||
Raises:
|
||||
TemplateError: If the template engine cannot be initialized.
|
||||
"""
|
||||
if self.engine_type == TemplateEngine.SIMPLE:
|
||||
# Simple engine uses Python's str.format()
|
||||
self.engine = str.format
|
||||
elif self.engine_type == TemplateEngine.JINJA2:
|
||||
try:
|
||||
import jinja2
|
||||
|
||||
self.engine = jinja2.Environment(
|
||||
autoescape=False, trim_blocks=True, lstrip_blocks=True
|
||||
)
|
||||
except ImportError:
|
||||
raise TemplateError(
|
||||
"Jinja2 is not installed. Install it with 'pip install jinja2'."
|
||||
)
|
||||
elif self.engine_type == TemplateEngine.MUSTACHE:
|
||||
try:
|
||||
import pystache # type: ignore
|
||||
|
||||
self.engine = pystache.Renderer()
|
||||
except ImportError:
|
||||
raise TemplateError(
|
||||
"Pystache is not installed. Install it with 'pip install pystache'."
|
||||
)
|
||||
else:
|
||||
raise TemplateError(f"Unsupported template engine: {self.engine_type}")
|
||||
|
||||
@staticmethod
|
||||
def _render_simple_with_jinja_like(template: str, context: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Render the template string replacing {{ ... }} placeholders using a
|
||||
very small Jinja-like subset.
|
||||
"""
|
||||
|
||||
def _sub(match: re.Match[str]) -> str:
|
||||
expr = match.group(1).strip()
|
||||
|
||||
# Attempt to evaluate the placeholder as a Python expression first.
|
||||
# This enables support for constructs like `context.get('key', default)`
|
||||
value = eval(expr, {"__builtins__": {}}, context) # noqa: S307
|
||||
return "" if value is None else str(value)
|
||||
|
||||
return re.sub(r"\{\{\s*(.*?)\s*\}\}", _sub, template)
|
||||
|
||||
def register_template(self, name: str, template_str: str) -> None:
|
||||
"""
|
||||
Register a template with the renderer.
|
||||
|
||||
Args:
|
||||
name: The name of the template.
|
||||
template_str: The template string.
|
||||
|
||||
Raises:
|
||||
TemplateError: If the template cannot be registered.
|
||||
"""
|
||||
if not name:
|
||||
raise TemplateError("Template name cannot be empty")
|
||||
|
||||
if self.engine_type == TemplateEngine.JINJA2:
|
||||
try:
|
||||
# Precompile the template for Jinja2
|
||||
if hasattr(self.engine, "from_string"):
|
||||
self.templates[name] = self.engine.from_string(template_str) # type: ignore
|
||||
else:
|
||||
raise TemplateError("Jinja2 environment has no from_string method")
|
||||
except Exception as e:
|
||||
raise TemplateError(
|
||||
f"Failed to register Jinja2 template '{name}': {str(e)}"
|
||||
)
|
||||
else:
|
||||
# For other engines, just store the template string
|
||||
self.templates[name] = template_str
|
||||
|
||||
def render(self, template_name: str, context: Dict[str, Any]) -> str:
|
||||
"""
|
||||
Render a template with the given context.
|
||||
|
||||
Args:
|
||||
template_name: The name of the template to render.
|
||||
context: The context data to use when rendering the template.
|
||||
|
||||
Returns:
|
||||
The rendered template.
|
||||
|
||||
Raises:
|
||||
TemplateError: If the template cannot be rendered.
|
||||
"""
|
||||
if template_name not in self.templates:
|
||||
raise TemplateError(f"Template '{template_name}' not found")
|
||||
|
||||
template = self.templates[template_name]
|
||||
|
||||
try:
|
||||
if self.engine_type == TemplateEngine.SIMPLE:
|
||||
# 1. first pass – do simple {{ }} replacement
|
||||
result = self._render_simple_with_jinja_like(template, context)
|
||||
# 2. second pass – regular str.format() placeholders
|
||||
try:
|
||||
result = result.format(**context)
|
||||
except KeyError as e:
|
||||
missing = e.args[0]
|
||||
raise TemplateError(f"Missing template variable: {missing}") from e
|
||||
return result
|
||||
elif self.engine_type == TemplateEngine.JINJA2:
|
||||
# For Jinja2, template is already a compiled template
|
||||
if hasattr(template, "render"):
|
||||
return template.render(**context) # type: ignore
|
||||
else:
|
||||
raise TemplateError("Jinja2 template has no render method")
|
||||
elif self.engine_type == TemplateEngine.MUSTACHE:
|
||||
if hasattr(self.engine, "render"):
|
||||
return self.engine.render(template, context) # type: ignore
|
||||
else:
|
||||
raise TemplateError("Mustache renderer has no render method")
|
||||
else:
|
||||
raise TemplateError(f"Unsupported template engine: {self.engine_type}")
|
||||
except Exception as e:
|
||||
raise TemplateError(
|
||||
f"Failed to render template '{template_name}': {str(e)}"
|
||||
) from e
|
||||
|
||||
def render_string(
|
||||
self,
|
||||
template_str: str,
|
||||
context: Dict[str, Any],
|
||||
source_description: Optional[str] = None,
|
||||
) -> str:
|
||||
"""
|
||||
Render a template string with the given context.
|
||||
|
||||
Args:
|
||||
template_str: The template string to render.
|
||||
context: The context data to use when rendering the template.
|
||||
|
||||
Returns:
|
||||
The rendered template.
|
||||
|
||||
Raises:
|
||||
TemplateError: If the template cannot be rendered.
|
||||
"""
|
||||
try:
|
||||
if self.engine_type == TemplateEngine.SIMPLE:
|
||||
# 1. first pass – do simple {{ }} replacement
|
||||
result = self._render_simple_with_jinja_like(template_str, context)
|
||||
# 2. second pass – regular str.format() placeholders
|
||||
try:
|
||||
result = result.format(**context)
|
||||
except KeyError as e:
|
||||
missing = e.args[0]
|
||||
raise TemplateError(f"Missing template variable: {missing}") from e
|
||||
return result
|
||||
elif self.engine_type == TemplateEngine.JINJA2:
|
||||
if hasattr(self.engine, "from_string"):
|
||||
template = self.engine.from_string(template_str) # type: ignore
|
||||
return template.render(**context)
|
||||
else:
|
||||
raise TemplateError("Jinja2 environment has no from_string method")
|
||||
elif self.engine_type == TemplateEngine.MUSTACHE:
|
||||
if hasattr(self.engine, "render"):
|
||||
return self.engine.render(template_str, context) # type: ignore
|
||||
else:
|
||||
raise TemplateError("Mustache renderer has no render method")
|
||||
else:
|
||||
raise TemplateError(f"Unsupported template engine: {self.engine_type}")
|
||||
except Exception as e:
|
||||
desc = f" for {source_description}" if source_description else ""
|
||||
raise TemplateError(
|
||||
f"Failed to render template string{desc}: {str(e)}"
|
||||
) from e
|
||||
|
||||
def get_template(self, name: str) -> str:
|
||||
"""
|
||||
Get a template from the registry.
|
||||
|
||||
Args:
|
||||
name: The name of the template to get.
|
||||
|
||||
Returns:
|
||||
The template string or object.
|
||||
|
||||
Raises:
|
||||
TemplateError: If the template does not exist.
|
||||
"""
|
||||
if name not in self.templates:
|
||||
raise TemplateError(f"Template '{name}' not found")
|
||||
return self.templates[name]
|
||||
|
||||
def list_templates(self) -> List[str]:
|
||||
"""
|
||||
List all registered templates.
|
||||
|
||||
Returns:
|
||||
A list of template names.
|
||||
"""
|
||||
return list(self.templates.keys())
|
||||
@@ -0,0 +1,129 @@
|
||||
# Advanced agent network configuration example
|
||||
# This configuration defines a complex network with multiple agents and conditional routing
|
||||
cleveragents:
|
||||
default_router: advanced_analysis
|
||||
template_engine: JINJA2
|
||||
|
||||
# Agent definitions
|
||||
agents:
|
||||
researcher:
|
||||
type: llm
|
||||
config:
|
||||
model: gpt-4
|
||||
provider: openai
|
||||
api_key: ${OPENAI_API_KEY}
|
||||
temperature: 0.7
|
||||
prompt_reference: research_prompt
|
||||
|
||||
financial_analyst:
|
||||
type: llm
|
||||
config:
|
||||
model: claude-3-opus
|
||||
provider: anthropic
|
||||
api_key: ${ANTHROPIC_API_KEY}
|
||||
temperature: 0.2
|
||||
prompt_reference: financial_analysis_prompt
|
||||
|
||||
technical_analyst:
|
||||
type: llm
|
||||
config:
|
||||
model: gpt-4
|
||||
provider: openai
|
||||
temperature: 0.3
|
||||
prompt_reference: technical_analysis_prompt
|
||||
|
||||
summarizer:
|
||||
type: llm
|
||||
config:
|
||||
model: gpt-3.5-turbo
|
||||
provider: openai
|
||||
temperature: 0.4
|
||||
prompt_reference: summary_prompt
|
||||
|
||||
fact_checker:
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: web_search
|
||||
config:
|
||||
search_engine: google
|
||||
api_key: ${SEARCH_API_KEY}
|
||||
- name: calculator
|
||||
|
||||
# Prompt templates
|
||||
prompts:
|
||||
research_prompt:
|
||||
content: |
|
||||
You are a research specialist. Your task is to gather information on {{topic}}.
|
||||
Focus on the following aspects:
|
||||
- Historical context
|
||||
- Current trends
|
||||
- Future predictions
|
||||
|
||||
Provide a comprehensive but concise summary.
|
||||
|
||||
financial_analysis_prompt:
|
||||
content: |
|
||||
You are a financial analyst. Analyze the following research from a financial perspective:
|
||||
|
||||
{{research_results}}
|
||||
|
||||
Consider market trends, investment opportunities, and financial risks.
|
||||
Provide your analysis in a structured format with bullet points for key findings.
|
||||
|
||||
technical_analysis_prompt:
|
||||
content: |
|
||||
You are a technical analyst. Analyze the following research from a technical perspective:
|
||||
|
||||
{{research_results}}
|
||||
|
||||
Consider technological trends, implementation challenges, and technical opportunities.
|
||||
Provide your analysis in a structured format with bullet points for key findings.
|
||||
|
||||
summary_prompt:
|
||||
content: |
|
||||
You are a professional summarizer. Create a concise summary of the following analysis:
|
||||
|
||||
{{analysis_results}}
|
||||
|
||||
Your summary should be clear, comprehensive, and highlight the most important points.
|
||||
Keep it under 500 words.
|
||||
|
||||
# Routing configuration
|
||||
routes:
|
||||
advanced_analysis:
|
||||
- source: input
|
||||
destination: researcher
|
||||
transform: '{"topic": "{{message}}"}'
|
||||
|
||||
- source: researcher
|
||||
destination: financial_analyst
|
||||
condition:
|
||||
type: keywords
|
||||
keywords: ["finance", "market", "investment", "economic", "money", "cost"]
|
||||
match_all: false
|
||||
transform: '{"research_results": "{{message}}"}'
|
||||
|
||||
- source: researcher
|
||||
destination: technical_analyst
|
||||
# This route is taken if the first one is not.
|
||||
# The default behavior is to try routes in order.
|
||||
transform: '{"research_results": "{{message}}"}'
|
||||
|
||||
- source: financial_analyst
|
||||
destination: summarizer
|
||||
transform: '{"analysis_results": "{{message}}"}'
|
||||
|
||||
- source: technical_analyst
|
||||
destination: summarizer
|
||||
transform: '{"analysis_results": "{{message}}"}'
|
||||
|
||||
- source: summarizer
|
||||
destination: output
|
||||
|
||||
# Global context variables
|
||||
context:
|
||||
global:
|
||||
company_name: "CleverThis"
|
||||
industry: "Technology"
|
||||
depth: "comprehensive"
|
||||
@@ -0,0 +1,64 @@
|
||||
# Basic agent network configuration example
|
||||
# This configuration defines a simple network with two agents: a researcher and an analyst
|
||||
cleveragents:
|
||||
default_router: research_and_analysis
|
||||
template_engine: JINJA2
|
||||
|
||||
# Agent definitions
|
||||
agents:
|
||||
researcher:
|
||||
type: llm
|
||||
config:
|
||||
model: gpt-4
|
||||
provider: openai
|
||||
api_key: ${OPENAI_API_KEY}
|
||||
temperature: 0.7
|
||||
prompt_reference: research_prompt
|
||||
|
||||
analyst:
|
||||
type: llm
|
||||
config:
|
||||
model: claude-3-opus
|
||||
provider: anthropic
|
||||
temperature: 0.2
|
||||
prompt_reference: analysis_prompt
|
||||
|
||||
# Prompt templates
|
||||
prompts:
|
||||
research_prompt:
|
||||
content: |
|
||||
You are a research specialist. Your task is to gather information on {{topic}}.
|
||||
Focus on the following aspects:
|
||||
- Historical context
|
||||
- Current trends
|
||||
- Future predictions
|
||||
|
||||
Provide a comprehensive but concise summary.
|
||||
|
||||
analysis_prompt:
|
||||
content: |
|
||||
You are a data analyst. Analyze the following research and extract key insights:
|
||||
|
||||
{{research_results}}
|
||||
|
||||
Provide your analysis in a structured format with bullet points for key findings.
|
||||
|
||||
# Routing configuration
|
||||
routes:
|
||||
research_and_analysis:
|
||||
- source: input
|
||||
destination: researcher
|
||||
transform: '{"topic": "{{message}}"}'
|
||||
|
||||
- source: researcher
|
||||
destination: analyst
|
||||
transform: '{"research_results": "{{message}}"}'
|
||||
|
||||
- source: analyst
|
||||
destination: output
|
||||
|
||||
# Global context variables
|
||||
context:
|
||||
global:
|
||||
company_name: "CleverThis"
|
||||
industry: "Technology"
|
||||
@@ -0,0 +1,140 @@
|
||||
cleveragents:
|
||||
default_router: main_workflow
|
||||
template_engine: JINJA2
|
||||
|
||||
# Agent definitions
|
||||
agents:
|
||||
# Main research agent
|
||||
researcher:
|
||||
type: llm
|
||||
config:
|
||||
model: gpt-4
|
||||
provider: openai
|
||||
temperature: 0.7
|
||||
prompt_reference: research_prompt
|
||||
|
||||
# --- Agents used within the CompositeAgent's sub-network ---
|
||||
financial_analyst:
|
||||
type: llm
|
||||
config:
|
||||
model: claude-3-opus-latest
|
||||
provider: anthropic
|
||||
temperature: 0.2
|
||||
prompt_reference: financial_analysis_prompt
|
||||
|
||||
technical_analyst:
|
||||
type: llm
|
||||
config:
|
||||
model: gpt-4
|
||||
provider: openai
|
||||
temperature: 0.3
|
||||
prompt_reference: technical_analysis_prompt
|
||||
|
||||
summarizer:
|
||||
type: llm
|
||||
config:
|
||||
model: gpt-3.5-turbo
|
||||
provider: openai
|
||||
temperature: 0.4
|
||||
prompt_reference: summary_prompt
|
||||
|
||||
# --- CompositeAgent Definition ---
|
||||
# This agent encapsulates the analysis and summarization workflow.
|
||||
# It uses the 'route' strategy to process messages using its own router.
|
||||
analysis_unit:
|
||||
type: composite
|
||||
config:
|
||||
strategy: route
|
||||
route: analysis_router # Specifies the router for the sub-network
|
||||
|
||||
# Prompt templates
|
||||
prompts:
|
||||
research_prompt:
|
||||
content: |
|
||||
You are a research specialist. Your task is to gather information on {{ topic }}.
|
||||
Focus on the following aspects:
|
||||
- Historical context
|
||||
- Current trends
|
||||
- Future predictions
|
||||
|
||||
Provide a comprehensive but concise summary.
|
||||
|
||||
financial_analysis_prompt:
|
||||
content: |
|
||||
You are a financial analyst. Analyze the following research from a financial perspective:
|
||||
|
||||
{{ research_results }}
|
||||
|
||||
Consider market trends, investment opportunities, and financial risks.
|
||||
Provide your analysis in a structured format with bullet points for key findings.
|
||||
|
||||
technical_analysis_prompt:
|
||||
content: |
|
||||
You are a technical analyst. Analyze the following research from a technical perspective:
|
||||
|
||||
{{ research_results }}
|
||||
|
||||
Consider technological trends, implementation challenges, and technical opportunities.
|
||||
Provide your analysis in a structured format with bullet points for key findings.
|
||||
|
||||
summary_prompt:
|
||||
content: |
|
||||
You are a professional summarizer. Create a concise summary of the following analysis:
|
||||
|
||||
{{ analysis_results }}
|
||||
|
||||
Your summary should be clear, comprehensive, and highlight the most important points.
|
||||
Keep it under 500 words.
|
||||
|
||||
# Routing configuration
|
||||
routes:
|
||||
# Main workflow router
|
||||
main_workflow:
|
||||
- source: input
|
||||
destination: researcher
|
||||
context: '{"topic": "{{ message }}"}'
|
||||
|
||||
# The output from the researcher is sent to the composite 'analysis_unit'.
|
||||
- source: researcher
|
||||
destination: analysis_unit
|
||||
# The message from 'researcher' becomes the input for the 'analysis_router'.
|
||||
|
||||
# The final output from the 'analysis_unit' is sent to the main output.
|
||||
- source: analysis_unit
|
||||
destination: output
|
||||
|
||||
# Sub-network router, used by the 'analysis_unit' CompositeAgent
|
||||
analysis_router:
|
||||
# The 'input' here is the message received by the 'analysis_unit' agent.
|
||||
# A condition routes the message to the appropriate analyst.
|
||||
- source: input
|
||||
destination: financial_analyst
|
||||
condition:
|
||||
type: keywords
|
||||
keywords: [ "finance", "market", "investment", "economic", "money", "cost" ]
|
||||
match_all: false
|
||||
context: '{"research_results": "{{ message }}"}'
|
||||
|
||||
# If the financial condition is not met, this route is taken.
|
||||
- source: input
|
||||
destination: technical_analyst
|
||||
context: '{"research_results": "{{ message }}"}'
|
||||
|
||||
# The results from either analyst are routed to the summarizer.
|
||||
- source: financial_analyst
|
||||
destination: summarizer
|
||||
context: '{"analysis_results": "{{ message }}"}'
|
||||
|
||||
- source: technical_analyst
|
||||
destination: summarizer
|
||||
context: '{"analysis_results": "{{ message }}"}'
|
||||
|
||||
# The summarizer's output becomes the final result of this sub-network.
|
||||
- source: summarizer
|
||||
destination: output
|
||||
|
||||
# Global context variables
|
||||
context:
|
||||
global:
|
||||
company_name: "CleverThis"
|
||||
industry: "Technology"
|
||||
@@ -0,0 +1,44 @@
|
||||
cleveragents:
|
||||
default_router: context_demo_flow
|
||||
template_engine: JINJA2
|
||||
|
||||
agents:
|
||||
step1_agent:
|
||||
type: chain
|
||||
config:
|
||||
steps:
|
||||
- "Initial message processed."
|
||||
|
||||
step2_agent:
|
||||
type: chain
|
||||
config:
|
||||
steps:
|
||||
- "Intermediate step processed."
|
||||
|
||||
# Define the routing logic.
|
||||
routes:
|
||||
context_demo_flow:
|
||||
# Route 1: Create a new context variable.
|
||||
# The user's input is used to create a 'topic' variable in the context.
|
||||
- from: input
|
||||
to: step1_agent
|
||||
context: '{"topic": "learning about {{ message }}"}'
|
||||
# The transform here can already see the new 'topic' variable.
|
||||
transform: "The topic is '{{ context.topic }}'. Passing to step 1."
|
||||
|
||||
# Route 2: Modify an existing variable and add another.
|
||||
# We update 'topic' and add a new 'status' variable.
|
||||
- from: step1_agent
|
||||
to: step2_agent
|
||||
context: '{"topic": "advanced {{ context.topic }}", "status": "in_progress"}'
|
||||
transform: "The topic is now '{{ context.topic }}' and status is '{{ context.status }}'."
|
||||
|
||||
# Route 3: Delete a variable from the context.
|
||||
# Setting 'topic' to null will remove it from the context.
|
||||
# We also update the 'status' variable.
|
||||
- from: step2_agent
|
||||
to: output
|
||||
context: '{"topic": null, "status": "completed"}'
|
||||
# The transform for the final output will not have access to 'topic',
|
||||
# but it can see the final value of 'status'.
|
||||
transform: "Processing finished. Final status is '{{ context.status }}'. The 'topic' variable has been removed."
|
||||
@@ -0,0 +1,170 @@
|
||||
# CleverAgents Multi-Agent Workflow Example
|
||||
#
|
||||
# This configuration file demonstrates a more complex agent network.
|
||||
# It showcases a multi-agent workflow where an initial "classifier" agent
|
||||
# routes tasks to specialized agents (a calculator and a web searcher),
|
||||
# and a final "responder" agent synthesizes the results into a cohesive answer.
|
||||
#
|
||||
# To run this example, you will need to set the OPENAI_API_KEY environment variable.
|
||||
# Example usage from the command line:
|
||||
#
|
||||
# export OPENAI_API_KEY="your_api_key_here"
|
||||
# cleveragents run --config src/examples/multi_agent_workflow.yaml --prompt "What is the capital of France?"
|
||||
# cleveragents run --config src/examples/multi_agent_workflow.yaml --prompt "Calculate 25 * 4"
|
||||
|
||||
cleveragents:
|
||||
version: "1.0"
|
||||
logging:
|
||||
level: "INFO" # Set to "DEBUG" for more verbose output
|
||||
template_engine: "JINJA2"
|
||||
default_router: "main_workflow"
|
||||
|
||||
# Agent Definitions
|
||||
# -----------------
|
||||
# This section defines all the agents that will be part of the network.
|
||||
# Each agent has a unique name, a type, and a specific configuration.
|
||||
agents:
|
||||
user_input_classifier:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
role: |
|
||||
You are a classification expert. Your task is to analyze the user's prompt and determine its primary intent.
|
||||
Respond with ONLY ONE of the following keywords based on the prompt:
|
||||
- CALCULATION: If the prompt requires a calculation.
|
||||
- GENERAL: For all other prompts.
|
||||
temperature: 0.0 # Low temperature for deterministic classification
|
||||
|
||||
calculator_extractor:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
role: |
|
||||
The user is providing a prompt that requires a calculation to be performed. Respond with ONLY the
|
||||
calculation that needs to be performed without any words or texts present. Make sure the response uses
|
||||
numbers and operators in algebraic format, no natural language. Here are some examples of prompts and
|
||||
the responses I would expect:
|
||||
- Prompt: "If I have one apple and someone gives me five more, how many apples do I have?"
|
||||
Response: "1+5"
|
||||
- Prompt: "What is six time 7"
|
||||
Response: "6*7"
|
||||
- Prompt: "5/9"
|
||||
Response: "5/9"
|
||||
temperature: 0.0 # Low temperature for deterministic classification
|
||||
calculator_processor:
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: calculator
|
||||
calculator_responder:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4o-mini
|
||||
role: |
|
||||
You are a response synthesizer. Your job is to take the raw output from a tool which performs the
|
||||
the mathematical calculation present in a users response, along with the users original prompt, and
|
||||
construct a natural language response that provides the calculated answer as part of a friendly natural
|
||||
language response to answer the question. Make sure your response restates the equation and the
|
||||
calculation result as presented here. The equation will be provided by appending it to the end of
|
||||
the original user prompt with a line beginning with "EQUATION: ". Similarly the calculation result will
|
||||
be provided by appending it and the line will begin with "CALCULATION: ".
|
||||
|
||||
Here is an example of a prompt and the responses I might expect:
|
||||
- Prompt:
|
||||
If I have one apple and someone gives me five more, how many apples do I have?
|
||||
EQUATION: 1+5
|
||||
CALCULATION: 6
|
||||
- Response:
|
||||
This can be solved by adding 1+5 which results in 6, therefore you would have 6 apples.
|
||||
temperature: 0.7
|
||||
knowledge_checker:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4o-mini
|
||||
role: |
|
||||
You are friendly and helpful assistant. Your task is to analyze the user's prompt and determine if you
|
||||
have enough knowledge to answer the question like an expert on the topic.
|
||||
|
||||
Respond with ONLY ONE of the following keywords based on the prompt:
|
||||
- INFORMED: If you can answer the prompt with enough knowledge to give an expert quality response.
|
||||
- IGNORANT: For all other prompts.
|
||||
temperature: 0.7
|
||||
web_search_agent:
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: web_search
|
||||
config:
|
||||
# Uncomment and set the values below, or use environment variables.
|
||||
# api_key: "${GOOGLE_API_KEY}"
|
||||
# cx: "${GOOGLE_CX}"
|
||||
search_engine: "google"
|
||||
web_search_responder:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4o-mini
|
||||
role: |
|
||||
You are a response synthesizer. Your job is to take the raw output from a web search, along with the
|
||||
original prompt a user provided, which the web search is relevant to, and provide an appropriate
|
||||
response while incorporating the search data into that response as additional knowledge to provide a
|
||||
more well rounded answer than you could on your own. You will be provided the original users response
|
||||
followed by "SEARCH_RESULTS: " on its own line, which is then followed by all the information from
|
||||
the search results.
|
||||
temperature: 0.7
|
||||
general_responder:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4o-mini
|
||||
role: "You are a friendly and helpful assistant. Provide clear and concise answers to the user's questions."
|
||||
temperature: 0.5
|
||||
|
||||
|
||||
# Routes Definition
|
||||
# -----------------
|
||||
# The routes section defines named workflows for message passing between agents.
|
||||
# Each named route contains a list of rules that specify a source, a destination,
|
||||
# and a condition for the route to be taken.
|
||||
routes:
|
||||
main_workflow:
|
||||
- source: input
|
||||
destination: user_input_classifier
|
||||
transform: "{{ context.get('initial_message', message) }}"
|
||||
- source: user_input_classifier
|
||||
destination: calculator_extractor
|
||||
condition: "'CALCULATION' in message"
|
||||
transform: "{{ context.get('initial_message', message) }}"
|
||||
- source: calculator_extractor
|
||||
destination: calculator_processor
|
||||
- source: calculator_processor
|
||||
destination: calculator_responder
|
||||
transform: |-
|
||||
{{ context.initial_message }}
|
||||
EQUATION: {{ context.history[-2].message }}
|
||||
CALCULATION: {{ message }}
|
||||
- source: user_input_classifier
|
||||
destination: knowledge_checker
|
||||
condition: "'GENERAL' in message"
|
||||
transform: "{{ context.get('initial_message', message) }}"
|
||||
- source: knowledge_checker
|
||||
destination: general_responder
|
||||
condition: "'INFORMED' in message"
|
||||
transform: "{{ context.get('initial_message', message) }}"
|
||||
- source: knowledge_checker
|
||||
destination: web_search_agent
|
||||
condition: "'IGNORANT' in message"
|
||||
transform: "{{ context.get('initial_message', message) }}"
|
||||
- source: web_search_agent
|
||||
destination: web_search_responder
|
||||
transform: |-
|
||||
{{ context.initial_message }}
|
||||
SEARCH_RESULTS: {{ message }}
|
||||
- source: web_search_responder
|
||||
destination: output
|
||||
- source: calculator_responder
|
||||
destination: output
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
from cleveragents.agents.tool import Tool
|
||||
from cleveragents.core.exceptions import ExecutionError
|
||||
|
||||
|
||||
class CustomToolTool(Tool):
|
||||
"""Custom tool implementation for testing."""
|
||||
|
||||
def __init__(self, config=None):
|
||||
super().__init__("custom_tool", "A custom tool for testing", config or {})
|
||||
|
||||
async def execute(self, input_data, context=None):
|
||||
if context is None:
|
||||
context = {}
|
||||
if "fail" in input_data.lower():
|
||||
raise ExecutionError("Tool execution failed as requested")
|
||||
return f"Custom tool processed: {input_data}"
|
||||
@@ -0,0 +1,34 @@
|
||||
Feature: Agent Base Class Coverage
|
||||
In order to ensure the robustness of the base Agent classes
|
||||
As a developer
|
||||
I want to test all code paths in `src/cleveragents/agents/base.py`
|
||||
|
||||
Scenario: Agent metadata includes model and provider
|
||||
Given an Agent is initialized with model and provider in config
|
||||
When I get the agent's metadata
|
||||
Then the metadata should contain the model "test-model"
|
||||
And the metadata should contain the provider "test-provider"
|
||||
|
||||
Scenario: Agent metadata includes only model
|
||||
Given an Agent is initialized with only a model in config
|
||||
When I get the agent's metadata
|
||||
Then the metadata should contain the model "test-model"
|
||||
And the metadata should not contain a provider
|
||||
|
||||
Scenario: Agent metadata includes only provider
|
||||
Given an Agent is initialized with only a provider in config
|
||||
When I get the agent's metadata
|
||||
Then the metadata should not contain a model
|
||||
And the metadata should contain the provider "test-provider"
|
||||
|
||||
Scenario: AgentWithMemory handles invalid memory type on load
|
||||
Given an AgentWithMemory instance
|
||||
When I try to load memory with a non-dictionary value
|
||||
Then an AgentCreationError should be raised
|
||||
|
||||
Scenario: AgentWithMemory saves and loads memory correctly
|
||||
Given an AgentWithMemory instance
|
||||
And I save the initial memory state
|
||||
When I modify the agent's memory
|
||||
And I load the saved memory state
|
||||
Then the agent's memory should be restored to the initial state
|
||||
@@ -0,0 +1,9 @@
|
||||
Feature: Agent Module
|
||||
As a developer using CleverAgents
|
||||
I want to use the agent module
|
||||
So that I can access agent classes
|
||||
|
||||
Scenario: Import agent classes
|
||||
Given I import the agent module
|
||||
Then I should be able to access the Agent class
|
||||
And I should be able to access the AgentWithMemory class
|
||||
@@ -0,0 +1,39 @@
|
||||
Feature: AgentNetwork functionality
|
||||
|
||||
Scenario: Initialize AgentNetwork without configuration
|
||||
Given I create an AgentNetwork without configuration files
|
||||
Then the AgentNetwork should have a ConfigurationManager
|
||||
And the AgentNetwork's agent factory should be None
|
||||
And the AgentNetwork's router should be None
|
||||
|
||||
Scenario: Initialize AgentNetwork with configuration
|
||||
Given a configuration file named "config.yaml" with content:
|
||||
"""
|
||||
version: "1.0"
|
||||
agents:
|
||||
- name: agent1
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
"""
|
||||
When I create an AgentNetwork with the configuration file "config.yaml"
|
||||
Then the configuration should be loaded and validated
|
||||
|
||||
Scenario: Process a message with an initialized router
|
||||
Given an AgentNetwork
|
||||
And the AgentNetwork has a mock router that responds based on input
|
||||
When I process the message "hello" with the AgentNetwork
|
||||
Then the router's process_message method should be called with "hello"
|
||||
And the response should be "mock response to hello"
|
||||
|
||||
Scenario: Attempt to process a message without an initialized router
|
||||
Given an AgentNetwork
|
||||
When I try to process a message with the AgentNetwork
|
||||
Then an AssertionError should be raised with message "Router not initialized. Load configuration first."
|
||||
|
||||
Scenario: Get the router from the AgentNetwork
|
||||
Given an AgentNetwork
|
||||
And the AgentNetwork has a mock router
|
||||
When I get the router from the AgentNetwork
|
||||
Then the returned router should be the mock router
|
||||
@@ -0,0 +1,21 @@
|
||||
Feature: Agent Message Processing
|
||||
As a user of CleverAgents
|
||||
I want to send messages to agents
|
||||
So that I can get intelligent responses
|
||||
|
||||
Scenario: Process a message with an LLM agent
|
||||
Given an LLM agent is configured
|
||||
When I send the message "Hello, how are you?" to the agent
|
||||
Then I should receive a non-empty response
|
||||
And the response should contain relevant information
|
||||
|
||||
Scenario: Process a message with a chain agent
|
||||
Given a chain agent with multiple steps is configured
|
||||
When I send the message "Process this in steps" to the agent
|
||||
Then each step in the chain should be executed
|
||||
And I should receive the final processed result
|
||||
|
||||
Scenario: Use a tool agent to perform calculations
|
||||
Given a tool agent with calculator capability is configured
|
||||
When I send the message "calculate: 2 + 2" to the agent
|
||||
Then I should receive the result "4"
|
||||
@@ -0,0 +1,6 @@
|
||||
Feature: Agent and Route Name Validation
|
||||
|
||||
Scenario: Agent and Route names must not conflict
|
||||
Given a configuration where an agent and a route have the same name "shared_name"
|
||||
When I initialize the application
|
||||
Then a ConfigurationError should be raised with a message about unique names for "shared_name"
|
||||
@@ -0,0 +1,35 @@
|
||||
Feature: API Key Handling for Agents and Tools
|
||||
|
||||
Scenario: LLM Agent uses API key from config when present
|
||||
Given an LLM agent config for provider "openai" with api_key "config-key"
|
||||
And the environment variable "OPENAI_API_KEY" is set to "env-key"
|
||||
When I initialize the LLM agent with config
|
||||
Then the agent's API key should be "config-key"
|
||||
|
||||
Scenario: LLM Agent falls back to environment variable when config key is missing
|
||||
Given an LLM agent config for provider "openai" with api_key "null"
|
||||
And the environment variable "OPENAI_API_KEY" is set to "env-key"
|
||||
When I initialize the LLM agent with config
|
||||
Then the agent's API key should be "env-key"
|
||||
|
||||
Scenario: LLM Agent falls back to environment variable when config key is empty
|
||||
Given an LLM agent config for provider "openai" with api_key "empty"
|
||||
And the environment variable "OPENAI_API_KEY" is set to "env-key"
|
||||
When I initialize the LLM agent with config
|
||||
Then the agent's API key should be "env-key"
|
||||
|
||||
Scenario: LLM Agent raises error if no API key is found
|
||||
Given an LLM agent config for provider "openai" with api_key "null"
|
||||
And the environment variable "OPENAI_API_KEY" is unset
|
||||
When I initialize the LLM agent with config
|
||||
Then a ConfigurationError should be raised containing "OPENAI_API_KEY"
|
||||
|
||||
Scenario: WebSearchTool uses API key from environment variable
|
||||
Given the environment variable "GOOGLE_SEARCH_API_KEY" is set to "env-key-for-search"
|
||||
When I initialize a WebSearchTool with a missing API key
|
||||
Then the tool's API key should be "env-key-for-search"
|
||||
|
||||
Scenario: WebSearchTool raises error if no API key is found
|
||||
Given the environment variable "GOOGLE_SEARCH_API_KEY" is unset
|
||||
When I try to initialize a WebSearchTool with a missing API key
|
||||
Then a ConfigurationError should be raised containing "GOOGLE_SEARCH_API_KEY"
|
||||
@@ -0,0 +1,19 @@
|
||||
Feature: Chain Agent Functionality
|
||||
As a developer
|
||||
I want to test the ChainAgent
|
||||
So that I can ensure it works correctly
|
||||
|
||||
Scenario: Chain agent processes messages through multiple steps
|
||||
Given a chain agent with multiple processing steps
|
||||
When I send a message to the chain agent
|
||||
Then the message should be processed through all steps
|
||||
|
||||
Scenario: Chain agent with custom step implementation
|
||||
Given a chain agent with custom step implementations
|
||||
When I send a message requiring custom processing
|
||||
Then the message should be processed using the custom implementations
|
||||
|
||||
Scenario: Chain agent capabilities
|
||||
Given a chain agent with various capabilities
|
||||
When I request the agent's capabilities
|
||||
Then the capabilities should include chain-processing
|
||||
@@ -0,0 +1,19 @@
|
||||
Feature: Interactive Commands
|
||||
As a user of CleverAgents
|
||||
I want to use commands during interactive sessions
|
||||
So that I can control the session behavior
|
||||
|
||||
Scenario: Parse and execute help command
|
||||
Given an interactive session is active
|
||||
When I enter the "/help" command
|
||||
Then I should see the help information
|
||||
|
||||
Scenario: Parse and execute history command
|
||||
Given an interactive session with some history
|
||||
When I enter the "/history" command
|
||||
Then I should see the conversation history
|
||||
|
||||
Scenario: Parse and execute exit command
|
||||
Given an interactive session is active
|
||||
When I enter the "/exit" command
|
||||
Then the session should end
|
||||
@@ -0,0 +1,44 @@
|
||||
Feature: CompositeAgent Advanced Strategies
|
||||
As a developer, I want to use CompositeAgents with advanced strategies
|
||||
that combine agents and routes as steps, to build complex and flexible
|
||||
agent networks.
|
||||
|
||||
Scenario: CompositeAgent with sequential strategy using only routes
|
||||
Given a configuration for a CompositeAgent "SequentialRouterAgent" with sequential strategy and steps "RouteA, RouteB"
|
||||
And a router named "RouteA" that appends "->Processed by RouteA"
|
||||
And a router named "RouteB" that appends "->Processed by RouteB"
|
||||
When I create the advanced CompositeAgent named "SequentialRouterAgent"
|
||||
And I process the message "Start" with the agent, providing the routers in the context
|
||||
Then the response should be "Start->Processed by RouteA->Processed by RouteB"
|
||||
|
||||
Scenario: CompositeAgent with sequential strategy using mixed agents and routes
|
||||
Given a configuration for a CompositeAgent "SequentialMixedAgent" with sequential strategy and steps "AgentA, RouteB, AgentC"
|
||||
And an agent named "AgentA" that appends "->Processed by AgentA"
|
||||
And a router named "RouteB" that appends "->Processed by RouteB"
|
||||
And an agent named "AgentC" that appends "->Processed by AgentC"
|
||||
When I create the advanced CompositeAgent named "SequentialMixedAgent"
|
||||
And I process the message "Start" with the agent, providing the routers in the context
|
||||
Then the response should be "Start->Processed by AgentA->Processed by RouteB->Processed by AgentC"
|
||||
|
||||
Scenario: CompositeAgent with parallel strategy using only routes
|
||||
Given a configuration for a CompositeAgent "ParallelRouterAgent" with parallel strategy and steps "RouteA, RouteB"
|
||||
And a router named "RouteA" that returns "Response from RouteA"
|
||||
And a router named "RouteB" that returns "Response from RouteB"
|
||||
When I create the advanced CompositeAgent named "ParallelRouterAgent"
|
||||
And I process the message "Test" with the agent, providing the routers in the context
|
||||
Then the response should be a JSON object with keys "RouteA, RouteB" and values "Response from RouteA, Response from RouteB"
|
||||
|
||||
Scenario: CompositeAgent with parallel strategy using mixed agents and routes
|
||||
Given a configuration for a CompositeAgent "ParallelMixedAgent" with parallel strategy and steps "AgentA, RouteB"
|
||||
And an agent named "AgentA" that returns "Response from AgentA"
|
||||
And a router named "RouteB" that returns "Response from RouteB"
|
||||
When I create the advanced CompositeAgent named "ParallelMixedAgent"
|
||||
And I process the message "Test" with the agent, providing the routers in the context
|
||||
Then the response should be a JSON object with keys "AgentA, RouteB" and values "Response from AgentA, Response from RouteB"
|
||||
|
||||
Scenario: CompositeAgent with route strategy fails when route name is an agent
|
||||
Given a configuration for a CompositeAgent "InvalidRouterAgent" with route strategy pointing to agent "MyAgent"
|
||||
And an agent named "MyAgent" that returns "This should not be called"
|
||||
When I create the advanced CompositeAgent named "InvalidRouterAgent"
|
||||
And I try to process the message "hello" with the agent, providing an empty router context
|
||||
Then an ExecutionError should be raised with a message about the missing route "MyAgent"
|
||||
@@ -0,0 +1,85 @@
|
||||
Feature: CompositeAgent Step Aliasing
|
||||
|
||||
As a user, I want to define aliases for steps in a CompositeAgent
|
||||
to handle cases where the same agent or router is used multiple times
|
||||
in a parallel strategy, ensuring unique keys in the output.
|
||||
|
||||
Background:
|
||||
Given a mock agent named "agent_A" that returns "Response from A"
|
||||
And a mock agent named "agent_B" that returns "Response from B"
|
||||
And a mock router named "router_C" that appends " - processed by C"
|
||||
|
||||
Scenario: Parallel execution with a simple list of agent names
|
||||
Given a CompositeAgent configuration named "parallel_simple" with "parallel" strategy and steps:
|
||||
"""
|
||||
- agent_A
|
||||
- agent_B
|
||||
"""
|
||||
When I create the CompositeAgent named "parallel_simple"
|
||||
And I process the message "test" with the agent
|
||||
Then the JSON response should be:
|
||||
"""
|
||||
{
|
||||
"agent_A": "Response from A",
|
||||
"agent_B": "Response from B"
|
||||
}
|
||||
"""
|
||||
|
||||
Scenario: Parallel execution with a mixed list of agent names and aliases
|
||||
Given a CompositeAgent configuration named "parallel_mixed" with "parallel" strategy and steps:
|
||||
"""
|
||||
- agent_A
|
||||
- first_B: agent_B
|
||||
- second_B: agent_B
|
||||
"""
|
||||
When I create the CompositeAgent named "parallel_mixed"
|
||||
And I process the message "test" with the agent
|
||||
Then the JSON response should be:
|
||||
"""
|
||||
{
|
||||
"agent_A": "Response from A",
|
||||
"first_B": "Response from B",
|
||||
"second_B": "Response from B"
|
||||
}
|
||||
"""
|
||||
|
||||
Scenario: Parallel execution with a mix of agents, aliased agents, and a router
|
||||
Given a CompositeAgent configuration named "parallel_complex" with "parallel" strategy and steps:
|
||||
"""
|
||||
- agent_A
|
||||
- aliased_router: router_C
|
||||
- agent_B
|
||||
"""
|
||||
When I create the CompositeAgent named "parallel_complex"
|
||||
And I process the message "input" with the agent
|
||||
Then the JSON response should be:
|
||||
"""
|
||||
{
|
||||
"agent_A": "Response from A",
|
||||
"aliased_router": "input - processed by C",
|
||||
"agent_B": "Response from B"
|
||||
}
|
||||
"""
|
||||
|
||||
Scenario: Sequential execution with a mixed list of agent names and aliases
|
||||
Given a mock agent named "append_A" that appends "->A"
|
||||
And a mock agent named "append_B" that appends "->B"
|
||||
And a CompositeAgent configuration named "sequential_mixed" with "sequential" strategy and steps:
|
||||
"""
|
||||
- append_A
|
||||
- aliased_B: append_B
|
||||
- append_A
|
||||
"""
|
||||
When I create the CompositeAgent named "sequential_mixed"
|
||||
And I process the message "start" with the agent
|
||||
Then the text response should be "start->A->B->A"
|
||||
|
||||
Scenario: Parallel execution with an invalid step format raises an error
|
||||
Given a CompositeAgent configuration named "parallel_invalid" with "parallel" strategy and steps:
|
||||
"""
|
||||
- agent_A
|
||||
- alias1: agent_B
|
||||
alias2: agent_B
|
||||
"""
|
||||
When I try to process the message "test" with agent "parallel_invalid"
|
||||
Then a ConfigurationError should be raised with message containing "must have exactly one key-value pair"
|
||||
@@ -0,0 +1,17 @@
|
||||
Feature: CompositeAgent Parallel Strategy
|
||||
|
||||
As a developer, I want to use a CompositeAgent with a parallel strategy
|
||||
so that I can process a message with multiple agents concurrently and
|
||||
combine their outputs in different formats.
|
||||
|
||||
Scenario: CompositeAgent with parallel strategy and text output
|
||||
Given a CompositeAgent configuration with parallel strategy and format "text"
|
||||
When I create the CompositeAgent named "ParallelAgent"
|
||||
And I process the message "Test" with the composite agent
|
||||
Then the response should be the plain text combination of child agent outputs
|
||||
|
||||
Scenario: CompositeAgent with parallel strategy and JSON output
|
||||
Given a CompositeAgent configuration with parallel strategy and format "json"
|
||||
When I create the CompositeAgent named "ParallelAgent"
|
||||
And I process the message "Test" with the composite agent
|
||||
Then the response should be a JSON object with child agent outputs
|
||||
@@ -0,0 +1,35 @@
|
||||
Feature: CompositeAgent Route Strategy
|
||||
|
||||
As a developer, I want to use the "route" strategy in a CompositeAgent
|
||||
to encapsulate a routing flow, so that I can build modular and reusable
|
||||
agent networks.
|
||||
|
||||
Scenario: CompositeAgent successfully processes a message using the route strategy
|
||||
Given a configuration for a CompositeAgent with a "route" strategy pointing to "test_route"
|
||||
And a router named "test_route" that returns "Response from test_route"
|
||||
When I create the CompositeAgent named "router_agent"
|
||||
And I process the message "hello" with the agent, providing the router in the context
|
||||
Then the response should be "Response from test_route"
|
||||
|
||||
Scenario: CompositeAgent fails when the specified route does not exist
|
||||
Given a configuration for a CompositeAgent with a "route" strategy pointing to "non_existent_route"
|
||||
And an empty dictionary of routers
|
||||
When I create the CompositeAgent named "router_agent"
|
||||
And I try to process the message "hello" with the agent, providing the routers in the context
|
||||
Then an ExecutionError should be raised with a message about the missing route "non_existent_route"
|
||||
|
||||
Scenario: CompositeAgent fails when the routers are not provided in the context
|
||||
Given a configuration for a CompositeAgent with a "route" strategy pointing to "test_route"
|
||||
When I create the CompositeAgent named "router_agent"
|
||||
And I try to process the message "hello" with the agent without providing routers in the context
|
||||
Then an ExecutionError should be raised with a message about missing routers in context
|
||||
|
||||
Scenario: CompositeAgent configuration fails if 'route' strategy is used with 'steps' section
|
||||
Given a configuration for a CompositeAgent with a "route" strategy and a "steps" section
|
||||
When I try to create the CompositeAgent named "router_agent"
|
||||
Then a ConfigurationError should be raised with a message about not having a "steps" section
|
||||
|
||||
Scenario: CompositeAgent configuration fails if 'route' strategy is used without a 'route' attribute
|
||||
Given a configuration for a CompositeAgent with a "route" strategy but no "route" attribute
|
||||
When I try to create the CompositeAgent named "router_agent"
|
||||
Then a ConfigurationError should be raised with a message about the missing "route" attribute
|
||||
@@ -0,0 +1,24 @@
|
||||
Feature: Configuration Manager Comprehensive Functionality
|
||||
As a developer
|
||||
I want to test all aspects of the ConfigurationManager
|
||||
So that I can ensure it works correctly in all scenarios
|
||||
|
||||
Scenario: Loading configuration with environment variable interpolation
|
||||
Given a configuration file with environment variable references
|
||||
When I load the configuration with environment variables set
|
||||
Then the environment variables should be interpolated correctly
|
||||
|
||||
Scenario: Deep merging of configuration files
|
||||
Given multiple configuration files with overlapping sections
|
||||
When I load all the configuration files
|
||||
Then the configurations should be deep merged correctly
|
||||
|
||||
Scenario: Setting configuration values by path
|
||||
Given a configuration manager with loaded configuration
|
||||
When I set configuration values using dot notation paths
|
||||
Then the configuration should be updated correctly
|
||||
|
||||
Scenario: Configuration validation with schema
|
||||
Given a configuration with missing required sections
|
||||
When I try to validate the configuration
|
||||
Then appropriate validation errors should be raised
|
||||
@@ -0,0 +1,155 @@
|
||||
Feature: Configuration Management
|
||||
This feature covers various aspects of configuration loading, validation,
|
||||
and management to ensure the ConfigurationManager behaves as expected under
|
||||
different conditions, including handling of malformed files and edge cases.
|
||||
|
||||
Scenario: Load an empty YAML file
|
||||
Given an empty configuration file named "empty.yml"
|
||||
When I load the configuration from "empty.yml"
|
||||
Then the configuration should be empty
|
||||
|
||||
Scenario: Load a YAML file that is not a dictionary
|
||||
Given a configuration file named "not_a_dict.yml" with content:
|
||||
"""
|
||||
- item1
|
||||
- item2
|
||||
"""
|
||||
When I try to load the configuration from "not_a_dict.yml"
|
||||
Then a ConfigurationError should be raised with the message "Configuration file must contain a YAML dictionary: not_a_dict.yml"
|
||||
|
||||
Scenario: Load a malformed YAML file
|
||||
Given a configuration file named "malformed.yml" with content:
|
||||
"""
|
||||
agents:
|
||||
agent1:
|
||||
type: LLMAgent
|
||||
- invalid
|
||||
"""
|
||||
When I try to load the configuration from "malformed.yml"
|
||||
Then a ConfigurationError should be raised that contains "Failed to parse YAML file malformed.yml"
|
||||
|
||||
Scenario: Load a file with read permission error
|
||||
Given a configuration file named "no_permission.yml" that cannot be read
|
||||
When I try to load the configuration from "no_permission.yml"
|
||||
Then a ConfigurationError should be raised that contains "Failed to load configuration file no_permission.yml"
|
||||
|
||||
Scenario: Set a configuration value with an invalid path
|
||||
Given a ConfigurationManager with the following configuration:
|
||||
"""
|
||||
{
|
||||
"agents": {
|
||||
"researcher": "some_string"
|
||||
}
|
||||
}
|
||||
"""
|
||||
When I try to set the configuration path "agents.researcher.model" to "gpt-4"
|
||||
Then a ConfigurationError should be raised with the message "Cannot set 'agents.researcher.model' because 'agents.researcher' is not a dictionary"
|
||||
|
||||
Scenario: Call interpolate_env_vars with a non-dictionary
|
||||
Given a ConfigurationManager
|
||||
When I call interpolate_env_vars with the string "some string"
|
||||
Then the result should match "some string"
|
||||
|
||||
Scenario: Interpolate with a missing environment variable
|
||||
Given a configuration file named "missing_env.yml" with content:
|
||||
"""
|
||||
key: "${MISSING_ENV_VAR}"
|
||||
"""
|
||||
When I try to load the configuration from "missing_env.yml"
|
||||
Then a ConfigurationError should be raised with the message "Environment variable 'MISSING_ENV_VAR' is not set"
|
||||
|
||||
Scenario: Convert configuration to JSON
|
||||
Given a ConfigurationManager with the following configuration:
|
||||
"""
|
||||
{
|
||||
"key": "value"
|
||||
}
|
||||
"""
|
||||
When I convert the configuration to JSON
|
||||
Then the result should be a JSON string equivalent to '{"key": "value"}'
|
||||
|
||||
Scenario Outline: Validate agent configuration with invalid 'config' field
|
||||
Given a configuration file named "invalid_agent_config.yml" with content:
|
||||
"""
|
||||
agents:
|
||||
agent1:
|
||||
type: LLMAgent
|
||||
config: <config_value>
|
||||
routes:
|
||||
main:
|
||||
- from: input
|
||||
to: agent1
|
||||
cleveragents:
|
||||
default_router: main
|
||||
"""
|
||||
When I load and validate the configuration from "invalid_agent_config.yml"
|
||||
Then a ConfigurationError should be raised with the message "Agent 'agent1' 'config' must be a dictionary"
|
||||
|
||||
Examples:
|
||||
| config_value |
|
||||
| "a string" |
|
||||
| [1, 2] |
|
||||
|
||||
Scenario: Validate configuration with an empty routes section
|
||||
Given a configuration file named "empty_routes.yml" with content:
|
||||
"""
|
||||
agents:
|
||||
agent1:
|
||||
type: LLMAgent
|
||||
routes: {}
|
||||
cleveragents:
|
||||
default_router: main
|
||||
"""
|
||||
When I load and validate the configuration from "empty_routes.yml"
|
||||
Then a ConfigurationError should be raised with the message "'routes' section cannot be empty"
|
||||
|
||||
Scenario: Validate configuration with routes but no default router
|
||||
Given a configuration file named "no_default_router.yml" with content:
|
||||
"""
|
||||
agents:
|
||||
agent1:
|
||||
type: LLMAgent
|
||||
routes:
|
||||
main:
|
||||
- from: input
|
||||
to: agent1
|
||||
"""
|
||||
When I load and validate the configuration from "no_default_router.yml"
|
||||
Then a ConfigurationError should be raised with the message "Configuration with 'routes' section must specify 'cleveragents.default_router'"
|
||||
|
||||
Scenario: Validate configuration with a non-existent default router
|
||||
Given a configuration file named "bad_default_router.yml" with content:
|
||||
"""
|
||||
agents:
|
||||
agent1:
|
||||
type: LLMAgent
|
||||
routes:
|
||||
main:
|
||||
- from: input
|
||||
to: agent1
|
||||
cleveragents:
|
||||
default_router: non_existent_router
|
||||
"""
|
||||
When I load and validate the configuration from "bad_default_router.yml"
|
||||
Then a ConfigurationError should be raised with the message "Default router 'non_existent_router' not found within 'routes'"
|
||||
|
||||
Scenario: Validate configuration with missing routes section
|
||||
Given a configuration file named "invalid_routes_section.yml" with content:
|
||||
"""
|
||||
agents:
|
||||
agent1:
|
||||
type: LLMAgent
|
||||
"""
|
||||
When I load and validate the configuration from "invalid_routes_section.yml"
|
||||
Then a ConfigurationError should be raised with the message "Configuration must have 'routes' section"
|
||||
|
||||
Scenario: Validate configuration with invalid routes section type
|
||||
Given a configuration file named "invalid_routes_section.yml" with content:
|
||||
"""
|
||||
agents:
|
||||
agent1:
|
||||
type: LLMAgent
|
||||
routes: 'not a dict'
|
||||
"""
|
||||
When I load and validate the configuration from "invalid_routes_section.yml"
|
||||
Then a ConfigurationError should be raised with the message "Configuration must have 'routes' section"
|
||||
@@ -0,0 +1,21 @@
|
||||
Feature: Configuration Management
|
||||
As a developer using CleverAgents
|
||||
I want to load and validate configurations
|
||||
So that I can properly set up agent networks
|
||||
|
||||
Scenario: Load configuration from a YAML file
|
||||
Given a valid configuration file
|
||||
When I load the configuration
|
||||
Then the configuration should be properly parsed
|
||||
And no validation errors should occur
|
||||
|
||||
Scenario: Merge multiple configuration files
|
||||
Given multiple configuration files
|
||||
When I load all configuration files
|
||||
Then the configurations should be merged correctly
|
||||
And later files should override earlier ones
|
||||
|
||||
Scenario: Validate configuration schema
|
||||
Given a configuration with missing required fields
|
||||
When I attempt to validate the configuration
|
||||
Then a configuration error should be raised
|
||||
@@ -0,0 +1,33 @@
|
||||
Feature: Core Application Functionality
|
||||
As a user of CleverAgents
|
||||
I want to use the core application
|
||||
So that I can process messages through agent networks
|
||||
|
||||
Scenario: Initialize application without configuration
|
||||
Given I create a CleverAgentsApp without configuration
|
||||
Then the application should be initialized with default values
|
||||
|
||||
Scenario: Load configuration from files
|
||||
Given I create a CleverAgentsApp without configuration
|
||||
When I load configuration from test files
|
||||
Then the configuration should be loaded successfully
|
||||
|
||||
Scenario: Run in single-shot mode
|
||||
Given I create a CleverAgentsApp with test configuration
|
||||
When I run the application in single-shot mode with prompt "Hello"
|
||||
Then I should receive a response from the application
|
||||
|
||||
Scenario: Start interactive session
|
||||
Given I create a CleverAgentsApp with test configuration
|
||||
When I start an interactive session with the application
|
||||
Then the interactive session should be started
|
||||
|
||||
Scenario: Handle configuration errors
|
||||
Given I create a CleverAgentsApp without configuration
|
||||
When I try to run the application without loading configuration
|
||||
Then an appropriate error should be raised
|
||||
|
||||
Scenario: Handle invalid configuration
|
||||
Given I create a CleverAgentsApp without configuration
|
||||
When I try to load invalid configuration
|
||||
Then a configuration error should be raised for the application
|
||||
@@ -0,0 +1,92 @@
|
||||
Feature: Custom Code Tool in ToolAgent
|
||||
As a developer,
|
||||
I want to define tools that execute custom Python code from the configuration,
|
||||
so that I can create flexible, dynamic tools without writing new classes.
|
||||
|
||||
Background:
|
||||
Given a TemplateRenderer is initialized
|
||||
|
||||
Scenario: Successfully execute a custom code tool
|
||||
Given a configuration for a ToolAgent with a custom code tool that concatenates input with a fixed string
|
||||
"""
|
||||
agents:
|
||||
- name: custom_tool_agent
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: my_custom_tool
|
||||
description: "A custom tool that concatenates."
|
||||
code: |
|
||||
result = f"Custom code executed with: {input_data}"
|
||||
"""
|
||||
And I create the ToolAgent named "custom_tool_agent"
|
||||
When I process this message "my_custom_tool(hello world)" with the agent
|
||||
Then the response should be the following "Custom code executed with: hello world"
|
||||
|
||||
Scenario: Custom code tool accessing context
|
||||
Given a configuration for a ToolAgent with a custom code tool that accesses context
|
||||
"""
|
||||
agents:
|
||||
- name: context_tool_agent
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: context_reader
|
||||
description: "A custom tool that reads from context."
|
||||
code: |
|
||||
suffix = context.get('suffix', 'default')
|
||||
result = f"{input_data}-{suffix}"
|
||||
"""
|
||||
And I create the ToolAgent named "context_tool_agent"
|
||||
When I process this message "context_reader(data)" with the agent with context
|
||||
"""
|
||||
{
|
||||
"suffix": "from_context"
|
||||
}
|
||||
"""
|
||||
Then the response should be the following "data-from_context"
|
||||
|
||||
Scenario: Custom code tool with no result assigned
|
||||
Given a configuration for a ToolAgent with a custom code tool that does not assign to result
|
||||
"""
|
||||
agents:
|
||||
- name: no_result_agent
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: no_result_tool
|
||||
description: "A tool that does not assign to result."
|
||||
code: |
|
||||
x = 1 + 1 # Does something but no 'result = ...'
|
||||
"""
|
||||
And I create the ToolAgent named "no_result_agent"
|
||||
When I process this message "no_result_tool(any input)" with the agent
|
||||
Then the response should be the following ""
|
||||
|
||||
Scenario: Handle execution error in custom code tool
|
||||
Given a configuration for a ToolAgent with a custom code tool containing invalid Python code
|
||||
"""
|
||||
agents:
|
||||
- name: error_agent
|
||||
type: tool
|
||||
config:
|
||||
tools:
|
||||
- name: error_tool
|
||||
description: "A tool with an error."
|
||||
code: |
|
||||
result = 1 / 0
|
||||
"""
|
||||
And I create the ToolAgent named "error_agent"
|
||||
When I try to process the message "error_tool(trigger)" with the agent
|
||||
Then an ExecutionError should be raised with the message containing "division by zero"
|
||||
|
||||
Scenario Outline: Handle configuration errors for custom code tool
|
||||
Given a configuration for a ToolAgent with an invalid custom code tool config '<config_json>'
|
||||
When I try to create the ToolAgent named "bad_config_agent"
|
||||
Then a ConfigurationError should be raised with the message containing "<error_message>"
|
||||
|
||||
Examples:
|
||||
| config_json | error_message |
|
||||
| {"agents":[{"name":"bad_config_agent","type":"tool","config":{"tools":[{"description":"d","code":"c"}]}}]} | Each tool configuration must be a dictionary with a 'name' key |
|
||||
| {"agents":[{"name":"bad_config_agent","type":"tool","config":{"tools":[{"name":"t1","description":"d"}]}}]} | is not a built-in tool and is missing a 'code' or 'module' |
|
||||
| {"agents":[{"name":"bad_config_agent","type":"tool","config":{"tools":[{"name":"t1","code":123}]}}]} | is missing 'code' in its configuration or it is not a string |
|
||||
@@ -0,0 +1,102 @@
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import traceback
|
||||
from pathlib import Path
|
||||
|
||||
from cleveragents.core.config import ConfigurationManager
|
||||
|
||||
# Add the src directory to the path so we can import the package
|
||||
sys.path.insert(
|
||||
0, os.path.abspath(os.path.join(os.path.dirname(__file__), "../../../src"))
|
||||
)
|
||||
|
||||
|
||||
def before_all(context):
|
||||
# Set up any global context here
|
||||
|
||||
# Add custom exception hook for better error reporting
|
||||
original_excepthook = sys.excepthook
|
||||
|
||||
def custom_excepthook(exc_type, exc_value, exc_traceback):
|
||||
print("\n\n===== UNCAUGHT EXCEPTION =====")
|
||||
print(f"Type: {exc_type.__name__}")
|
||||
print(f"Value: {exc_value}")
|
||||
print("Traceback:")
|
||||
traceback.print_tb(exc_traceback)
|
||||
print("==============================\n")
|
||||
# Call the original excepthook
|
||||
original_excepthook(exc_type, exc_value, exc_traceback)
|
||||
|
||||
sys.excepthook = custom_excepthook
|
||||
|
||||
|
||||
def after_all(context):
|
||||
# Clean up after all tests
|
||||
pass
|
||||
|
||||
|
||||
def before_feature(context, feature):
|
||||
# Set up context for each feature
|
||||
pass
|
||||
|
||||
|
||||
def after_feature(context, feature):
|
||||
# Clean up after each feature
|
||||
pass
|
||||
|
||||
|
||||
def before_scenario(context, scenario):
|
||||
# Set up context for each scenario
|
||||
context.temp_dir = Path(tempfile.mkdtemp())
|
||||
context.config_manager = ConfigurationManager()
|
||||
context.error = None
|
||||
context.cleanups = []
|
||||
context.cleanup_dirs = []
|
||||
|
||||
|
||||
def after_scenario(context, scenario):
|
||||
# Clean up after each scenario
|
||||
# Capture any errors that occurred during the scenario
|
||||
if scenario.status == "failed":
|
||||
print(f"\n\n===== SCENARIO FAILED: {scenario.name} =====")
|
||||
if hasattr(context, "error") and context.error:
|
||||
print(f"Exception Type: {type(context.error).__name__}")
|
||||
print(f"Exception Message: {str(context.error)}")
|
||||
if hasattr(context.error, "__traceback__"):
|
||||
print("--- Traceback ---")
|
||||
tb_lines = traceback.format_exception(
|
||||
type(context.error), context.error, context.error.__traceback__
|
||||
)
|
||||
print("".join(tb_lines), end="")
|
||||
print("-------------------")
|
||||
print("===============================\n")
|
||||
|
||||
if hasattr(context, "env_patchers"):
|
||||
for patcher in reversed(context.env_patchers):
|
||||
patcher.stop()
|
||||
delattr(context, "env_patchers")
|
||||
|
||||
if hasattr(context, "cleanups"):
|
||||
for cleanup in reversed(context.cleanups):
|
||||
try:
|
||||
cleanup()
|
||||
except Exception as e:
|
||||
print(f"Error during cleanup: {e}")
|
||||
delattr(context, "cleanups")
|
||||
|
||||
def _cleanup_dir(temp_dir_obj):
|
||||
if isinstance(temp_dir_obj, tempfile.TemporaryDirectory):
|
||||
temp_dir_obj.cleanup()
|
||||
elif temp_dir_obj and os.path.exists(temp_dir_obj):
|
||||
shutil.rmtree(temp_dir_obj)
|
||||
|
||||
if hasattr(context, "cleanup_dirs"):
|
||||
for temp_dir in context.cleanup_dirs:
|
||||
_cleanup_dir(temp_dir)
|
||||
del context.cleanup_dirs
|
||||
|
||||
if hasattr(context, "temp_dir"):
|
||||
_cleanup_dir(context.temp_dir)
|
||||
delattr(context, "temp_dir")
|
||||
@@ -0,0 +1,62 @@
|
||||
Feature: Interactive Commands Functionality
|
||||
As a user of CleverAgents
|
||||
I want to use various interactive commands
|
||||
So that I can control my interactive session
|
||||
|
||||
Scenario: Parse basic commands
|
||||
Given I have a command parser
|
||||
When I parse the command "/help"
|
||||
Then the command should be identified as "help"
|
||||
And the args should be empty
|
||||
|
||||
Scenario: Parse commands with arguments
|
||||
Given I have a command parser
|
||||
When I parse the command "/config set temperature 0.7"
|
||||
Then the command should be identified as "config_set"
|
||||
And the args should contain "temperature" and "0.7"
|
||||
|
||||
Scenario: Parse command aliases
|
||||
Given I have a command parser
|
||||
When I parse the command "/h"
|
||||
Then the command should be identified as "help"
|
||||
When I parse the command "/q"
|
||||
Then the command should be identified as "exit"
|
||||
When I parse the command "/hist"
|
||||
Then the command should be identified as "history"
|
||||
|
||||
Scenario: Execute help command
|
||||
Given I have a command executor
|
||||
When I execute the "help" command
|
||||
Then I should receive help text containing available commands
|
||||
|
||||
Scenario: Execute exit command
|
||||
Given I have a command executor
|
||||
When I execute the "exit" command
|
||||
Then I should receive a message about exiting
|
||||
And the continue flag should be false
|
||||
|
||||
Scenario: Execute config commands
|
||||
Given I have a command executor
|
||||
When I execute the "config_show" command with args "agents"
|
||||
Then I should receive a message about showing configuration
|
||||
When I execute the "config_set" command with args "temperature 0.8"
|
||||
Then I should receive a message about setting configuration
|
||||
|
||||
Scenario: Execute agent commands
|
||||
Given I have a command executor
|
||||
When I execute the "agents_list" command
|
||||
Then I should receive a message about listing agents
|
||||
When I execute the "agent_info" command with args "test_agent"
|
||||
Then I should receive a message about showing agent information
|
||||
|
||||
Scenario: Execute switch command
|
||||
Given I have a command executor
|
||||
When I execute the "switch" command with args "new_agent"
|
||||
Then I should receive a message about switching agents
|
||||
When I execute the "switch" command without args
|
||||
Then I should receive an error message about usage
|
||||
|
||||
Scenario: Execute unknown command
|
||||
Given I have a command executor
|
||||
When I execute the "unknown" command
|
||||
Then I should receive an error message about unknown command
|
||||
@@ -0,0 +1,22 @@
|
||||
Feature: Interactive Session
|
||||
As a user of CleverAgents
|
||||
I want to have interactive chat sessions
|
||||
So that I can engage in ongoing conversations
|
||||
|
||||
Scenario: Start and end an interactive session
|
||||
Given the CleverAgents application is configured
|
||||
When I start an interactive session
|
||||
Then I should be able to send messages and receive responses
|
||||
And I should be able to end the session
|
||||
|
||||
Scenario: Save and load conversation history
|
||||
Given an interactive session with history
|
||||
When I save the conversation history
|
||||
And I load the conversation history in a new session
|
||||
Then the new session should contain the previous conversation
|
||||
|
||||
Scenario: Execute commands during an interactive session
|
||||
Given an active interactive session
|
||||
When I enter a command prefixed with "/"
|
||||
Then the command should be executed
|
||||
And the appropriate response should be displayed
|
||||
@@ -0,0 +1,60 @@
|
||||
Feature: Comprehensive Interactive Session Functionality
|
||||
As a developer, I want to test all aspects of the InteractiveSession class,
|
||||
including its main run loop and error handling, to ensure its robustness.
|
||||
|
||||
Scenario: Session handles failure during history loading
|
||||
Given an interactive session with a corrupted history file
|
||||
When I try to load the history
|
||||
Then an InteractiveSessionError should be raised with a message about loading history
|
||||
|
||||
Scenario: Session handles failure during history saving
|
||||
Given an interactive session with a read-only history file
|
||||
When I try to save the history
|
||||
Then an InteractiveSessionError should be raised with a message about saving history
|
||||
|
||||
Scenario: Session handles failure during message processing
|
||||
Given an interactive session where the router will fail
|
||||
When I try to process a message through the session
|
||||
Then an InteractiveSessionError should be raised with a message about processing a message
|
||||
|
||||
Scenario: Session run loop processes user input and commands
|
||||
Given an interactive session with a mock router
|
||||
And a sequence of user inputs: "hello", "/exit"
|
||||
When I run the session's main loop
|
||||
Then the router should have processed the message "hello"
|
||||
And the session should have stopped running
|
||||
|
||||
Scenario: Session run loop handles verbose mode
|
||||
Given an interactive session in verbose mode
|
||||
And a sequence of user inputs: "test verbose", "/exit"
|
||||
When I run the session's main loop
|
||||
Then the output should contain "Processing message..."
|
||||
|
||||
Scenario: Session run loop handles KeyboardInterrupt gracefully
|
||||
Given an interactive session
|
||||
And the user input will raise a KeyboardInterrupt
|
||||
When I run the session's main loop
|
||||
Then the output should contain "Interrupted by user."
|
||||
And the session should have stopped running
|
||||
|
||||
Scenario: Session run loop handles generic exceptions gracefully
|
||||
Given an interactive session where message processing will raise a generic error
|
||||
And a sequence of user inputs: "cause error", "/exit"
|
||||
When I run the session's main loop
|
||||
Then the output should contain "Error: Test processing error"
|
||||
|
||||
Scenario: Session run loop handles startup failure
|
||||
Given an interactive session that will fail to load history on startup
|
||||
When I try to run the session's main loop
|
||||
Then an InteractiveSessionError should be raised with a message about running the session
|
||||
|
||||
Scenario: Session displays history correctly
|
||||
Given an interactive session with some history entries
|
||||
When I display the history
|
||||
Then the output must contain "You: Hello"
|
||||
And the output must contain "Agent: Hi there!"
|
||||
|
||||
Scenario: Session handles history operations when no file is provided
|
||||
Given an interactive session without a history file
|
||||
When I call load_history and save_history
|
||||
Then no errors should be raised
|
||||
@@ -0,0 +1,19 @@
|
||||
Feature: LLM Agent Functionality
|
||||
As a user of CleverAgents
|
||||
I want to use LLM agents to generate text
|
||||
So that I can get intelligent responses
|
||||
|
||||
Scenario: Process a message with OpenAI provider
|
||||
Given an LLM agent with "openai" provider
|
||||
When I send a message to the LLM agent
|
||||
Then I should receive a response from the LLM
|
||||
|
||||
Scenario: Process a message with Anthropic provider
|
||||
Given an LLM agent with "anthropic" provider
|
||||
When I send a message to the LLM agent
|
||||
Then I should receive a response from the LLM
|
||||
|
||||
Scenario: Handle errors in LLM processing
|
||||
Given an LLM agent with invalid configuration
|
||||
When I try to process a message
|
||||
Then an appropriate LLM error should be raised
|
||||
@@ -0,0 +1,156 @@
|
||||
Feature: Detailed LLM Agent functionality
|
||||
This feature covers detailed test cases for the LLMAgent, including provider integration,
|
||||
error handling, API key management, template rendering, and other core behaviors,
|
||||
to ensure its robustness and correctness.
|
||||
|
||||
Scenario: Initialize LLM agent with different providers
|
||||
Given I try to initialize an LLM agent with provider "openai"
|
||||
Then the agent should be initialized successfully
|
||||
Given I try to initialize an LLM agent with provider "anthropic"
|
||||
Then the agent should be initialized successfully
|
||||
Given I try to initialize an LLM agent with provider "invalid"
|
||||
Then an agent creation error should be raised
|
||||
|
||||
Scenario: Process messages with OpenAI provider
|
||||
Given I have an LLM agent with provider "openai"
|
||||
When I process a message "Hello" with the agent
|
||||
Then the agent should return a response
|
||||
And the message should be added to the agent's memory
|
||||
|
||||
Scenario: Process messages with Anthropic provider
|
||||
Given I have an LLM agent with provider "anthropic"
|
||||
When I process a message "Hello" with the agent
|
||||
Then the agent should return a response
|
||||
And the message should be added to the agent's memory
|
||||
|
||||
Scenario: Format prompts with a referenced template
|
||||
Given I have an LLM agent with a prompt reference
|
||||
When I process a message "Hello" with the agent
|
||||
Then the agent's prompt should be formatted according to the template
|
||||
|
||||
Scenario: Format prompts with an inline template
|
||||
Given I have an LLM agent with an inline prompt
|
||||
When I process a message "Hello" with the agent
|
||||
Then the agent's prompt should be formatted according to the template
|
||||
|
||||
Scenario: Process responses with a referenced template
|
||||
Given I have an LLM agent with a response reference
|
||||
When I process a message "Hello" with the agent
|
||||
Then the response should be properly formatted according to the template
|
||||
|
||||
Scenario: Process responses with an inline template
|
||||
Given I have an LLM agent with an inline response template
|
||||
When I process a message "Hello" with the agent
|
||||
Then the response should be properly formatted according to the template
|
||||
|
||||
Scenario: Get agent capabilities
|
||||
Given I have an LLM agent with provider "openai"
|
||||
When I get the agent's capabilities
|
||||
Then the capabilities should include "text-generation"
|
||||
Given I have an LLM agent with model "gpt-4-vision"
|
||||
When I get the agent's capabilities
|
||||
Then the capabilities should include "image-understanding"
|
||||
Given I have an LLM agent with model "gpt-4-code-interpreter"
|
||||
When I get the agent's capabilities
|
||||
Then the capabilities should include "code-generation"
|
||||
|
||||
Scenario: Error on conflicting role configuration
|
||||
Given a config with both "role" and "role_reference"
|
||||
When I initialize the LLMAgent with that config
|
||||
Then a ConfigurationError should be raised containing "cannot contain both 'role' and 'role_reference'"
|
||||
|
||||
Scenario: Error on conflicting prompt configuration
|
||||
Given a config with both "prompt" and "prompt_reference"
|
||||
When I initialize the LLMAgent with that config
|
||||
Then a ConfigurationError should be raised containing "cannot contain both 'prompt' and 'prompt_reference'"
|
||||
|
||||
Scenario: Error on conflicting response configuration
|
||||
Given a config with both "response" and "response_reference"
|
||||
When I initialize the LLMAgent with that config
|
||||
Then a ConfigurationError should be raised containing "cannot contain both 'response' and 'response_reference'"
|
||||
|
||||
Scenario: LLM Agent raises ConfigurationError if API key is missing
|
||||
Given a configuration for an LLM agent "openai_agent" with provider "openai" and no API key
|
||||
When I try to create the LLM agent "openai_agent" without the API key environment variable
|
||||
Then an "ConfigurationError" should be raised with a message containing "API key for provider 'openai' not found"
|
||||
|
||||
Scenario: LLM Agent initializes with a provider that does not require an API key
|
||||
Given a configuration for an LLM-based agent "local_agent" with provider "local_llm"
|
||||
When I create the LLM agent "local_agent"
|
||||
Then the agent "local_agent" should be created successfully
|
||||
And its API key should be an empty string
|
||||
|
||||
Scenario: LLM Agent logs debug information when verbosity is enabled
|
||||
Given a configuration for an LLM-based agent "debug_agent" with provider "openai"
|
||||
And the logging level is set to DEBUG
|
||||
And a mock successful OpenAI API response with content "Debug response"
|
||||
When I process the message "debug message" with the LLM agent "debug_agent"
|
||||
Then the logs should contain "--- LLMAgent 'debug_agent' received message ---"
|
||||
And the logs should contain "--- LLMAgent 'debug_agent' final render context ---"
|
||||
|
||||
Scenario: LLM Agent uses an inline role template
|
||||
Given a configuration for an LLM agent "role_agent" with provider "openai" and an inline role "You are a test bot."
|
||||
And a mock successful OpenAI API response
|
||||
When I process the message "test" with the LLM agent "role_agent"
|
||||
Then the OpenAI API should have been called with a system message "You are a test bot."
|
||||
|
||||
Scenario: LLM Agent handles an empty role template
|
||||
Given a configuration for an LLM agent "empty_role_agent" with provider "openai" and an empty inline role
|
||||
And a mock successful OpenAI API response
|
||||
When I process the message "test" with the LLM agent "empty_role_agent"
|
||||
Then the OpenAI API should have been called without a system message
|
||||
|
||||
Scenario: LLM Agent wraps generic processing exceptions
|
||||
Given a configuration for an LLM-based agent "error_agent" with provider "openai"
|
||||
And the template renderer will raise an error when rendering "prompt_ref"
|
||||
When I try to process a message with the agent "error_agent" using prompt reference "prompt_ref"
|
||||
Then an "ExecutionError" should be raised with a message containing "Failed to process message"
|
||||
|
||||
Scenario: LLM Agent handles OpenAI API non-200 response
|
||||
Given a configuration for an LLM-based agent "openai_fail_agent" with provider "openai"
|
||||
And a mock failed OpenAI API response with status 500 and error "Server Error"
|
||||
When I process the message "test" with the LLM agent "openai_fail_agent"
|
||||
Then an "ExecutionError" should be raised with a message containing "OpenAI API error: 500 - Server Error"
|
||||
|
||||
Scenario: LLM Agent handles invalid OpenAI API response
|
||||
Given a configuration for an LLM-based agent "openai_invalid_agent" with provider "openai"
|
||||
And a mock invalid OpenAI API response
|
||||
When I process the message "test" with the LLM agent "openai_invalid_agent"
|
||||
Then an "ExecutionError" should be raised with a message containing "No response from OpenAI API"
|
||||
|
||||
Scenario: LLM Agent handles OpenAI network error
|
||||
Given a configuration for an LLM-based agent "openai_network_error_agent" with provider "openai"
|
||||
And the OpenAI API call will raise a network error
|
||||
When I process the message "test" with the LLM agent "openai_network_error_agent"
|
||||
Then an "ExecutionError" should be raised with a message containing "Failed to generate OpenAI response"
|
||||
|
||||
Scenario: LLM Agent successfully processes with Anthropic provider
|
||||
Given a configuration for an LLM-based agent "anthropic_agent" with provider "anthropic"
|
||||
And a mock successful Anthropic API response with content "Hello from Claude"
|
||||
When I process the message "Hello" with the LLM agent "anthropic_agent"
|
||||
Then the response needs to be "Hello from Claude"
|
||||
|
||||
Scenario: LLM Agent handles Anthropic API non-200 response
|
||||
Given a configuration for an LLM-based agent "anthropic_fail_agent" with provider "anthropic"
|
||||
And a mock failed Anthropic API response with status 500 and error "Server Error"
|
||||
When I process the message "test" with the LLM agent "anthropic_fail_agent"
|
||||
Then an "ExecutionError" should be raised with a message containing "Anthropic API error: 500 - Server Error"
|
||||
|
||||
Scenario: LLM Agent handles invalid Anthropic API response
|
||||
Given a configuration for an LLM-based agent "anthropic_invalid_agent" with provider "anthropic"
|
||||
And a mock invalid Anthropic API response
|
||||
When I process the message "test" with the LLM agent "anthropic_invalid_agent"
|
||||
Then an "ExecutionError" should be raised with a message containing "No response from Anthropic API"
|
||||
|
||||
Scenario: LLM Agent handles Anthropic network error
|
||||
Given a configuration for an LLM-based agent "anthropic_network_error_agent" with provider "anthropic"
|
||||
And the Anthropic API call will raise a network error
|
||||
When I process the message "test" with the LLM agent "anthropic_network_error_agent"
|
||||
Then an "ExecutionError" should be raised with a message containing "Failed to generate Anthropic response"
|
||||
|
||||
Scenario: LLM Agent handles response processing error
|
||||
Given a configuration for an LLM agent "response_error_agent" with provider "openai" and a response reference "non_existent_template"
|
||||
And a mock successful OpenAI API response
|
||||
And the template renderer will raise an error when rendering "non_existent_template"
|
||||
When I process the message "test" with the LLM agent "response_error_agent"
|
||||
Then an "ExecutionError" should be raised with a message containing "Failed to process response"
|
||||
@@ -0,0 +1,9 @@
|
||||
Feature: Message Context Persistence in Multi-Agent Workflow
|
||||
To ensure reliable communication in complex agent networks,
|
||||
the initial message from the user should be preserved in the context
|
||||
and be accessible to agents throughout the entire workflow.
|
||||
|
||||
Scenario: Original message is accessible in a subsequent routing step
|
||||
Given a router configured for a multi-step workflow
|
||||
When I process the prompt "What is the capital of France?"
|
||||
Then the final output should be "The capital of France is Paris."
|
||||
@@ -0,0 +1,98 @@
|
||||
Feature: Extended coverage for interactive commands
|
||||
In order to ensure the robustness of the interactive CLI, all command parsing
|
||||
and execution paths must be thoroughly tested.
|
||||
|
||||
Scenario Outline: Parsing various command aliases and formats
|
||||
Given I have a command parser
|
||||
When I parse the command "<command_str>"
|
||||
Then the command should be identified as "<expected_command>"
|
||||
And the arguments should be <expected_args>
|
||||
|
||||
Examples:
|
||||
| command_str | expected_command | expected_args |
|
||||
| /h | help | [] |
|
||||
| /? | help | [] |
|
||||
| /quit | exit | [] |
|
||||
| /q | exit | [] |
|
||||
| /hist 15 | history | ['15'] |
|
||||
| /cls | clear | [] |
|
||||
| /save | save | [] |
|
||||
| /v on | verbose | ['on'] |
|
||||
| /conf show | config_show | [] |
|
||||
| /cfg set k v | config_set | ['k', 'v'] |
|
||||
| /config | config | [] |
|
||||
| /agent ls | agents_list | [] |
|
||||
| /agents info foo | agent_info | ['foo'] |
|
||||
| /agents | agents | [] |
|
||||
| /rt | route | [] |
|
||||
| /sw bar | switch | ['bar'] |
|
||||
| /unknown | unknown | [] |
|
||||
| an_unknown_command | an_unknown_command | [] |
|
||||
|
||||
Scenario: Executing verbose command variants
|
||||
Given I have a command executor
|
||||
When I execute the command "verbose" with arguments "on"
|
||||
Then the result should be successful
|
||||
And the message should be "Verbose output enabled."
|
||||
When I execute the command "verbose" with arguments "off"
|
||||
Then the result should be successful
|
||||
And the message should be "Verbose output disabled."
|
||||
When I execute the command "verbose" with no arguments
|
||||
Then the result should be successful
|
||||
And the message should be "Toggled verbose output."
|
||||
|
||||
Scenario: Executing config command variants
|
||||
Given I have a command executor
|
||||
When I execute the command "config_show" with no arguments
|
||||
Then the result should be successful
|
||||
And the message should be "Showing configuration for path: "
|
||||
When I execute the command "config_show" with arguments "some.path"
|
||||
Then the result should be successful
|
||||
And the message should be "Showing configuration for path: some.path"
|
||||
When I execute the command "config_set" with arguments "path"
|
||||
Then the result should be unsuccessful
|
||||
And the message should be "Usage: /config set path value"
|
||||
When I execute the command "config" with no arguments
|
||||
Then the result should be successful
|
||||
And the message should be "Available config commands: show, set"
|
||||
|
||||
Scenario: Executing agents command variants
|
||||
Given I have a command executor
|
||||
When I execute the command "agent_info" with no arguments
|
||||
Then the result should be successful
|
||||
And the message should be "Showing information for agent: "
|
||||
When I execute the command "agents" with no arguments
|
||||
Then the result should be successful
|
||||
And the message should be "Available agent commands: list, info"
|
||||
|
||||
Scenario: Executing commands that depend on a session when no session is available
|
||||
Given I have a command executor without a session
|
||||
When I execute the command "save" with no arguments
|
||||
Then the result message should be "History not available."
|
||||
When I execute the command "route" with no arguments
|
||||
Then the result message should be "Route command is not available."
|
||||
|
||||
Scenario: Executing route command with a session
|
||||
Given I have a command executor with a complete mock session
|
||||
And the session has routes "default", "backup" and active route "default"
|
||||
When I execute the command "route" with no arguments
|
||||
Then the result should be successful
|
||||
And the message should be "Current route: 'default'. Available: default, backup."
|
||||
When I execute the command "route" with arguments "nonexistent"
|
||||
Then the result should be unsuccessful
|
||||
And the message should be "Error: Route 'nonexistent' not found."
|
||||
|
||||
Scenario: Executing switch command variants
|
||||
Given I have a command executor
|
||||
When I execute the command "switch" with arguments "new_agent"
|
||||
Then the result should be successful
|
||||
And the message should be "Switching to agent: new_agent"
|
||||
When I execute the command "switch" with no arguments
|
||||
Then the result should be unsuccessful
|
||||
And the message should be "Usage: /switch agent_name"
|
||||
|
||||
Scenario: Executing an unknown command
|
||||
Given I have a command executor
|
||||
When I execute the command "phantom_command" with no arguments
|
||||
Then the result should be unsuccessful
|
||||
And the message should be "Unknown command: phantom_command"
|
||||
@@ -0,0 +1,11 @@
|
||||
Feature: Multi-Agent LLM Agent Configuration
|
||||
|
||||
Scenario: An LLM agent in a network should use its specific inline role
|
||||
Given a configuration with an LLM agent having a specific role
|
||||
When I run the network with a prompt
|
||||
Then the LLM provider should have been called with the specific role message
|
||||
|
||||
Scenario: An LLM agent uses a referenced role
|
||||
Given a configuration with an LLM agent having a referenced role
|
||||
When I run the network with a prompt
|
||||
Then the LLM provider should have been called with the specific role message
|
||||
@@ -0,0 +1,6 @@
|
||||
Feature: Multi-Agent LLM Agent Configuration
|
||||
|
||||
Scenario: An LLM agent in a network should use its specific system message
|
||||
Given a configuration with an LLM agent having a specific system message
|
||||
When I run the network with a prompt
|
||||
Then the LLM provider should have been called with the specific system message
|
||||
@@ -0,0 +1,6 @@
|
||||
Feature: Agent Network Processing
|
||||
|
||||
Scenario: Process a message through the agent network
|
||||
Given a configuration file "build/tests/config/network_config.yaml"
|
||||
When I process the message "Hello, AI!"
|
||||
Then I should receive a non-empty network response
|
||||
@@ -0,0 +1,9 @@
|
||||
Feature: Reproduce Multi-Agent Routing Bugs
|
||||
|
||||
This feature test aims to reproduce bugs observed in a multi-agent workflow,
|
||||
specifically related to template transforms and condition evaluation.
|
||||
|
||||
Scenario: A message routes incorrectly in a multi-step workflow
|
||||
Given a router with a buggy multi-step configuration
|
||||
When I process the initial prompt "What is the capital of France?"
|
||||
Then the buggy workflow output should be "search result for: What is the capital of France?"
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user