Compare commits

...

45 Commits

Author SHA1 Message Date
freemo db5b6063e8 Updated config to work better maybe? 2025-09-07 10:48:54 -04:00
freemo 7ce1ec791f fixed demo fully 2025-08-21 22:20:51 +00:00
freemo 763fb4a5a8 Fixedgetting to the end t now sorta works but produces generic sample out 2025-08-21 22:00:26 +00:00
freemo a2455bcbec Seems to fix the vetting stage 2025-08-21 21:07:20 +00:00
freemo d689facb61 Fixed most vetting errors 2025-08-21 20:57:26 +00:00
freemo 33047b4667 feat: enhance scientific paper writer with detailed role instructions 2025-07-11 22:25:32 -04:00
freemo 68dc5820b0 feat: add json_mode attribute and update Google response payload 2025-07-11 22:25:18 -04:00
freemo ea037e1bff refactor: update logging and error handling in LLMAgent and TemplateRenderer 2025-07-07 18:40:08 -04:00
freemo c2e9fbcf57 feat: add Google Gemini API support with web search feature flag 2025-07-07 18:17:32 -04:00
freemo 877a0d584c fix: correct environment variable name for Gemini API key 2025-07-07 21:52:09 +00:00
freemo f31a128982 refactor: update YAML for improved clarity and null handling 2025-07-07 17:51:23 -04:00
freemo 00c1cf0978 fix: improve template error handling and context availability in LLMAgent 2025-07-07 11:46:56 -04:00
freemo 462c3a4e30 fix: Fixed output of discovery stage so it behaves correctly. 2025-07-07 11:39:25 -04:00
freemo f4e8f4dca1 fix: update initial_message in context for each interactive turn 2025-07-07 11:15:51 -04:00
freemo 2f72c92147 feat: enhance error reporting and add intro stage to paper writer 2025-07-07 09:24:06 -04:00
freemo cdf2d52e0e feat: add scientific paper writer configuration file 2025-07-07 00:09:41 -04:00
freemo 6910774554 feat: add unsafe mode for executing code with full privileges 2025-07-06 23:55:07 -04:00
freemo 1452ef4239 fix: ensure context persistence and initialization in interactive mode 2025-07-06 22:15:56 -04:00
freemo 2fa7e1c913 feat: add support for custom code tools and enhance error handling 2025-07-06 20:40:04 -04:00
freemo 7fe3521a2c test: add feature tests for Agent base class coverage 2025-07-06 22:08:06 +00:00
freemo 0b052c75af refactor: improve null checks and error handling in LLMAgent class 2025-07-06 21:40:09 +00:00
freemo 3cc976c895 refactor: improve config management and add comprehensive tests 2025-07-06 20:20:09 +00:00
freemo 245a8774e8 test: add detailed router scenarios and steps for coverage 2025-07-06 15:23:54 -04:00
freemo 47b32b2800 test: add detailed tests for Tool, WebSearchTool, and ToolAgent 2025-07-06 14:42:47 -04:00
freemo b917e8fdff test: add tests for TemplateRenderer error handling and edge cases 2025-07-06 14:15:30 -04:00
freemo 6fde8193c6 feat: enhance error handling and add comprehensive session tests 2025-07-06 14:01:27 -04:00
freemo af801be1c1 test: add AgentNetwork feature tests with mock configurations 2025-07-05 23:32:24 -04:00
freemo ff99155cf0 chore: update .coveragerc to exclude tests from coverage source 2025-07-05 23:12:53 -04:00
freemo d935b7cec3 feat: add step aliasing and validation to CompositeAgent with tests 2025-07-06 02:50:28 +00:00
freemo f325866e6d feat: replace 'agents' with 'steps' in CompositeAgent configuration, and have it support both routes and agents. 2025-07-04 17:46:15 -04:00
freemo 437f2e5651 feat: enhance CompositeAgent with parallel strategy and add tests 2025-07-04 15:10:18 -04:00
freemo a9b8a1a6a3 fix: add validation for unique agent and route names in initialization 2025-07-04 13:22:36 -04:00
freemo f018ecd02f feat: enhance agent configuration with template references and validation 2025-07-02 23:33:06 -04:00
freemo 72e9fcb985 feat: add API key configuration and handling for agents and tools 2025-07-01 22:20:04 -04:00
freemo 9392934fd0 refactor: remove legacy router support and update configuration handling 2025-07-01 20:47:20 -04:00
freemo 08817f1754 refactor: remove fallback logic in template expression evaluation 2025-07-01 19:46:03 -04:00
freemo 682f511320 Removed eaten exceptions and support for json in messages 2025-07-01 18:31:36 -04:00
freemo 78a09c48fc feat: add dynamic context updates to Router class 2025-07-01 17:37:36 -04:00
freemo ee69a20ad7 feat: add route strategy to CompositeAgent with tests and examples 2025-07-01 16:18:56 -04:00
freemo 9c8cae1fd4 refactor: support multi-router configuration in CleverAgentsApp 2025-07-01 01:00:08 +00:00
freemo 2c667b4cb0 test: enhance message routing verification with context history 2025-06-30 23:16:09 +00:00
freemo 9049478bdc refactor: improve agent configuration and routing logic in LLM and tool agents 2025-06-30 18:12:05 +00:00
freemo 315c583ed9 feat: add calculator agent example and improve tool agent handling 2025-06-28 22:14:28 +00:00
freemo 72726a762a feat: migrate project to CleverAgents with new features and tests 2025-06-28 14:49:24 -04:00
freemo 29934a9c6a chore: update .gitignore to exclude .aider files 2025-06-25 17:15:28 +00:00
163 changed files with 16836 additions and 224 deletions
+1 -1
View File
@@ -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
View File
@@ -3,9 +3,7 @@ source = src
[run]
branch = true
source =
src
tests
source = src
parallel = true
[report]
+24
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
/bin/bash
+7
View File
@@ -49,3 +49,10 @@ hs_err_pid*
.settings/
.tox/
.aider*
**/*.egg-info/
**/__pycache__/
docs/_build/
build/
.coverage.*
.coverage
-45
View File
@@ -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
+2 -1
View File
@@ -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:
-4
View File
@@ -1,4 +0,0 @@
# Changelog
## v0.0.1
* Initial release.
+1
View File
@@ -0,0 +1 @@
Changelog
+24 -13
View File
@@ -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/>`_
+35 -23
View File
@@ -1,8 +1,18 @@
# Contributing
============
Contributing
============
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
[![Semantic Versioning](https://img.shields.io/SemVer/2.0.0.png)](http://semver.org/spec/v2.0.0.html)
[![Matrix](https://img.shields.io/matrix/cleverthis%3Aqoto.org?server_fqdn=matrix.qoto.org&label=Matrix%20chat)](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.
![Commitizen friendly](https://docs.cleverthis.com/public_media/commitizen.gif)
.. 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.
+5 -2
View File
@@ -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
View File
@@ -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
+35
View File
@@ -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
View File
@@ -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
-15
View File
@@ -1,15 +0,0 @@
![CleverThis](https://docs.cleverthis.com/public_media/logo-transp.png)
[![pipeline status](https://git.cleverthis.com/cleverthis/base/root/badges/master/pipeline.svg)](https://git.cleverthis.com/cleverthis/base/root/-/commits/master)
[![coverage report](https://git.cleverthis.com/cleverthis/base/root/badges/master/coverage.svg)](https://git.cleverthis.com/cleverthis/base/root/-/commits/master)
[![SemVer](https://img.shields.io/badge/SemVer-v2.0.0-green)](https://semver.org/spec/v2.0.0.html)
[![Matrix](https://img.shields.io/matrix/cleverthis%3Aqoto.org?server_fqdn=matrix.qoto.org&label=Matrix%20chat)](https://matrix.to/#/#CleverThis:qoto.org)
This is a starter project to be used as a starting point for new projects.
## Donating
[![Open Collective backers](https://img.shields.io/opencollective/all/CleverThis)](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
View File
@@ -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
View File
@@ -1,5 +1,5 @@
version: '2'
services:
boilerplate:
image: boilerplate/boilerplate:latest
cleveragents:
image: cleveragents/cleveragents:latest
build: .
+599
View File
@@ -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"]
+8
View File
@@ -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
View File
@@ -1 +0,0 @@
.. include:: ../AUTHORS.md
+1 -1
View File
@@ -1 +1 @@
.. include:: ../CHANGELOG.md
Changelog
+1
View File
@@ -0,0 +1 @@
Concepts
+6 -4
View File
@@ -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
View File
@@ -1 +1 @@
.. include:: ../CONTRIBUTING.md
.. include:: ../CONTRIBUTING.rst
+1
View File
@@ -0,0 +1 @@
.. include:: ../CONTRIBUTORS.rst
+5 -2
View File
@@ -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`
+1 -1
View File
@@ -4,4 +4,4 @@ Installation
At the command line::
pip install boilerplate
pip install cleveragents
+111
View File
@@ -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
View File
@@ -1 +1 @@
.. include:: ../README.md
.. include:: ../README.rst
-9
View File
@@ -1,9 +0,0 @@
boilerplate
==========
.. testsetup::
from boilerplate import *
.. automodule:: boilerplate
:members:
+10
View File
@@ -0,0 +1,10 @@
=============
cleveragents
=============
.. testsetup::
from cleveragents import *
.. automodule:: cleveragents
:members:
+15 -2
View File
@@ -1,7 +1,20 @@
Reference
=========
.. toctree::
:glob:
boilerplate*
cleveragents*
.. toctree::
:glob:
cleveragents*
.. toctree::
:glob:
cleveragents*
.. toctree::
:glob:
cleveragents*
-1
View File
@@ -7,5 +7,4 @@ staticmethods
args
kwargs
callstack
Changelog
Indices
+4 -2
View File
@@ -2,6 +2,8 @@
Usage
=====
To use Boilerplate in a project::
To use CleverAgents in a project::
import boilerplate
import cleveragents
Usage
Usage
+2 -3
View File
@@ -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
+37 -30
View File
@@ -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
View File
@@ -1 +0,0 @@
__version__ = "0.1.0"
-23
View File
@@ -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))
+8
View File
@@ -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()
+9
View File
@@ -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
+171
View File
@@ -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
+81
View File
@@ -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"]
+323
View File
@@ -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
+15
View File
@@ -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
+170
View File
@@ -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()
+601
View File
@@ -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 agents 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 LLMs 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 chains 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
+10
View File
@@ -0,0 +1,10 @@
"""
This module contains state management for agents.
"""
def initial_state():
"""
Returns the initial state for an agent.
"""
return {}
+614
View File
@@ -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
+214
View File
@@ -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()
+376
View File
@@ -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."
)
+320
View File
@@ -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")
+64
View File
@@ -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
+30
View File
@@ -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,
}
+210
View File
@@ -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
+211
View File
@@ -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)}"
)
+69
View File
@@ -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
+142
View File
@@ -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)}")
+42
View File
@@ -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
+357
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
+278
View File
@@ -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
+284
View File
@@ -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())
+129
View File
@@ -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"
+64
View File
@@ -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"
+44
View File
@@ -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."
+170
View File
@@ -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
+16
View File
@@ -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
+9
View File
@@ -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
+39
View File
@@ -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
+21
View File
@@ -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"
+35
View File
@@ -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"
+19
View File
@@ -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
+19
View File
@@ -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
+155
View File
@@ -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"
+21
View File
@@ -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
+33
View File
@@ -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
+92
View File
@@ -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 |
+102
View File
@@ -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
+19
View File
@@ -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
+156
View File
@@ -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"
+11
View File
@@ -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
+6
View File
@@ -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