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

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

|
||||
# CleverAgents
|
||||
|
||||
[](https://git.cleverthis.com/cleverthis/base/root/-/commits/master)
|
||||
[](https://git.cleverthis.com/cleverthis/base/root/-/commits/master)
|
||||
[](https://semver.org/spec/v2.0.0.html)
|
||||
[](https://matrix.to/#/#CleverThis:qoto.org)
|
||||
A powerful, reactive Agent Framework using RxPy streams for complex AI agent orchestration and message routing.
|
||||
|
||||
This is a starter project to be used as a starting point for new projects.
|
||||
## 🌊 What is CleverAgents?
|
||||
|
||||
## Donating
|
||||
CleverAgents is a **reactive agent orchestration framework** that combines the power of RxPy reactive streams with LangGraph stateful workflows to create sophisticated AI agent networks. It's designed for building complex, multi-agent systems that can handle real-time data processing, conversational AI, and workflow automation.
|
||||
|
||||
[](https://opencollective.com/cleverthis)
|
||||
### Key Features
|
||||
|
||||
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.
|
||||
- **🌊 Reactive Architecture**: Built on RxPy streams for real-time data processing
|
||||
- **🤖 Multiple Agent Types**: LLM agents, tool agents, composite agents, and more
|
||||
- **🔄 Unified Routes**: Single configuration system for streams and graphs
|
||||
- **🧠 Memory Management**: Conversation history and state persistence
|
||||
- **🔗 LangGraph Integration**: Stateful workflows with conditional logic
|
||||
- **⚡ Async Processing**: Full async/await support throughout
|
||||
- **🎯 Template System**: Reusable agent and workflow templates
|
||||
|
||||
## 📦 Installation
|
||||
|
||||
### Prerequisites
|
||||
|
||||
- Python 3.9+ (recommended: Python 3.11+)
|
||||
- pip package manager
|
||||
|
||||
### Install from Source (Recommended)
|
||||
|
||||
**For development (editable install):**
|
||||
```bash
|
||||
git clone https://git.cleverthis.com/cleveragents/cleveragents-core
|
||||
cd cleveragents-core
|
||||
pip install -e .
|
||||
```
|
||||
### Dependencies
|
||||
|
||||
CleverAgents automatically installs these dependencies:
|
||||
|
||||
- `click` - Command-line interface
|
||||
- `rx>=3.2.0` - Reactive extensions for Python
|
||||
- `jinja2` - Template engine
|
||||
- `pystache` - Mustache template support
|
||||
- `pyyaml` - YAML configuration parsing
|
||||
- `langchain-core>=0.3.0` - LangChain integration
|
||||
- `langchain-openai>=0.2.0` - OpenAI integration
|
||||
- `langchain-anthropic>=0.2.0` - Anthropic integration
|
||||
- `langchain-google-genai>=2.0.0` - Google Gemini integration
|
||||
- `aiohttp` - Async HTTP client
|
||||
|
||||
## 🚀 Quick Start
|
||||
|
||||
### 1. Create a Configuration File
|
||||
|
||||
Create a `config.yaml` file:
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
chat_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.7
|
||||
memory_enabled: true
|
||||
|
||||
routes:
|
||||
chat_stream:
|
||||
type: stream
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: chat_agent
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: chat_stream
|
||||
```
|
||||
|
||||
### 2. Set Up API Keys
|
||||
|
||||
Configure your API keys via environment variables:
|
||||
|
||||
```bash
|
||||
export OPENAI_API_KEY="your-openai-key"
|
||||
export ANTHROPIC_API_KEY="your-anthropic-key"
|
||||
export GOOGLE_API_KEY="your-google-key"
|
||||
```
|
||||
|
||||
Or include them directly in your configuration:
|
||||
|
||||
```yaml
|
||||
agents:
|
||||
chat_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
api_key: "your-openai-key"
|
||||
```
|
||||
|
||||
### 3. Run CleverAgents
|
||||
|
||||
**Interactive Mode:**
|
||||
```bash
|
||||
cleveragents interactive -c config.yaml
|
||||
```
|
||||
|
||||
**Single-Shot Mode:**
|
||||
```bash
|
||||
cleveragents run -c config.yaml -p "Hello, how are you?"
|
||||
```
|
||||
|
||||
## 🛠️ Command Line Interface
|
||||
|
||||
### Main Commands
|
||||
|
||||
#### `cleveragents run`
|
||||
Process a single prompt through the agent network.
|
||||
|
||||
```bash
|
||||
cleveragents run -c config.yaml -p "Your prompt here"
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `-c, --config`: Path to configuration file(s) (can be used multiple times)
|
||||
- `-p, --prompt`: The prompt to send to the agent network
|
||||
- `-o, --output`: Optional file to write output to
|
||||
- `-v, --verbose`: Enable verbose output
|
||||
- `-u, --unsafe`: Enable unsafe mode for code execution
|
||||
|
||||
#### `cleveragents interactive`
|
||||
Start an interactive chat session.
|
||||
|
||||
```bash
|
||||
cleveragents interactive -c config.yaml
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `-c, --config`: Path to configuration file(s)
|
||||
- `-h, --history`: Optional file to load/save conversation history
|
||||
- `-v, --verbose`: Enable verbose output
|
||||
- `-u, --unsafe`: Enable unsafe mode
|
||||
|
||||
#### `cleveragents generate-examples`
|
||||
Generate example configuration files.
|
||||
|
||||
```bash
|
||||
cleveragents generate-examples -o ./my-examples
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `-o, --output`: Directory to write example files to (default: `./examples`)
|
||||
|
||||
#### `cleveragents visualize`
|
||||
Visualize the agent network.
|
||||
|
||||
```bash
|
||||
cleveragents visualize -c config.yaml -f mermaid
|
||||
```
|
||||
|
||||
**Options:**
|
||||
- `-c, --config`: Path to configuration file(s)
|
||||
- `-o, --output`: Output file for the diagram
|
||||
- `-f, --format`: Output format (`mermaid`, `dot`, `ascii`)
|
||||
|
||||
### Interactive Session Commands
|
||||
|
||||
When in interactive mode, you can use these commands:
|
||||
|
||||
- `exit` - Exit the session
|
||||
- `help` - Show available commands
|
||||
- `/stream <name> <message>` - Send message to specific stream
|
||||
- `/graph <name> <message>` - Execute a LangGraph with message
|
||||
|
||||
## 🔧 API Key Configuration
|
||||
|
||||
CleverAgents supports multiple LLM providers through LangChain. Configure API keys in two ways:
|
||||
|
||||
### Environment Variables (Recommended)
|
||||
|
||||
```bash
|
||||
# OpenAI
|
||||
export OPENAI_API_KEY="sk-your-key-here"
|
||||
|
||||
# Anthropic
|
||||
export ANTHROPIC_API_KEY="your-anthropic-key"
|
||||
|
||||
# Google Gemini
|
||||
export GOOGLE_API_KEY="your-google-key"
|
||||
```
|
||||
|
||||
### Supported Models
|
||||
|
||||
**OpenAI:**
|
||||
- GPT-4, GPT-4o, GPT-3.5-turbo
|
||||
- o1-preview, o1-mini
|
||||
|
||||
**Anthropic:**
|
||||
- Claude-3.5-Sonnet, Claude-3-Opus, Claude-3-Haiku
|
||||
|
||||
**Google:**
|
||||
- Gemini-1.5-Pro, Gemini-1.5-Flash, Gemini-2.0-Flash
|
||||
|
||||
## 🏗️ Architecture
|
||||
|
||||
### Reactive Streams (RxPy)
|
||||
|
||||
CleverAgents uses RxPy for reactive programming:
|
||||
|
||||
- **Hot Streams**: Always active, replay last value
|
||||
- **Cold Streams**: Start when subscribed
|
||||
- **Replay Streams**: Replay all previous values
|
||||
- **Operators**: map, filter, merge, split, buffer, throttle, debounce
|
||||
|
||||
### Agent Types
|
||||
|
||||
1. **LLM Agents**: Powered by large language models
|
||||
2. **Tool Agents**: Execute functions and tools
|
||||
3. **Composite Agents**: Combine multiple agents
|
||||
4. **Chain Agents**: Sequential processing chains
|
||||
|
||||
### Unified Routes System
|
||||
|
||||
Single configuration system for different processing types:
|
||||
|
||||
- **Stream Routes**: Reactive, stateless processing
|
||||
- **Graph Routes**: Stateful workflows with conditional logic
|
||||
- **Bridge Routes**: Dynamic type conversion
|
||||
|
||||
## 📚 Examples
|
||||
|
||||
### Basic Chat
|
||||
```bash
|
||||
cleveragents interactive -c examples/basic_chat.yaml
|
||||
```
|
||||
|
||||
### Scientific Paper Writer
|
||||
```bash
|
||||
cleveragents interactive -c examples/scientific_paper_writer.yaml
|
||||
```
|
||||
|
||||
### Multi-Agent Collaboration
|
||||
```bash
|
||||
cleveragents interactive -c examples/collaboration_reactive.yaml
|
||||
```
|
||||
|
||||
## 🧪 Development
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all BDD tests
|
||||
pip install behave
|
||||
python -m behave tests/features
|
||||
|
||||
# Run with coverage
|
||||
coverage run -m behave tests/features
|
||||
coverage report
|
||||
coverage html
|
||||
|
||||
# Run with tox for multiple environments
|
||||
tox
|
||||
|
||||
# Run specific Python version
|
||||
tox -e py39
|
||||
tox -e py312
|
||||
|
||||
# Run tests with coverage
|
||||
tox -e py312-cover
|
||||
|
||||
# Run without coverage (faster)
|
||||
tox -e py312-nocov
|
||||
|
||||
# Integration test scripts (all tests passing):
|
||||
bash tests/scripts/test_multi_agent_paper_writer_langgraph.sh # Multi-agent paper writing with LangGraph
|
||||
bash tests/scripts/test_legal_contract_analyzer_langgraph.sh # Legal contract analysis with LangGraph
|
||||
bash tests/scripts/test_paper_writer_section_by_section.sh # Section-by-section paper writing
|
||||
```
|
||||
|
||||
### Contributing
|
||||
|
||||
1. Fork the repository
|
||||
2. Create a feature branch
|
||||
3. Add tests for new functionality
|
||||
4. Ensure all tests pass
|
||||
5. Submit a pull request
|
||||
|
||||
## 📄 License
|
||||
|
||||
Apache License 2.0
|
||||
|
||||
## 🔗 Links
|
||||
|
||||
- **Documentation**: https://cleveragents.readthedocs.io/
|
||||
- **PyPI**: https://pypi.org/project/cleveragents/
|
||||
- **GitHub**: https://github.com/cleverthis/cleveragents
|
||||
- **Issues**: https://github.com/cleverthis/cleveragents/issues
|
||||
|
||||
## 🤝 Support
|
||||
|
||||
For questions, issues, or contributions, please visit our [GitHub repository](https://github.com/cleverthis/cleveragents) or contact us at [dev@cleverthis.com](mailto:dev@cleverthis.com).
|
||||
|
||||
+1189
File diff suppressed because it is too large
Load Diff
+2
-2
@@ -1,5 +1,5 @@
|
||||
version: '2'
|
||||
services:
|
||||
boilerplate:
|
||||
image: boilerplate/boilerplate:latest
|
||||
cleveragents:
|
||||
image: cleveragents/cleveragents:latest
|
||||
build: .
|
||||
|
||||
+1012
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,599 @@
|
||||
==============
|
||||
Advanced Usage
|
||||
==============
|
||||
|
||||
Agent Configuration
|
||||
===================
|
||||
|
||||
Agents are defined in the ``agents`` section of your configuration file. Here's an overview of common configuration for the built-in ``llm`` agent type.
|
||||
|
||||
LLMAgent
|
||||
~~~~~~~~
|
||||
|
||||
The ``LLMAgent`` is powered by a large language model. Its configuration includes:
|
||||
|
||||
- ``provider``: The LLM provider to use (e.g., ``openai``, ``anthropic``).
|
||||
- ``model``: The specific model to use (e.g., ``gpt-4``, ``claude-3-opus-20240229``).
|
||||
- ``role``: An inline string defining the agent's role or system message.
|
||||
- ``role_reference``: The name of a template from the top-level ``prompts`` section to use as the role. Cannot be used with ``role``.
|
||||
- ``prompt``: An inline template for formatting the user's input message.
|
||||
- ``prompt_reference``: The name of a template from ``prompts`` to format the user's input. Cannot be used with ``prompt``.
|
||||
- ``response``: An inline template for formatting the LLM's output.
|
||||
- ``response_reference``: The name of a template from ``prompts`` to format the LLM's output. Cannot be used with ``response``.
|
||||
- ``api_key``: (Optional) The API key for the provider. If not provided, it will be read from the corresponding environment variable (e.g., ``OPENAI_API_KEY``).
|
||||
|
||||
Example:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
agents:
|
||||
researcher:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
role: "You are a research assistant."
|
||||
prompt_reference: research_task
|
||||
temperature: 0.7
|
||||
|
||||
prompts:
|
||||
research_task:
|
||||
content: "Research the topic: {{ message }}"
|
||||
|
||||
Custom Agent Implementation
|
||||
==========================
|
||||
|
||||
This section covers advanced usage scenarios for CleverAgents.
|
||||
|
||||
Custom Agent Implementation
|
||||
==========================
|
||||
|
||||
Creating custom agents allows you to extend CleverAgents with specialized functionality.
|
||||
|
||||
Basic Custom Agent
|
||||
----------------
|
||||
|
||||
Here's how to create a basic custom agent:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
|
||||
class MyCustomAgent(Agent):
|
||||
def __init__(self, name, config, template_renderer):
|
||||
super().__init__(name, config, template_renderer)
|
||||
# Initialize your custom agent
|
||||
self.custom_parameter = config.get("custom_parameter", "default")
|
||||
|
||||
async def process(self, message, context=None):
|
||||
# Process the message
|
||||
return f"Custom agent processed: {message}"
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["custom_processing"]
|
||||
|
||||
# Register the custom agent with the factory
|
||||
from cleveragents.agents.factory import AgentFactory
|
||||
|
||||
factory = AgentFactory(config, template_renderer)
|
||||
factory.register_agent_type("custom", MyCustomAgent)
|
||||
|
||||
# Create an instance of the custom agent
|
||||
custom_agent = factory.create_agent("my_custom_agent")
|
||||
|
||||
Stateful Custom Agent
|
||||
-------------------
|
||||
|
||||
For agents that need to maintain state:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.base import AgentWithMemory
|
||||
|
||||
class MyStatefulAgent(AgentWithMemory):
|
||||
def __init__(self, name, config, template_renderer):
|
||||
super().__init__(name, config, template_renderer)
|
||||
# Initialize memory
|
||||
self.memory = {
|
||||
"conversation_count": 0,
|
||||
"last_message": None
|
||||
}
|
||||
|
||||
async def process(self, message, context=None):
|
||||
# Update memory
|
||||
self.memory["conversation_count"] += 1
|
||||
self.memory["last_message"] = message
|
||||
|
||||
# Process the message
|
||||
return f"Message {self.memory['conversation_count']}: {message}"
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["stateful_processing"]
|
||||
|
||||
def save_memory(self):
|
||||
# Return the memory for persistence
|
||||
return self.memory
|
||||
|
||||
def load_memory(self, memory):
|
||||
# Load the memory from persistence
|
||||
self.memory = memory
|
||||
|
||||
Custom Tools
|
||||
===========
|
||||
|
||||
You can create custom tools to extend the functionality of tool agents.
|
||||
|
||||
Basic Custom Tool
|
||||
--------------
|
||||
|
||||
Here's how to create a basic custom tool:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.tool import Tool
|
||||
|
||||
class WeatherTool(Tool):
|
||||
def __init__(self, config=None):
|
||||
super().__init__(
|
||||
name="weather",
|
||||
description="Gets weather information for a location",
|
||||
config=config or {}
|
||||
)
|
||||
self.api_key = self.config.get("api_key", "")
|
||||
|
||||
async def execute(self, input_data, context=None):
|
||||
# Parse the location from the input
|
||||
location = input_data.strip()
|
||||
|
||||
# In a real implementation, you would call a weather API here
|
||||
# For this example, we'll return a mock response
|
||||
return f"The weather in {location} is sunny with a temperature of 25°C."
|
||||
|
||||
# Create an instance of the custom tool
|
||||
weather_tool = WeatherTool({"api_key": "your-api-key"})
|
||||
|
||||
# Use the tool
|
||||
result = await weather_tool.execute("New York")
|
||||
# Result: "The weather in New York is sunny with a temperature of 25°C."
|
||||
|
||||
Integrating Custom Tools
|
||||
---------------------
|
||||
|
||||
To use custom tools in a tool agent:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.tool import ToolAgent
|
||||
|
||||
# Create a tool agent with custom tools
|
||||
tool_agent = ToolAgent(
|
||||
name="tools",
|
||||
config={
|
||||
"tools": [
|
||||
{
|
||||
"name": "weather",
|
||||
"description": "Gets weather information for a location",
|
||||
"class": "path.to.WeatherTool",
|
||||
"config": {
|
||||
"api_key": "your-api-key"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
template_renderer=template_renderer
|
||||
)
|
||||
|
||||
# Process a message
|
||||
response = await tool_agent.process("What's the weather in New York?")
|
||||
|
||||
Advanced Routing
|
||||
==============
|
||||
|
||||
CleverAgents supports advanced routing scenarios for complex agent networks.
|
||||
|
||||
Conditional Routing and Transformation
|
||||
--------------------------------------
|
||||
|
||||
Route messages based on conditions and transform the message payload using Jinja2 templates.
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
routes:
|
||||
main_workflow:
|
||||
- source: input
|
||||
destination: classifier
|
||||
transform: "{{ context.get('initial_message', message) }}"
|
||||
|
||||
- source: classifier
|
||||
destination: calculator
|
||||
condition: "'CALCULATION' in message"
|
||||
transform: "{{ context.get('initial_message', message) }}"
|
||||
|
||||
- source: calculator
|
||||
destination: responder
|
||||
transform: |-
|
||||
The result of {{ context.history[-1].message }} is {{ message }}.
|
||||
|
||||
Dynamic Routing
|
||||
------------
|
||||
|
||||
Implement dynamic routing based on message content:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.routing.router import Router
|
||||
|
||||
class DynamicRouter(Router):
|
||||
async def route_message(self, message, source="input", context=None):
|
||||
# Analyze the message to determine the destination
|
||||
if "weather" in message.lower():
|
||||
destination = "weather_agent"
|
||||
elif "news" in message.lower():
|
||||
destination = "news_agent"
|
||||
elif "calculate" in message.lower():
|
||||
destination = "calculator_agent"
|
||||
else:
|
||||
destination = "general_agent"
|
||||
|
||||
# Route to the determined destination
|
||||
if destination in self.agents:
|
||||
return await self.agents[destination].process(message, context)
|
||||
else:
|
||||
return f"No agent found for: {destination}"
|
||||
|
||||
Advanced Templates
|
||||
================
|
||||
|
||||
CleverAgents supports advanced template features for complex prompt engineering.
|
||||
|
||||
Template Inheritance
|
||||
-----------------
|
||||
|
||||
Create template hierarchies:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.templates.renderer import TemplateRenderer, TemplateEngine
|
||||
|
||||
# Create a template renderer
|
||||
renderer = TemplateRenderer(engine_type=TemplateEngine.JINJA2)
|
||||
|
||||
# Register a base template
|
||||
renderer.register_template(
|
||||
"base_prompt",
|
||||
"""
|
||||
You are a helpful assistant.
|
||||
|
||||
User: {message}
|
||||
|
||||
Assistant:
|
||||
"""
|
||||
)
|
||||
|
||||
# Register a specialized template that extends the base
|
||||
renderer.register_template(
|
||||
"expert_prompt",
|
||||
"""
|
||||
{% extends "base_prompt" %}
|
||||
{% block preamble %}
|
||||
You are an expert in {domain}.
|
||||
{% endblock %}
|
||||
"""
|
||||
)
|
||||
|
||||
# Render the specialized template
|
||||
prompt = renderer.render(
|
||||
"expert_prompt",
|
||||
{"message": "How does quantum computing work?", "domain": "quantum physics"}
|
||||
)
|
||||
|
||||
Template Includes
|
||||
--------------
|
||||
|
||||
Include templates within other templates:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Register component templates
|
||||
renderer.register_template(
|
||||
"header",
|
||||
"# {title}\n\n"
|
||||
)
|
||||
|
||||
renderer.register_template(
|
||||
"footer",
|
||||
"\n\nRespond in a {tone} tone."
|
||||
)
|
||||
|
||||
# Register a template that includes components
|
||||
renderer.register_template(
|
||||
"complete_prompt",
|
||||
"""
|
||||
{% include "header" with {"title": "Expert Consultation"} %}
|
||||
|
||||
You are an expert in {domain}.
|
||||
|
||||
User: {message}
|
||||
|
||||
Assistant:
|
||||
|
||||
{% include "footer" with {"tone": "professional"} %}
|
||||
"""
|
||||
)
|
||||
|
||||
# Render the complete template
|
||||
prompt = renderer.render(
|
||||
"complete_prompt",
|
||||
{"message": "How does quantum computing work?", "domain": "quantum physics"}
|
||||
)
|
||||
|
||||
Conditional Templates
|
||||
-----------------
|
||||
|
||||
Use conditions in templates:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
# Register a template with conditions
|
||||
renderer.register_template(
|
||||
"conditional_prompt",
|
||||
"""
|
||||
You are a helpful assistant.
|
||||
|
||||
{% if context.user_level == "beginner" %}
|
||||
Please provide a simple explanation suitable for beginners.
|
||||
{% elif context.user_level == "intermediate" %}
|
||||
Please provide a detailed explanation with some technical terms.
|
||||
{% else %}
|
||||
Please provide an advanced technical explanation.
|
||||
{% endif %}
|
||||
|
||||
User: {message}
|
||||
|
||||
Assistant:
|
||||
"""
|
||||
)
|
||||
|
||||
# Render with different contexts
|
||||
beginner_prompt = renderer.render(
|
||||
"conditional_prompt",
|
||||
{"message": "How does quantum computing work?", "user_level": "beginner"}
|
||||
)
|
||||
|
||||
expert_prompt = renderer.render(
|
||||
"conditional_prompt",
|
||||
{"message": "How does quantum computing work?", "user_level": "expert"}
|
||||
)
|
||||
|
||||
Distributed Agent Networks
|
||||
========================
|
||||
|
||||
CleverAgents can be used to create distributed agent networks across multiple machines.
|
||||
|
||||
Agent Network Distribution
|
||||
-----------------------
|
||||
|
||||
Distribute agents across multiple machines:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.network import AgentNetwork
|
||||
from cleveragents.distribution import RemoteAgent
|
||||
|
||||
# Create a network
|
||||
network = AgentNetwork()
|
||||
|
||||
# Add a local agent
|
||||
network.add_agent(local_agent)
|
||||
|
||||
# Add a remote agent
|
||||
remote_agent = RemoteAgent(
|
||||
name="remote_assistant",
|
||||
endpoint="http://remote-server:8000/agent",
|
||||
api_key="your-api-key"
|
||||
)
|
||||
network.add_agent(remote_agent)
|
||||
|
||||
# Define connections
|
||||
router = network.get_router()
|
||||
router.add_route({
|
||||
"from": "input",
|
||||
"to": "local_agent",
|
||||
"condition": "true"
|
||||
})
|
||||
router.add_route({
|
||||
"from": "local_agent",
|
||||
"to": "remote_assistant",
|
||||
"condition": "true"
|
||||
})
|
||||
router.add_route({
|
||||
"from": "remote_assistant",
|
||||
"to": "output",
|
||||
"condition": "true"
|
||||
})
|
||||
|
||||
Remote Agent Server
|
||||
----------------
|
||||
|
||||
Create a server for remote agents:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from fastapi import FastAPI, HTTPException, Depends
|
||||
from pydantic import BaseModel
|
||||
from cleveragents.network import AgentNetwork
|
||||
|
||||
app = FastAPI()
|
||||
|
||||
# Create a network with agents
|
||||
network = AgentNetwork()
|
||||
|
||||
class MessageRequest(BaseModel):
|
||||
message: str
|
||||
context: dict = None
|
||||
agent: str
|
||||
|
||||
@app.post("/agent")
|
||||
async def process_message(request: MessageRequest):
|
||||
try:
|
||||
# Get the specified agent
|
||||
agent = network.get_agent(request.agent)
|
||||
if not agent:
|
||||
raise HTTPException(status_code=404, detail=f"Agent {request.agent} not found")
|
||||
|
||||
# Process the message
|
||||
response = await agent.process(request.message, request.context)
|
||||
|
||||
return {"response": response}
|
||||
except Exception as e:
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
|
||||
Performance Optimization
|
||||
======================
|
||||
|
||||
Optimize CleverAgents for better performance.
|
||||
|
||||
Caching
|
||||
------
|
||||
|
||||
Implement caching to reduce redundant processing:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from functools import lru_cache
|
||||
|
||||
class CachedAgent(Agent):
|
||||
def __init__(self, name, config, template_renderer):
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.cache_size = config.get("cache_size", 100)
|
||||
self.process_with_cache = lru_cache(maxsize=self.cache_size)(self._process_uncached)
|
||||
|
||||
async def process(self, message, context=None):
|
||||
# Convert context to a hashable form for caching
|
||||
hashable_context = None
|
||||
if context:
|
||||
hashable_context = tuple(sorted((k, str(v)) for k, v in context.items()))
|
||||
|
||||
return await self.process_with_cache(message, hashable_context)
|
||||
|
||||
async def _process_uncached(self, message, hashable_context):
|
||||
# Convert hashable context back to a dict
|
||||
context = None
|
||||
if hashable_context:
|
||||
context = {k: v for k, v in hashable_context}
|
||||
|
||||
# Actual processing logic
|
||||
return f"Processed: {message}"
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["cached_processing"]
|
||||
|
||||
Batching
|
||||
-------
|
||||
|
||||
Batch process messages for better throughput:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
import asyncio
|
||||
|
||||
class BatchAgent(Agent):
|
||||
def __init__(self, name, config, template_renderer):
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.batch_size = config.get("batch_size", 10)
|
||||
self.batch_timeout = config.get("batch_timeout", 1.0)
|
||||
self.batch = []
|
||||
self.batch_lock = asyncio.Lock()
|
||||
self.batch_event = asyncio.Event()
|
||||
self.results = {}
|
||||
self.next_id = 0
|
||||
|
||||
# Start the batch processor
|
||||
asyncio.create_task(self._process_batches())
|
||||
|
||||
async def process(self, message, context=None):
|
||||
async with self.batch_lock:
|
||||
# Assign an ID to this request
|
||||
request_id = self.next_id
|
||||
self.next_id += 1
|
||||
|
||||
# Add to the batch
|
||||
self.batch.append((request_id, message, context))
|
||||
|
||||
# Signal that a new item is in the batch
|
||||
self.batch_event.set()
|
||||
|
||||
# Wait for the result
|
||||
while request_id not in self.results:
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
# Get and remove the result
|
||||
result = self.results.pop(request_id)
|
||||
return result
|
||||
|
||||
async def _process_batches(self):
|
||||
while True:
|
||||
# Wait for items in the batch
|
||||
await self.batch_event.wait()
|
||||
|
||||
# Wait for more items or timeout
|
||||
await asyncio.sleep(self.batch_timeout)
|
||||
|
||||
# Get the current batch
|
||||
async with self.batch_lock:
|
||||
current_batch = self.batch[:self.batch_size]
|
||||
self.batch = self.batch[self.batch_size:]
|
||||
|
||||
# Reset the event if the batch is empty
|
||||
if not self.batch:
|
||||
self.batch_event.clear()
|
||||
|
||||
# Process the batch
|
||||
if current_batch:
|
||||
batch_results = await self._process_batch(current_batch)
|
||||
|
||||
# Store the results
|
||||
for request_id, result in batch_results:
|
||||
self.results[request_id] = result
|
||||
|
||||
async def _process_batch(self, batch):
|
||||
# Process the batch and return results
|
||||
# This is where you would implement your batch processing logic
|
||||
return [(request_id, f"Batch processed: {message}") for request_id, message, _ in batch]
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["batch_processing"]
|
||||
|
||||
Parallel Processing
|
||||
----------------
|
||||
|
||||
Process messages in parallel:
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
import asyncio
|
||||
|
||||
class ParallelAgent(Agent):
|
||||
def __init__(self, name, config, template_renderer):
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.max_parallel = config.get("max_parallel", 10)
|
||||
self.semaphore = asyncio.Semaphore(self.max_parallel)
|
||||
|
||||
async def process(self, message, context=None):
|
||||
async with self.semaphore:
|
||||
# Process the message with a limit on parallel executions
|
||||
return await self._process_message(message, context)
|
||||
|
||||
async def _process_message(self, message, context=None):
|
||||
# Actual processing logic
|
||||
return f"Processed: {message}"
|
||||
|
||||
def get_capabilities(self):
|
||||
return ["parallel_processing"]
|
||||
@@ -0,0 +1,8 @@
|
||||
=============
|
||||
API Reference
|
||||
=============
|
||||
|
||||
This section provides detailed API documentation for CleverAgents.
|
||||
|
||||
Agents:
|
||||
The agents module contains all the implementations of the different types of agents available in the package.
|
||||
@@ -1 +0,0 @@
|
||||
.. include:: ../AUTHORS.md
|
||||
+1
-1
@@ -1 +1 @@
|
||||
.. include:: ../CHANGELOG.md
|
||||
Changelog
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
Concepts
|
||||
+6
-4
@@ -2,6 +2,8 @@
|
||||
from __future__ import unicode_literals
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.insert(0, os.path.abspath('../src'))
|
||||
|
||||
|
||||
extensions = [
|
||||
@@ -22,7 +24,7 @@ if os.getenv('SPELLCHECK'):
|
||||
|
||||
source_suffix = '.rst'
|
||||
master_doc = 'index'
|
||||
project = u'Boilerplate'
|
||||
project = u'CleverAgents'
|
||||
year = '2024'
|
||||
author = u'Jeffrey Phillips Freeman'
|
||||
copyright = '{0}, {1}'.format(year, author)
|
||||
@@ -31,14 +33,14 @@ version = release = u'0.1.0'
|
||||
pygments_style = 'trac'
|
||||
templates_path = ['.']
|
||||
extlinks = {
|
||||
'issue': ('https://git.cleverthis.com/cleverthis/base/base-python/-/issues%s', '#'),
|
||||
'pr': ('https://git.cleverthis.com/cleverthis/base/base-python/-/merge_requests%s', 'PR #'),
|
||||
'issue': ('https://git.cleverthis.com/cleverthis/cleveragents/-/issues%s', '#'),
|
||||
'pr': ('https://git.cleverthis.com/cleverthis/cleveragents/-/merge_requests%s', 'PR #'),
|
||||
}
|
||||
import sphinx_py3doc_enhanced_theme
|
||||
html_theme = "sphinx_py3doc_enhanced_theme"
|
||||
html_theme_path = [sphinx_py3doc_enhanced_theme.get_html_theme_path()]
|
||||
html_theme_options = {
|
||||
'githuburl': 'https://git.cleverthis.com/cleverthis/base/base-python'
|
||||
'githuburl': 'https://git.cleverthis.com/cleverthis/cleveragents'
|
||||
}
|
||||
|
||||
html_use_smartypants = True
|
||||
|
||||
@@ -1 +1 @@
|
||||
.. include:: ../CONTRIBUTING.md
|
||||
.. include:: ../CONTRIBUTING.rst
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
.. include:: ../CONTRIBUTORS.rst
|
||||
+5
-2
@@ -7,10 +7,14 @@ Contents
|
||||
|
||||
readme
|
||||
installation
|
||||
quickstart
|
||||
usage
|
||||
concepts
|
||||
advanced_usage
|
||||
api_reference
|
||||
reference/index
|
||||
contributing
|
||||
authors
|
||||
contributors
|
||||
changelog
|
||||
|
||||
Indices and tables
|
||||
@@ -19,4 +23,3 @@ Indices and tables
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
||||
|
||||
|
||||
@@ -4,4 +4,4 @@ Installation
|
||||
|
||||
At the command line::
|
||||
|
||||
pip install boilerplate
|
||||
pip install cleveragents
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
===========
|
||||
Quick Start
|
||||
===========
|
||||
|
||||
This guide will help you get started with CleverAgents quickly. We'll create a simple agent network, run it in both single-shot and interactive modes, and explore some basic customization options.
|
||||
|
||||
Installation
|
||||
===========
|
||||
|
||||
First, install CleverAgents using pip:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
pip install cleveragents
|
||||
|
||||
================================
|
||||
Creating Your First Agent Network
|
||||
================================
|
||||
|
||||
Let's create a simple agent network with two agents: a user interface agent and an LLM-powered assistant.
|
||||
|
||||
Create a file named ``my_network.yaml`` with the following content:
|
||||
|
||||
.. code-block:: yaml
|
||||
|
||||
name: "Simple Assistant Network"
|
||||
description: "A basic network with a user interface and an assistant"
|
||||
|
||||
variables:
|
||||
default_model: "gpt-4"
|
||||
|
||||
agents:
|
||||
- name: "interface"
|
||||
type: "interface"
|
||||
description: "Handles user interaction"
|
||||
|
||||
- name: "assistant"
|
||||
type: "llm"
|
||||
description: "Provides helpful responses"
|
||||
model: "${default_model}"
|
||||
system_message: "You are a helpful assistant that provides concise and accurate information."
|
||||
|
||||
connections:
|
||||
- from: "interface"
|
||||
to: "assistant"
|
||||
|
||||
- from: "assistant"
|
||||
to: "interface"
|
||||
|
||||
==========================
|
||||
Running in Single-Shot Mode
|
||||
==========================
|
||||
|
||||
To process a single query through your agent network:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cleveragents run --config my_network.yaml --input "What is the capital of France?"
|
||||
|
||||
This will output:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
The capital of France is Paris.
|
||||
|
||||
=========================
|
||||
Running in Interactive Mode
|
||||
=========================
|
||||
|
||||
For a continuous conversation with your agent network:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cleveragents interactive --config my_network.yaml
|
||||
|
||||
This will start an interactive session:
|
||||
|
||||
.. code-block:: text
|
||||
|
||||
CleverAgents Interactive Session
|
||||
Type '/help' for available commands
|
||||
> What is the capital of France?
|
||||
The capital of France is Paris.
|
||||
|
||||
> Tell me more about Paris.
|
||||
Paris is the capital and most populous city of France. Located on the Seine River in the north-central part of the country, it is a major European city and a global center for art, fashion, gastronomy, and culture. The city is known for its iconic landmarks such as the Eiffel Tower, the Louvre Museum, Notre-Dame Cathedral, and the Champs-Élysées.
|
||||
|
||||
> /exit
|
||||
Session ended.
|
||||
|
||||
=======================
|
||||
Customizing Your Network
|
||||
=======================
|
||||
|
||||
You can override variables in your configuration at runtime:
|
||||
|
||||
.. code-block:: bash
|
||||
|
||||
cleveragents run --config my_network.yaml --var default_model=gpt-3.5-turbo --input "What is the capital of France?"
|
||||
|
||||
This will use the GPT-3.5 Turbo model instead of GPT-4 for this run.
|
||||
|
||||
Next Steps
|
||||
|
||||
Troubleshooting
|
||||
---------------
|
||||
|
||||
If you encounter any issues while setting up or running CleverAgents, please check the following:
|
||||
- Ensure that all dependencies are installed correctly.
|
||||
- Verify that your configuration file is properly formatted.
|
||||
- Consult the FAQ section on our website or open an issue on the repository if the problem persists.
|
||||
+1
-1
@@ -1 +1 @@
|
||||
.. include:: ../README.md
|
||||
.. include:: ../README.rst
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
boilerplate
|
||||
==========
|
||||
|
||||
.. testsetup::
|
||||
|
||||
from boilerplate import *
|
||||
|
||||
.. automodule:: boilerplate
|
||||
:members:
|
||||
@@ -0,0 +1,10 @@
|
||||
=============
|
||||
cleveragents
|
||||
=============
|
||||
|
||||
.. testsetup::
|
||||
|
||||
from cleveragents import *
|
||||
|
||||
.. automodule:: cleveragents
|
||||
:members:
|
||||
@@ -1,7 +1,20 @@
|
||||
Reference
|
||||
|
||||
=========
|
||||
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
boilerplate*
|
||||
cleveragents*
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
cleveragents*
|
||||
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
cleveragents*
|
||||
.. toctree::
|
||||
:glob:
|
||||
|
||||
cleveragents*
|
||||
|
||||
@@ -7,5 +7,4 @@ staticmethods
|
||||
args
|
||||
kwargs
|
||||
callstack
|
||||
Changelog
|
||||
Indices
|
||||
|
||||
+4
-2
@@ -2,6 +2,8 @@
|
||||
Usage
|
||||
=====
|
||||
|
||||
To use Boilerplate in a project::
|
||||
To use CleverAgents in a project::
|
||||
|
||||
import boilerplate
|
||||
import cleveragents
|
||||
Usage
|
||||
Usage
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
# Basic Reactive Chat Configuration
|
||||
# Simple conversational agent with reactive streams
|
||||
|
||||
agents:
|
||||
chat_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.8
|
||||
system_prompt: |
|
||||
You are a helpful and friendly AI assistant.
|
||||
Respond naturally and conversationally.
|
||||
|
||||
# NEW: Unified routes section
|
||||
routes:
|
||||
# Main chat processing route
|
||||
chat_stream:
|
||||
type: stream # REQUIRED: Must specify type (stream or graph)
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: chat_agent
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Simple flow: input -> chat -> output
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: chat_stream
|
||||
|
||||
context:
|
||||
global:
|
||||
conversation_mode: true
|
||||
@@ -0,0 +1,40 @@
|
||||
# Basic Chat with Memory Configuration
|
||||
# Simple conversational agent with memory enabled for testing
|
||||
|
||||
agents:
|
||||
chat_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.8
|
||||
memory_enabled: true
|
||||
max_history: 10
|
||||
system_prompt: |
|
||||
You are a helpful and friendly AI assistant with memory.
|
||||
You can remember our conversation and refer back to previous topics.
|
||||
Respond naturally and conversationally, and feel free to reference
|
||||
what we've discussed before.
|
||||
|
||||
# NEW: Unified routes section
|
||||
routes:
|
||||
# Main chat processing route
|
||||
chat_stream:
|
||||
type: stream # REQUIRED: Must specify type (stream or graph)
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: chat_agent
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Simple flow: input -> chat -> output
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: chat_stream
|
||||
|
||||
context:
|
||||
global:
|
||||
conversation_mode: true
|
||||
memory_enabled: true
|
||||
@@ -0,0 +1,188 @@
|
||||
# Hybrid RxPy-LangGraph Pipeline
|
||||
# Demonstrates seamless integration between RxPy streams and LangGraph
|
||||
|
||||
agents:
|
||||
preprocessor:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
temperature: 0.2
|
||||
system_prompt: "Clean and normalize user input. Fix typos and grammar."
|
||||
|
||||
analyzer:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.3
|
||||
system_prompt: "Analyze the content and extract key insights."
|
||||
|
||||
formatter:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
temperature: 0.5
|
||||
system_prompt: "Format the analysis into a clear, structured response."
|
||||
|
||||
# NEW: Unified routes section
|
||||
routes:
|
||||
# Define a processing graph
|
||||
analysis_graph:
|
||||
type: graph # REQUIRED: Must specify type
|
||||
nodes:
|
||||
analyze:
|
||||
type: agent
|
||||
agent: analyzer
|
||||
|
||||
format_output:
|
||||
type: agent
|
||||
agent: formatter
|
||||
|
||||
edges:
|
||||
- source: start
|
||||
target: analyze
|
||||
- source: analyze
|
||||
target: format_output
|
||||
- source: format_output
|
||||
target: end
|
||||
|
||||
# Preprocessing stream with RxPy operators
|
||||
input_preprocessor:
|
||||
type: stream # REQUIRED: Must specify type
|
||||
stream_type: cold
|
||||
operators:
|
||||
# Debounce rapid inputs
|
||||
- type: debounce
|
||||
params:
|
||||
duration: 0.5
|
||||
|
||||
# Clean input with agent
|
||||
- type: map
|
||||
params:
|
||||
agent: preprocessor
|
||||
|
||||
# Add metadata
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: wrap
|
||||
wrapper:
|
||||
timestamp: "{{now}}"
|
||||
preprocessed: true
|
||||
publications:
|
||||
- graph_trigger
|
||||
|
||||
# Stream that executes the LangGraph
|
||||
graph_trigger:
|
||||
type: stream # REQUIRED: Must specify type
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: graph_execute
|
||||
params:
|
||||
graph: analysis_graph
|
||||
publications:
|
||||
- postprocessor
|
||||
|
||||
# Postprocessing with RxPy
|
||||
postprocessor:
|
||||
type: cold
|
||||
operators:
|
||||
# Buffer results for batch processing
|
||||
- type: buffer
|
||||
params:
|
||||
count: 3
|
||||
|
||||
# Format buffered results
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: format
|
||||
template: |
|
||||
=== Analysis Batch ===
|
||||
{content}
|
||||
==================
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Define a hybrid pipeline (alternative approach)
|
||||
pipelines:
|
||||
complete_analysis:
|
||||
stages:
|
||||
# Stage 1: RxPy preprocessing
|
||||
- type: stream
|
||||
name: stage1_preprocess
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: filter
|
||||
params:
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "analyze"
|
||||
- type: throttle
|
||||
params:
|
||||
duration: 1.0
|
||||
publications:
|
||||
- stage2_input
|
||||
|
||||
# Stage 2: LangGraph processing
|
||||
- type: graph
|
||||
config:
|
||||
name: inline_processor
|
||||
nodes:
|
||||
deep_analysis:
|
||||
type: agent
|
||||
agent: analyzer
|
||||
parallel: true
|
||||
|
||||
context_check:
|
||||
type: function
|
||||
function: validate
|
||||
parallel: true
|
||||
|
||||
synthesize:
|
||||
type: function
|
||||
function: summarize
|
||||
|
||||
edges:
|
||||
- source: start
|
||||
target: deep_analysis
|
||||
- source: start
|
||||
target: context_check
|
||||
- source: deep_analysis
|
||||
target: synthesize
|
||||
- source: context_check
|
||||
target: synthesize
|
||||
- source: synthesize
|
||||
target: end
|
||||
|
||||
input_from: stage2_input
|
||||
output_to: stage3_input
|
||||
|
||||
# Stage 3: RxPy postprocessing
|
||||
- type: stream
|
||||
name: stage3_finalize
|
||||
subscriptions:
|
||||
- stage3_input
|
||||
operators:
|
||||
- type: distinct
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: wrap
|
||||
wrapper:
|
||||
final: true
|
||||
pipeline: "complete_analysis"
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Route inputs
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: input_preprocessor
|
||||
|
||||
context:
|
||||
global:
|
||||
app_name: "Hybrid RxPy-LangGraph Analysis Pipeline"
|
||||
enable_parallel: true
|
||||
@@ -0,0 +1,127 @@
|
||||
# LangGraph with Conditional Routing
|
||||
# Demonstrates conditional flow based on message content
|
||||
|
||||
agents:
|
||||
classifier:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
temperature: 0.1
|
||||
system_prompt: |
|
||||
Classify the input as either:
|
||||
- "question" if it's asking something
|
||||
- "command" if it's requesting an action
|
||||
- "statement" if it's providing information
|
||||
Reply with only one word: question, command, or statement.
|
||||
|
||||
qa_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.7
|
||||
system_prompt: "Answer questions accurately and thoroughly."
|
||||
|
||||
command_processor:
|
||||
type: tool
|
||||
config:
|
||||
tools: ["echo", "math"]
|
||||
safe_mode: true
|
||||
|
||||
acknowledger:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
temperature: 0.5
|
||||
system_prompt: "Acknowledge statements and provide relevant comments."
|
||||
|
||||
# NEW: Unified routes section
|
||||
routes:
|
||||
conditional_flow:
|
||||
type: graph # REQUIRED: Must specify type
|
||||
entry_point: start
|
||||
|
||||
nodes:
|
||||
# Classify the input
|
||||
classify:
|
||||
type: agent
|
||||
agent: classifier
|
||||
|
||||
# Route based on classification
|
||||
router:
|
||||
type: conditional
|
||||
condition:
|
||||
type: always # The actual routing happens via edges
|
||||
|
||||
# Process question
|
||||
handle_question:
|
||||
type: agent
|
||||
agent: qa_agent
|
||||
|
||||
# Process command
|
||||
handle_command:
|
||||
type: agent
|
||||
agent: command_processor
|
||||
|
||||
# Process statement
|
||||
handle_statement:
|
||||
type: agent
|
||||
agent: acknowledger
|
||||
|
||||
edges:
|
||||
# Initial flow
|
||||
- source: start
|
||||
target: classify
|
||||
|
||||
- source: classify
|
||||
target: router
|
||||
|
||||
# Conditional routing based on classification
|
||||
- source: router
|
||||
target: handle_question
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "question"
|
||||
|
||||
- source: router
|
||||
target: handle_command
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "command"
|
||||
|
||||
- source: router
|
||||
target: handle_statement
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "statement"
|
||||
|
||||
# All paths lead to end
|
||||
- source: handle_question
|
||||
target: end
|
||||
|
||||
- source: handle_command
|
||||
target: end
|
||||
|
||||
- source: handle_statement
|
||||
target: end
|
||||
|
||||
# Simple stream setup
|
||||
input_handler:
|
||||
type: stream # REQUIRED: Must specify type
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: graph_execute
|
||||
params:
|
||||
graph: conditional_flow
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: input_handler
|
||||
|
||||
context:
|
||||
global:
|
||||
app_name: "Conditional LangGraph Router"
|
||||
@@ -0,0 +1,495 @@
|
||||
# Legal Contract Metadata Extractor - LangGraph with Conditional Routing
|
||||
# True multi-agent system with dynamic workflow and conditional routing
|
||||
#
|
||||
# AGENTS:
|
||||
# - Coordinator: Loads documents and decides routing
|
||||
# - Text Preprocessor: Cleans and structures contract text
|
||||
# - Contract Analyzer: Extracts detailed metadata
|
||||
# - Risk Assessor: Identifies risks and issues
|
||||
# - JSON Formatter: Creates structured output
|
||||
# - File Manager: Saves analysis results
|
||||
#
|
||||
# USAGE:
|
||||
# cleveragents interactive -c examples/legal_contract_metadata_extractor_langgraph.yaml --unsafe
|
||||
#
|
||||
# HOW IT WORKS:
|
||||
# - LangGraph manages stateful workflow with conditional routing
|
||||
# - Coordinator loads documents and decides which specialist to invoke
|
||||
# - Each agent actually executes (not simulation)
|
||||
# - Dynamic routing based on analysis stage
|
||||
# - Supports iterative refinement and re-analysis
|
||||
|
||||
agents:
|
||||
# File operations agents
|
||||
file_reader:
|
||||
type: tool
|
||||
config:
|
||||
tools: ["file_read"]
|
||||
safe_mode: true
|
||||
|
||||
file_manager:
|
||||
type: tool
|
||||
config:
|
||||
tools: ["file_write"]
|
||||
safe_mode: false
|
||||
|
||||
# Coordinator agent - loads documents and decides routing
|
||||
coordinator:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4o
|
||||
temperature: 0.3
|
||||
memory_enabled: true
|
||||
max_history: 20
|
||||
max_tokens: 2000
|
||||
system_prompt: |
|
||||
You are the COORDINATOR agent in a multi-agent legal contract analysis system.
|
||||
|
||||
YOUR ROLE: Route workflow to appropriate specialist agents.
|
||||
|
||||
WORKFLOW STAGES:
|
||||
1. Read file (if file path provided)
|
||||
2. Route to TEXT_PREPROCESSOR for cleaning
|
||||
3. Route to CONTRACT_ANALYZER for metadata extraction
|
||||
4. Route to RISK_ASSESSOR for risk analysis
|
||||
5. Route to JSON_FORMATTER for structured output
|
||||
6. Route to FILE_MANAGER for saving (if requested)
|
||||
7. End workflow
|
||||
|
||||
FILE READING:
|
||||
When user provides file path, output this simple command format:
|
||||
file_read sample_contract.txt
|
||||
[ROUTE:READ_FILE]
|
||||
|
||||
FILE SAVING:
|
||||
When user wants to save analysis, extract filename and route to file manager:
|
||||
"User wants to save analysis to 'contract_analysis.json'. Routing to file manager.
|
||||
[ROUTE:FILE_MANAGER]
|
||||
FILENAME: contract_analysis.json"
|
||||
|
||||
ROUTING DECISIONS:
|
||||
Output ONE of these routing tags based on workflow stage:
|
||||
|
||||
[ROUTE:READ_FILE] - When file path provided, route to file reader first
|
||||
[ROUTE:PREPROCESSOR] - After file loaded, route to text preprocessor
|
||||
[ROUTE:ANALYZER] - After preprocessing, route to contract analyzer
|
||||
[ROUTE:RISK] - After analysis, route to risk assessor
|
||||
[ROUTE:FORMATTER] - After risk assessment, route to JSON formatter
|
||||
[ROUTE:FILE_MANAGER] - When user wants to save analysis to file
|
||||
[ROUTE:END] - When analysis is complete
|
||||
|
||||
OUTPUT FORMAT:
|
||||
For file reading:
|
||||
file_read filename.txt
|
||||
[ROUTE:READ_FILE]
|
||||
|
||||
For routing:
|
||||
Proceeding to next stage.
|
||||
[ROUTE:NEXT_STAGE]
|
||||
|
||||
Always include the [ROUTE:X] tag!
|
||||
|
||||
# Text preprocessing agent
|
||||
text_preprocessor:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
temperature: 0.1
|
||||
memory_enabled: true
|
||||
max_history: 20
|
||||
system_prompt: |
|
||||
You are the TEXT_PREPROCESSOR agent in a legal contract analysis system.
|
||||
|
||||
YOUR ROLE: Clean and structure raw contract text for analysis.
|
||||
|
||||
IMPORTANT: The complete contract file content is in the conversation history.
|
||||
Look for the full text provided by the file reader tool. DO NOT make up data.
|
||||
Extract information ONLY from the actual contract text you see.
|
||||
|
||||
TASKS:
|
||||
- Fix OCR errors and formatting issues
|
||||
- Normalize dates to YYYY-MM-DD format
|
||||
- Standardize monetary amounts ($X,XXX.XX USD)
|
||||
- Extract proper nouns (companies, people, locations)
|
||||
- Label key sections with markers
|
||||
|
||||
SECTION MARKERS:
|
||||
[PARTIES] ... [/PARTIES]
|
||||
[DATES] ... [/DATES]
|
||||
[FINANCIAL_TERMS] ... [/FINANCIAL_TERMS]
|
||||
[OBLIGATIONS] ... [/OBLIGATIONS]
|
||||
[LEGAL_TERMS] ... [/LEGAL_TERMS]
|
||||
|
||||
COMPLETION SIGNAL:
|
||||
End your response with: "[PREPROCESSING_COMPLETE]"
|
||||
|
||||
OUTPUT FORMAT:
|
||||
"[TEXT_PREPROCESSOR SPEAKING]
|
||||
Cleaning and structuring contract text...
|
||||
|
||||
[Structured content with section markers]
|
||||
|
||||
[PREPROCESSING_COMPLETE]"
|
||||
|
||||
# Contract analysis agent
|
||||
contract_analyzer:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.2
|
||||
memory_enabled: true
|
||||
max_history: 20
|
||||
max_tokens: 2000
|
||||
system_prompt: |
|
||||
You are the CONTRACT_ANALYZER agent in a legal contract analysis system.
|
||||
|
||||
YOUR ROLE: Extract comprehensive metadata from preprocessed contract text.
|
||||
|
||||
ACCESSING PREPROCESSED TEXT:
|
||||
- Review the COMPLETE conversation history
|
||||
- Look for "[TEXT_PREPROCESSOR SPEAKING]" in the conversation
|
||||
- Extract the structured contract text with section markers
|
||||
- Look for "[COORDINATOR SPEAKING]" to find the original contract
|
||||
- Use ALL available information from previous agents
|
||||
|
||||
IMPORTANT: The contract and preprocessed text exist in conversation history!
|
||||
Search for them before analyzing.
|
||||
|
||||
EXTRACT:
|
||||
- PARTIES: Names, roles, addresses, representatives, confidence scores
|
||||
- DATES: Signing, effective, expiration, payment, milestones
|
||||
- FINANCIAL TERMS: Values, schedules, penalties, currency
|
||||
- OBLIGATIONS: Deliverables, requirements, standards
|
||||
- LEGAL TERMS: Governing law, termination, liability, IP, confidentiality
|
||||
|
||||
COMPLETION SIGNAL:
|
||||
End your response with: "[ANALYSIS_COMPLETE]"
|
||||
|
||||
OUTPUT FORMAT:
|
||||
"[CONTRACT_ANALYZER SPEAKING]
|
||||
Extracting comprehensive metadata...
|
||||
|
||||
PARTIES INFORMATION:
|
||||
[Detailed party information with confidence scores]
|
||||
|
||||
KEY DATES:
|
||||
[All dates with confidence scores]
|
||||
|
||||
FINANCIAL TERMS:
|
||||
[Payment details with confidence scores]
|
||||
|
||||
OBLIGATIONS:
|
||||
[Deliverables with confidence scores]
|
||||
|
||||
LEGAL TERMS:
|
||||
[Legal provisions with confidence scores]
|
||||
|
||||
[ANALYSIS_COMPLETE]"
|
||||
|
||||
# Risk assessment agent
|
||||
risk_assessor:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.3
|
||||
memory_enabled: true
|
||||
max_history: 20
|
||||
max_tokens: 2000
|
||||
system_prompt: |
|
||||
You are the RISK_ASSESSOR agent in a legal contract analysis system.
|
||||
|
||||
YOUR ROLE: Identify risks, issues, and potential problems.
|
||||
|
||||
ACCESSING ANALYSIS DATA:
|
||||
- Review the COMPLETE conversation history
|
||||
- Look for "[CONTRACT_ANALYZER SPEAKING]" to find extracted metadata
|
||||
- Look for "[TEXT_PREPROCESSOR SPEAKING]" to find structured text
|
||||
- Look for "[COORDINATOR SPEAKING]" to find original contract
|
||||
- Use ALL information from previous agents
|
||||
|
||||
IMPORTANT: All analysis data exists in conversation history!
|
||||
Search for previous agents' outputs before assessing.
|
||||
|
||||
ASSESS:
|
||||
- HIGH-RISK TERMS: Unusual or unfavorable provisions
|
||||
- MISSING CLAUSES: Absent standard protections
|
||||
- AMBIGUOUS LANGUAGE: Unclear or vague terms
|
||||
- COMPLIANCE CONCERNS: Regulatory issues
|
||||
- OVERALL RISK SCORE: 0.0-1.0 scale
|
||||
|
||||
COMPLETION SIGNAL:
|
||||
End your response with: "[RISK_ASSESSMENT_COMPLETE]"
|
||||
|
||||
OUTPUT FORMAT:
|
||||
"[RISK_ASSESSOR SPEAKING]
|
||||
Conducting risk assessment...
|
||||
|
||||
HIGH-RISK TERMS:
|
||||
[List with confidence scores]
|
||||
|
||||
MISSING CLAUSES:
|
||||
[List with confidence scores]
|
||||
|
||||
AMBIGUOUS LANGUAGE:
|
||||
[List with confidence scores]
|
||||
|
||||
COMPLIANCE CONCERNS:
|
||||
[List of concerns]
|
||||
|
||||
OVERALL RISK ASSESSMENT:
|
||||
- Risk Score: X.XX
|
||||
- Risk Level: Low/Medium/High/Critical
|
||||
- Recommendations: [List]
|
||||
|
||||
[RISK_ASSESSMENT_COMPLETE]"
|
||||
|
||||
# JSON formatting agent
|
||||
metadata_formatter:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
temperature: 0.0
|
||||
memory_enabled: true
|
||||
max_history: 15
|
||||
max_tokens: 3000
|
||||
system_prompt: |
|
||||
You are the JSON_FORMATTER agent in a legal contract analysis system.
|
||||
|
||||
YOUR ROLE: Convert all analysis into structured JSON format.
|
||||
|
||||
ACCESSING ALL ANALYSIS DATA:
|
||||
- Review the COMPLETE conversation history
|
||||
- Look for "[TEXT_PREPROCESSOR SPEAKING]" to find structured text
|
||||
- Look for "[CONTRACT_ANALYZER SPEAKING]" to find extracted metadata
|
||||
- Look for "[RISK_ASSESSOR SPEAKING]" to find risk assessment
|
||||
- Extract ALL data from these agents
|
||||
|
||||
IMPORTANT: All analysis exists in conversation history!
|
||||
Search for each agent's output and compile into JSON.
|
||||
|
||||
TASK:
|
||||
- Extract all data from PREPROCESSOR, ANALYZER, and RISK_ASSESSOR
|
||||
- Format into comprehensive JSON structure
|
||||
|
||||
JSON STRUCTURE:
|
||||
{
|
||||
"document_info": {...},
|
||||
"parties": [...],
|
||||
"dates": {...},
|
||||
"financial_terms": {...},
|
||||
"obligations": {...},
|
||||
"legal_terms": {...},
|
||||
"risk_assessment": {...},
|
||||
"extraction_summary": {...}
|
||||
}
|
||||
|
||||
COMPLETION SIGNAL:
|
||||
End your response with: "[JSON_COMPLETE]"
|
||||
|
||||
OUTPUT FORMAT:
|
||||
"[JSON_FORMATTER SPEAKING]
|
||||
Converting to structured JSON...
|
||||
|
||||
```json
|
||||
{[COMPLETE JSON]}
|
||||
```
|
||||
|
||||
[JSON_COMPLETE]"
|
||||
|
||||
# File manager agent - extracts JSON and formats file write command
|
||||
file_manager:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.3
|
||||
memory_enabled: true
|
||||
max_history: 30
|
||||
system_prompt: |
|
||||
You are the FILE_MANAGER agent in a legal contract analysis system.
|
||||
|
||||
YOUR ROLE: Extract the JSON analysis from conversation history and format a file write command.
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Look for the target filename in the user's message (e.g., "contract_analysis_result.json")
|
||||
2. Search the conversation history for "[JSON_FORMATTER SPEAKING]"
|
||||
3. Extract the COMPLETE JSON content from within the ```json code block
|
||||
4. Output ONLY a JSON command with the analysis content
|
||||
|
||||
OUTPUT FORMAT (CRITICAL - FOLLOW EXACTLY):
|
||||
{"tool": "file_write", "args": {"file": "filename.json", "content": "COMPLETE_JSON_HERE"}}
|
||||
|
||||
EXAMPLE:
|
||||
Input: User says "save the analysis to contract_analysis_result.json"
|
||||
Output: {"tool": "file_write", "args": {"file": "contract_analysis_result.json", "content": "{\"document_info\": {...}, \"parties\": [...], ...}"}}
|
||||
|
||||
CRITICAL RULES:
|
||||
- Output ONLY the JSON command, absolutely no other text before or after
|
||||
- Include the COMPLETE JSON from the formatter (as a string, with escaped quotes)
|
||||
- Use the exact filename from the user's request
|
||||
- If no specific filename mentioned, use "contract_analysis.json"
|
||||
- Strip any markdown code block markers (``` json or ```) from the extracted JSON
|
||||
|
||||
# Tool executor for actual file operations
|
||||
file_writer_tool:
|
||||
type: tool
|
||||
config:
|
||||
tools: ["file_write"]
|
||||
safe_mode: false
|
||||
|
||||
routes:
|
||||
# LangGraph workflow with conditional routing
|
||||
contract_analysis_workflow:
|
||||
type: graph
|
||||
entry_point: start
|
||||
checkpointing: true
|
||||
|
||||
nodes:
|
||||
# Coordinator routes workflow
|
||||
coordinate:
|
||||
type: agent
|
||||
agent: coordinator
|
||||
|
||||
# File reader tool executes file reading
|
||||
read_file:
|
||||
type: agent
|
||||
agent: file_reader
|
||||
|
||||
# Text preprocessor cleans text
|
||||
preprocess:
|
||||
type: agent
|
||||
agent: text_preprocessor
|
||||
|
||||
# Contract analyzer extracts metadata
|
||||
analyze:
|
||||
type: agent
|
||||
agent: contract_analyzer
|
||||
|
||||
# Risk assessor identifies issues
|
||||
assess_risk:
|
||||
type: agent
|
||||
agent: risk_assessor
|
||||
|
||||
# JSON formatter creates output
|
||||
format_json:
|
||||
type: agent
|
||||
agent: metadata_formatter
|
||||
|
||||
# File manager extracts JSON and formats command
|
||||
save_file:
|
||||
type: agent
|
||||
agent: file_manager
|
||||
|
||||
# Tool executor writes the file
|
||||
execute_write:
|
||||
type: agent
|
||||
agent: file_writer_tool
|
||||
|
||||
edges:
|
||||
# Always start with coordinator
|
||||
- source: start
|
||||
target: coordinate
|
||||
|
||||
# Coordinator routes to file reader
|
||||
- source: coordinate
|
||||
target: read_file
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:READ_FILE]"
|
||||
|
||||
# Coordinator routes to preprocessor
|
||||
- source: coordinate
|
||||
target: preprocess
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:PREPROCESSOR]"
|
||||
|
||||
# Coordinator routes to analyzer
|
||||
- source: coordinate
|
||||
target: analyze
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:ANALYZER]"
|
||||
|
||||
# Coordinator routes to risk assessor
|
||||
- source: coordinate
|
||||
target: assess_risk
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:RISK]"
|
||||
|
||||
# Coordinator routes to formatter
|
||||
- source: coordinate
|
||||
target: format_json
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:FORMATTER]"
|
||||
|
||||
# Coordinator routes to file manager
|
||||
- source: coordinate
|
||||
target: save_file
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:FILE_MANAGER]"
|
||||
|
||||
# Coordinator ends workflow
|
||||
- source: coordinate
|
||||
target: end
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:END]"
|
||||
|
||||
# After file reading, go back to coordinator
|
||||
- source: read_file
|
||||
target: coordinate
|
||||
|
||||
# After preprocessing, go back to coordinator
|
||||
- source: preprocess
|
||||
target: coordinate
|
||||
|
||||
# After analysis, go back to coordinator
|
||||
- source: analyze
|
||||
target: coordinate
|
||||
|
||||
# After risk assessment, go back to coordinator
|
||||
- source: assess_risk
|
||||
target: coordinate
|
||||
|
||||
# After formatting, go back to coordinator
|
||||
- source: format_json
|
||||
target: coordinate
|
||||
|
||||
# After file manager prepares command, execute the write
|
||||
- source: save_file
|
||||
target: execute_write
|
||||
|
||||
# After writing file, go to end
|
||||
- source: execute_write
|
||||
target: end
|
||||
|
||||
# Stream that executes the graph
|
||||
graph_executor:
|
||||
type: stream
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: graph_execute
|
||||
params:
|
||||
graph: contract_analysis_workflow
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: graph_executor
|
||||
|
||||
context:
|
||||
global:
|
||||
app_name: "Legal Contract Metadata Extractor (LangGraph)"
|
||||
version: "2.0-langgraph-conditional"
|
||||
_unsafe_mode: true
|
||||
log_level: "INFO"
|
||||
@@ -0,0 +1,307 @@
|
||||
# Multi-Agent Paper Writer - Multiple Specialized Agents Working Together
|
||||
# Each agent has a specific role and they collaborate through conversation
|
||||
#
|
||||
# AGENTS:
|
||||
# - Coordinator: Routes user requests to appropriate specialist
|
||||
# - Researcher: Gathers requirements and defines research scope
|
||||
# - Writer: Writes the actual paper based on requirements
|
||||
# - Reviewer: Reviews and provides feedback on the paper
|
||||
# - File Manager: Handles file operations
|
||||
#
|
||||
# USAGE:
|
||||
# cleveragents interactive -c examples/multi_agent_paper_writer.yaml --unsafe
|
||||
#
|
||||
# HOW IT WORKS:
|
||||
# - All agents share conversation history (memory enabled)
|
||||
# - Coordinator decides which specialist to activate
|
||||
# - Each specialist can see previous conversations
|
||||
# - Natural handoffs between agents
|
||||
|
||||
agents:
|
||||
# Coordinator agent - decides which specialist to route to
|
||||
coordinator:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
temperature: 0.3
|
||||
memory_enabled: true
|
||||
max_history: 30
|
||||
system_prompt: |
|
||||
You are the COORDINATOR agent in a multi-agent paper writing system.
|
||||
|
||||
YOUR ROLE: Analyze user input and delegate to the appropriate specialist.
|
||||
|
||||
AVAILABLE SPECIALISTS:
|
||||
- RESEARCHER: For gathering requirements, understanding research questions
|
||||
- WRITER: For actually writing the paper
|
||||
- REVIEWER: For reviewing and improving written content
|
||||
- FILE_MANAGER: For saving papers to files
|
||||
|
||||
RULES:
|
||||
1. If user wants to start, discuss topic, or define requirements → activate RESEARCHER
|
||||
2. If requirements are clear and user wants paper written → activate WRITER
|
||||
3. If paper exists and needs review/improvement → activate REVIEWER
|
||||
4. If user wants to save to file → activate FILE_MANAGER
|
||||
5. For greetings or general questions → respond yourself briefly
|
||||
|
||||
OUTPUT FORMAT:
|
||||
[AGENT:RESEARCHER] - to activate researcher
|
||||
[AGENT:WRITER] - to activate writer
|
||||
[AGENT:REVIEWER] - to activate reviewer
|
||||
[AGENT:FILE_MANAGER] - to activate file manager
|
||||
[AGENT:COORDINATOR] Your response - when you respond directly
|
||||
|
||||
Always prefix your response with the agent tag!
|
||||
|
||||
# Researcher agent - gathers requirements and defines scope
|
||||
researcher:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.7
|
||||
memory_enabled: true
|
||||
max_history: 30
|
||||
system_prompt: |
|
||||
You are the RESEARCHER agent in a multi-agent paper writing system.
|
||||
|
||||
YOUR ROLE: Gather requirements and define the research scope for papers.
|
||||
|
||||
RESPONSIBILITIES:
|
||||
- Ask clarifying questions about the research topic
|
||||
- Understand the research question or thesis
|
||||
- Identify target audience
|
||||
- Define paper scope and key points
|
||||
- Gather any specific requirements
|
||||
|
||||
CONVERSATION CONTEXT:
|
||||
- You can see the entire conversation history
|
||||
- Build on what users have already said
|
||||
- Don't repeat questions if information was already provided
|
||||
|
||||
HANDOFF:
|
||||
- When you have enough information, summarize requirements
|
||||
- Tell user: "Requirements are clear! Ready to write the paper?"
|
||||
- The coordinator will then route to the WRITER agent
|
||||
|
||||
Always start responses with: [RESEARCHER SPEAKING]
|
||||
|
||||
# Writer agent - writes the actual paper
|
||||
writer:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.8
|
||||
max_tokens: 3000
|
||||
memory_enabled: true
|
||||
max_history: 30
|
||||
system_prompt: |
|
||||
You are the WRITER agent in a multi-agent paper writing system.
|
||||
|
||||
YOUR ROLE: Write complete, publication-ready scientific papers.
|
||||
|
||||
CONTEXT:
|
||||
- You can see the entire conversation including requirements gathered by RESEARCHER
|
||||
- Extract all relevant requirements from the conversation history
|
||||
- Use information provided in previous messages
|
||||
|
||||
PAPER STRUCTURE:
|
||||
# [Descriptive Title Based on Topic]
|
||||
|
||||
## Abstract
|
||||
[150-250 words]
|
||||
|
||||
## Introduction
|
||||
[Background and research question]
|
||||
|
||||
## Methodology
|
||||
[Research approach]
|
||||
|
||||
## Results
|
||||
[Key findings]
|
||||
|
||||
## Discussion
|
||||
[Analysis]
|
||||
|
||||
## Conclusion
|
||||
[Summary and future work]
|
||||
|
||||
## References
|
||||
[3-5 relevant references]
|
||||
|
||||
WRITING STYLE:
|
||||
- Academic and professional
|
||||
- Appropriate for target audience mentioned in conversation
|
||||
- Clear and well-structured
|
||||
|
||||
Always start responses with: [WRITER SPEAKING]
|
||||
|
||||
# Reviewer agent - reviews and improves papers
|
||||
reviewer:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.6
|
||||
memory_enabled: true
|
||||
max_history: 30
|
||||
system_prompt: |
|
||||
You are the REVIEWER agent in a multi-agent paper writing system.
|
||||
|
||||
YOUR ROLE: Review papers and provide constructive feedback.
|
||||
|
||||
REVIEW CRITERIA:
|
||||
- Structure and organization
|
||||
- Clarity and coherence
|
||||
- Academic rigor
|
||||
- Appropriate level for target audience
|
||||
- Completeness
|
||||
|
||||
CONTEXT:
|
||||
- You can see the entire conversation
|
||||
- Review the paper that was written by the WRITER
|
||||
- Provide specific, actionable feedback
|
||||
|
||||
OUTPUT:
|
||||
- Highlight strengths
|
||||
- Identify areas for improvement
|
||||
- Suggest specific changes
|
||||
- Can rewrite sections if needed
|
||||
|
||||
Always start responses with: [REVIEWER SPEAKING]
|
||||
|
||||
# File manager agent - handles file operations
|
||||
file_manager:
|
||||
type: tool
|
||||
config:
|
||||
tools: ["file_write"]
|
||||
safe_mode: false
|
||||
|
||||
# Main orchestrator that processes the workflow
|
||||
orchestrator:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.7
|
||||
memory_enabled: true
|
||||
max_history: 50
|
||||
system_prompt: |
|
||||
You are the ORCHESTRATOR of a multi-agent paper writing system.
|
||||
|
||||
YOUR ROLE: Execute the multi-agent workflow seamlessly.
|
||||
|
||||
WORKFLOW:
|
||||
1. Analyze user input and conversation history
|
||||
2. Determine which agent should respond
|
||||
3. Let that agent generate their response
|
||||
4. Present the response to the user naturally
|
||||
|
||||
AGENTS AND THEIR ROLES:
|
||||
- RESEARCHER: Gathers requirements (early stage)
|
||||
- WRITER: Writes papers (when requirements are clear)
|
||||
- REVIEWER: Reviews papers (after writing)
|
||||
- FILE_MANAGER: Saves files (when requested)
|
||||
|
||||
DECISION LOGIC:
|
||||
- First messages or discussing topic → RESEARCHER
|
||||
- User says "write", "ready to write", or requirements complete → WRITER
|
||||
- Paper exists and user asks for review/improvements → REVIEWER
|
||||
- User says "save" or "write to file" → FILE_MANAGER
|
||||
|
||||
AGENT SIMULATION:
|
||||
Respond AS IF you are the appropriate agent. Example:
|
||||
|
||||
If RESEARCHER should respond:
|
||||
"=== RESEARCHER AGENT ===>
|
||||
[Ask clarifying questions about the research topic]"
|
||||
|
||||
If WRITER should respond:
|
||||
"=== WRITER AGENT ===>
|
||||
[Generate the complete paper]"
|
||||
|
||||
CRITICAL - FILE SAVING:
|
||||
When user wants to save a file, you must output a special command format:
|
||||
|
||||
[TOOL_EXECUTE:file_write]
|
||||
{"file": "filename.txt", "content": "the complete paper content here"}
|
||||
[/TOOL_EXECUTE]
|
||||
|
||||
Extract the COMPLETE paper content from conversation history and include it in the "content" field.
|
||||
The filename should be what the user specified or a sensible default like "paper.txt".
|
||||
|
||||
Example:
|
||||
User: "save the paper to quantum_paper.txt"
|
||||
You respond:
|
||||
"=== FILE_MANAGER AGENT ===>
|
||||
Saving your paper to quantum_paper.txt...
|
||||
|
||||
[TOOL_EXECUTE:file_write]
|
||||
{"file": "quantum_paper.txt", "content": "[FULL PAPER CONTENT FROM CONVERSATION]"}
|
||||
[/TOOL_EXECUTE]"
|
||||
|
||||
IMPORTANT:
|
||||
- Use full conversation history to maintain context
|
||||
- Agents can see what other agents said before
|
||||
- Natural handoffs between agents
|
||||
- Always indicate which agent is speaking
|
||||
- For file operations, MUST include the [TOOL_EXECUTE] command
|
||||
|
||||
routes:
|
||||
# Stage 1: Coordinator routes to appropriate specialist
|
||||
coordinator_stream:
|
||||
type: stream
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: coordinator
|
||||
|
||||
# Stage 2: Researcher gathers requirements
|
||||
researcher_stream:
|
||||
type: stream
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: researcher
|
||||
|
||||
# Stage 3: Writer creates the paper
|
||||
writer_stream:
|
||||
type: stream
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: writer
|
||||
|
||||
# Stage 4: Reviewer provides feedback
|
||||
reviewer_stream:
|
||||
type: stream
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: reviewer
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: coordinator_stream
|
||||
- sources: [coordinator_stream]
|
||||
target: researcher_stream
|
||||
- sources: [researcher_stream]
|
||||
target: writer_stream
|
||||
- sources: [writer_stream]
|
||||
target: reviewer_stream
|
||||
|
||||
context:
|
||||
global:
|
||||
app_name: "Multi-Agent Paper Writer"
|
||||
version: "1.0-multi-agent"
|
||||
_unsafe_mode: true
|
||||
log_level: "INFO"
|
||||
|
||||
@@ -0,0 +1,450 @@
|
||||
# Multi-Agent Paper Writer - LangGraph with Conditional Routing & Section-by-Section Support
|
||||
# True multi-agent system with dynamic workflow, file operations, and incremental writing
|
||||
#
|
||||
# AGENTS:
|
||||
# - Coordinator: Analyzes input and decides routing
|
||||
# - Researcher: Gathers requirements and defines research scope
|
||||
# - Writer: Writes papers (whole or section-by-section)
|
||||
# - Reviewer: Reviews and provides feedback on the paper
|
||||
# - File Manager: Intelligently handles file operations (read, write, append, insert)
|
||||
#
|
||||
# USAGE:
|
||||
# cleveragents interactive -c examples/multi_agent_paper_writer_langgraph.yaml --unsafe
|
||||
#
|
||||
# EXAMPLES:
|
||||
# # Whole paper at once:
|
||||
# /graph paper_writing_workflow write a paper about quantum computing
|
||||
# /graph paper_writing_workflow save it to quantum.txt
|
||||
#
|
||||
# # Section by section:
|
||||
# /graph paper_writing_workflow write the abstract for a paper on AI ethics
|
||||
# /graph paper_writing_workflow save the abstract to ai_ethics.txt
|
||||
# /graph paper_writing_workflow now write the introduction section
|
||||
# /graph paper_writing_workflow append it to ai_ethics.txt
|
||||
# /graph paper_writing_workflow write the methodology section
|
||||
# /graph paper_writing_workflow append it to ai_ethics.txt
|
||||
#
|
||||
# HOW IT WORKS:
|
||||
# - LangGraph manages stateful workflow with conditional routing
|
||||
# - Coordinator decides which specialist to invoke based on conversation state
|
||||
# - Each agent actually executes (not simulation)
|
||||
# - Dynamic routing based on user input and workflow stage
|
||||
# - Supports loops (e.g., review → revise → review again)
|
||||
# - Supports incremental writing with file read/write/append/insert
|
||||
|
||||
agents:
|
||||
# Coordinator agent - analyzes input and decides routing
|
||||
coordinator:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
temperature: 0.3
|
||||
memory_enabled: true
|
||||
max_history: 30
|
||||
system_prompt: |
|
||||
You are the COORDINATOR agent in a multi-agent paper writing system.
|
||||
|
||||
YOUR ROLE: Analyze user input and decide which specialist should handle it.
|
||||
|
||||
AVAILABLE SPECIALISTS:
|
||||
- RESEARCHER: For gathering requirements, understanding research questions
|
||||
- WRITER: For writing papers (whole or sections)
|
||||
- REVIEWER: For reviewing and improving written content
|
||||
- FILE_MANAGER: For file operations (save, read, append)
|
||||
|
||||
ROUTING DECISIONS:
|
||||
Based on conversation context, output ONE of these routing tags:
|
||||
|
||||
[ROUTE:RESEARCHER] - If user wants to start, discuss topic, or define requirements
|
||||
[ROUTE:WRITER] - If requirements are clear and user wants content written
|
||||
[ROUTE:REVIEWER] - If paper/content exists and needs review/improvement
|
||||
[ROUTE:FILE_MANAGER] - If user wants to save/read/append content to a file
|
||||
[ROUTE:END] - If workflow is complete or user says goodbye
|
||||
|
||||
FILE OPERATION INSTRUCTIONS:
|
||||
When user wants file operations, extract key information and route to FILE_MANAGER:
|
||||
|
||||
For SAVE/WRITE (new file or overwrite):
|
||||
"User wants to save content to 'paper.txt' (new/overwrite).
|
||||
[ROUTE:FILE_MANAGER]
|
||||
OPERATION: write
|
||||
FILENAME: paper.txt"
|
||||
|
||||
For APPEND (add to end of file):
|
||||
"User wants to append new section to 'paper.txt'.
|
||||
[ROUTE:FILE_MANAGER]
|
||||
OPERATION: append
|
||||
FILENAME: paper.txt"
|
||||
|
||||
For READ (read existing file):
|
||||
"User wants to read 'paper.txt'.
|
||||
[ROUTE:FILE_MANAGER]
|
||||
OPERATION: read
|
||||
FILENAME: paper.txt"
|
||||
|
||||
SECTION-BY-SECTION WORKFLOW:
|
||||
- User can write one section at a time (abstract, introduction, methodology, etc.)
|
||||
- Each section can be saved individually or appended to existing file
|
||||
- Support iterative refinement of individual sections
|
||||
|
||||
OUTPUT FORMAT:
|
||||
Provide brief context, then routing decision:
|
||||
|
||||
"User wants to write the abstract for a quantum computing paper. Routing to writer for content creation.
|
||||
[ROUTE:WRITER]"
|
||||
|
||||
Always include the [ROUTE:X] tag in your response!
|
||||
|
||||
# Researcher agent - gathers requirements
|
||||
researcher:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.7
|
||||
memory_enabled: true
|
||||
max_history: 30
|
||||
system_prompt: |
|
||||
You are the RESEARCHER agent in a multi-agent paper writing system.
|
||||
|
||||
YOUR ROLE: Gather requirements and define the research scope for papers.
|
||||
|
||||
RESPONSIBILITIES:
|
||||
- Ask clarifying questions about the research topic
|
||||
- Understand the research question or thesis
|
||||
- Identify target audience
|
||||
- Define paper scope and key points
|
||||
- Gather any specific requirements
|
||||
- Support both full papers and individual sections
|
||||
|
||||
CONVERSATION CONTEXT:
|
||||
- You can see the entire conversation history
|
||||
- Build on what users have already said
|
||||
- Don't repeat questions if information was already provided
|
||||
|
||||
COMPLETION SIGNAL:
|
||||
When you have enough information, end your response with:
|
||||
"[REQUIREMENTS_COMPLETE]"
|
||||
|
||||
This signals the coordinator to route to the WRITER.
|
||||
|
||||
OUTPUT FORMAT:
|
||||
"[RESEARCHER SPEAKING]
|
||||
[Your questions and analysis]
|
||||
|
||||
[REQUIREMENTS_COMPLETE] (only when done)"
|
||||
|
||||
# Writer agent - writes papers or sections
|
||||
writer:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.8
|
||||
max_tokens: 3000
|
||||
memory_enabled: true
|
||||
max_history: 30
|
||||
system_prompt: |
|
||||
You are the WRITER agent in a multi-agent paper writing system.
|
||||
|
||||
YOUR ROLE: Write complete papers OR individual sections based on user request.
|
||||
|
||||
ACCESSING REQUIREMENTS:
|
||||
- Review the COMPLETE conversation history
|
||||
- Look for "[RESEARCHER SPEAKING]" for requirements
|
||||
- Look for coordinator instructions about what to write
|
||||
- Check if user wants full paper or specific section
|
||||
|
||||
FULL PAPER STRUCTURE:
|
||||
# [Descriptive Title Based on Topic]
|
||||
|
||||
## Abstract
|
||||
[150-250 words]
|
||||
|
||||
## Introduction
|
||||
[Background and research question]
|
||||
|
||||
## Methodology
|
||||
[Research approach]
|
||||
|
||||
## Results
|
||||
[Key findings]
|
||||
|
||||
## Discussion
|
||||
[Analysis]
|
||||
|
||||
## Conclusion
|
||||
[Summary and future work]
|
||||
|
||||
## References
|
||||
[3-5 relevant references]
|
||||
|
||||
SECTION-BY-SECTION MODE:
|
||||
If user asks for specific section (e.g., "write the abstract", "write introduction"):
|
||||
- Write ONLY that section with appropriate heading
|
||||
- Make it complete and self-contained
|
||||
- Follow academic writing standards
|
||||
- Include section heading (e.g., "## Abstract")
|
||||
|
||||
CONTINUING SECTIONS:
|
||||
If user says "now write the next section" or "write methodology":
|
||||
- Review conversation history to see what's already written
|
||||
- Write the requested section that follows logically
|
||||
- Maintain consistent style and tone
|
||||
|
||||
WRITING STYLE:
|
||||
- Academic and professional
|
||||
- Appropriate for target audience
|
||||
- Clear and well-structured
|
||||
|
||||
COMPLETION SIGNAL:
|
||||
End your response with: "[PAPER_COMPLETE]" for full paper and ignore the ending if writing a section
|
||||
|
||||
OUTPUT FORMAT:
|
||||
"[WRITER SPEAKING]
|
||||
[Content here]
|
||||
|
||||
[PAPER_COMPLETE] and ignore the ending if writing a section
|
||||
|
||||
# Reviewer agent - reviews and improves papers
|
||||
reviewer:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.6
|
||||
memory_enabled: true
|
||||
max_history: 30
|
||||
system_prompt: |
|
||||
You are the REVIEWER agent in a multi-agent paper writing system.
|
||||
|
||||
YOUR ROLE: Review papers or sections and provide constructive feedback.
|
||||
|
||||
ACCESSING CONTENT TO REVIEW:
|
||||
- Review the COMPLETE conversation history
|
||||
- Look for "[WRITER SPEAKING]" for the content
|
||||
- Look for "[FILE_CONTENT_START]" if content was read from file
|
||||
- Extract the full content (sections or complete paper)
|
||||
- Then provide your detailed review
|
||||
|
||||
IMPORTANT: The content EXISTS in the conversation history!
|
||||
Search for "[WRITER SPEAKING]" or "[FILE_CONTENT_START]" and extract everything after it.
|
||||
Do NOT say you can't find the content - it's in the conversation!
|
||||
|
||||
REVIEW CRITERIA:
|
||||
- Structure and organization
|
||||
- Clarity and coherence
|
||||
- Academic rigor
|
||||
- Appropriate level for target audience
|
||||
- Completeness
|
||||
- Consistency with previous sections (if reviewing incrementally)
|
||||
|
||||
OUTPUT:
|
||||
- Highlight strengths
|
||||
- Identify areas for improvement
|
||||
- Suggest specific changes
|
||||
- Can provide revised sections if needed
|
||||
|
||||
COMPLETION SIGNAL:
|
||||
End your response with: "[REVIEW_COMPLETE]"
|
||||
|
||||
OUTPUT FORMAT:
|
||||
"[REVIEWER SPEAKING]
|
||||
[Your review and feedback]
|
||||
|
||||
[REVIEW_COMPLETE]"
|
||||
|
||||
# File manager agent - intelligently handles file operations
|
||||
file_manager:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.3
|
||||
memory_enabled: true
|
||||
max_history: 30
|
||||
system_prompt: |
|
||||
You are the FILE_MANAGER agent in a multi-agent paper writing system.
|
||||
|
||||
YOUR ROLE: Execute file operations based on coordinator instructions.
|
||||
|
||||
OPERATIONS:
|
||||
1. WRITE - Save new content or overwrite existing file
|
||||
2. APPEND - Add content to end of existing file
|
||||
3. READ - Read existing file content
|
||||
|
||||
INSTRUCTIONS:
|
||||
1. Check coordinator message for "OPERATION:" and "FILENAME:"
|
||||
2. Search conversation history for content to save
|
||||
- Look for "[WRITER SPEAKING]" for newly written content
|
||||
- Look for most recent content in conversation
|
||||
3. Format appropriate JSON command based on operation
|
||||
|
||||
OUTPUT FORMATS:
|
||||
|
||||
For WRITE operation:
|
||||
{"tool": "file_write", "args": {"file": "filename.txt", "content": "CONTENT_HERE", "mode": "w"}}
|
||||
|
||||
For APPEND operation:
|
||||
{"tool": "file_write", "args": {"file": "filename.txt", "content": "CONTENT_HERE", "mode": "a"}}
|
||||
|
||||
For READ operation:
|
||||
{"tool": "file_read", "args": {"file": "filename.txt"}}
|
||||
|
||||
CRITICAL RULES:
|
||||
- Output ONLY the JSON command, no other text before or after
|
||||
- Include the COMPLETE content from writer
|
||||
- Use the exact filename from FILENAME: field
|
||||
- Use correct mode: "w" for write/overwrite, "a" for append
|
||||
- For READ, just read the file (no content needed)
|
||||
|
||||
# Tool executor for file reading
|
||||
file_reader_tool:
|
||||
type: tool
|
||||
config:
|
||||
tools: ["file_read"]
|
||||
safe_mode: true
|
||||
|
||||
# Tool executor for file writing
|
||||
file_writer_tool:
|
||||
type: tool
|
||||
config:
|
||||
tools: ["file_write"]
|
||||
safe_mode: false
|
||||
|
||||
routes:
|
||||
# LangGraph workflow with conditional routing
|
||||
paper_writing_workflow:
|
||||
type: graph
|
||||
entry_point: start
|
||||
checkpointing: true
|
||||
|
||||
nodes:
|
||||
# Coordinator decides routing
|
||||
coordinate:
|
||||
type: agent
|
||||
agent: coordinator
|
||||
|
||||
# Researcher gathers requirements
|
||||
research:
|
||||
type: agent
|
||||
agent: researcher
|
||||
|
||||
# Writer creates content
|
||||
write:
|
||||
type: agent
|
||||
agent: writer
|
||||
|
||||
# Reviewer provides feedback
|
||||
review:
|
||||
type: agent
|
||||
agent: reviewer
|
||||
|
||||
# File manager formats file commands
|
||||
manage_file:
|
||||
type: agent
|
||||
agent: file_manager
|
||||
|
||||
# Tool executors for file operations
|
||||
read_file:
|
||||
type: agent
|
||||
agent: file_reader_tool
|
||||
|
||||
write_file:
|
||||
type: agent
|
||||
agent: file_writer_tool
|
||||
|
||||
edges:
|
||||
# Always start with coordinator
|
||||
- source: start
|
||||
target: coordinate
|
||||
|
||||
# Coordinator routes to researcher
|
||||
- source: coordinate
|
||||
target: research
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:RESEARCHER]"
|
||||
|
||||
# Coordinator routes to writer
|
||||
- source: coordinate
|
||||
target: write
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:WRITER]"
|
||||
|
||||
# Coordinator routes to reviewer
|
||||
- source: coordinate
|
||||
target: review
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:REVIEWER]"
|
||||
|
||||
# Coordinator routes to file manager
|
||||
- source: coordinate
|
||||
target: manage_file
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:FILE_MANAGER]"
|
||||
|
||||
# Coordinator ends workflow
|
||||
- source: coordinate
|
||||
target: end
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "[ROUTE:END]"
|
||||
|
||||
# After research, go back to coordinator for next decision
|
||||
- source: research
|
||||
target: coordinate
|
||||
|
||||
# After writing, go back to coordinator
|
||||
- source: write
|
||||
target: coordinate
|
||||
|
||||
# After review, go back to coordinator
|
||||
- source: review
|
||||
target: coordinate
|
||||
|
||||
# File manager routes to read or write based on JSON command
|
||||
- source: manage_file
|
||||
target: read_file
|
||||
condition:
|
||||
type: content_contains
|
||||
text: '"tool": "file_read"'
|
||||
|
||||
- source: manage_file
|
||||
target: write_file
|
||||
condition:
|
||||
type: content_contains
|
||||
text: '"tool": "file_write"'
|
||||
|
||||
# After file operations, go back to coordinator
|
||||
- source: read_file
|
||||
target: coordinate
|
||||
|
||||
- source: write_file
|
||||
target: coordinate
|
||||
|
||||
# Stream that executes the graph
|
||||
graph_executor:
|
||||
type: stream
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: graph_execute
|
||||
params:
|
||||
graph: paper_writing_workflow
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: graph_executor
|
||||
|
||||
context:
|
||||
global:
|
||||
app_name: "Multi-Agent Paper Writer (LangGraph)"
|
||||
version: "3.0-section-by-section"
|
||||
_unsafe_mode: true
|
||||
log_level: "INFO"
|
||||
@@ -0,0 +1,107 @@
|
||||
# Route Bridging Demo
|
||||
# Demonstrates dynamic conversion between stream and graph routes
|
||||
|
||||
agents:
|
||||
simple_processor:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
temperature: 0.7
|
||||
system_prompt: |
|
||||
Process user input. If the user asks about state or context,
|
||||
respond with "NEEDS_STATE: true" at the beginning of your response.
|
||||
|
||||
stateful_processor:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.7
|
||||
system_prompt: |
|
||||
You are a stateful assistant that remembers conversation context.
|
||||
Use the provided state information to give contextual responses.
|
||||
|
||||
# NEW: Unified routes section with bridging
|
||||
routes:
|
||||
# Start with a simple stream that can upgrade to a graph
|
||||
adaptive_processor:
|
||||
type: stream # REQUIRED: Must specify type
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: simple_processor
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Bridge configuration for dynamic type conversion
|
||||
bridge:
|
||||
# Conditions to upgrade from stream to graph
|
||||
upgrade_conditions:
|
||||
needs_state: true # Upgrade when state is needed
|
||||
message_count: 5 # Or after 5 messages
|
||||
custom_predicate: |
|
||||
lambda msg, cfg: "NEEDS_STATE: true" in msg.content
|
||||
|
||||
# Conditions to downgrade from graph to stream
|
||||
downgrade_conditions:
|
||||
idle_time: 300 # Downgrade after 5 minutes of inactivity
|
||||
state_size: 2 # Or when state is minimal
|
||||
no_conditionals_used: true # Or if no conditional logic was used
|
||||
|
||||
# State management during conversion
|
||||
state_extractor: |
|
||||
lambda msg: {"last_message": msg.content, "timestamp": msg.timestamp}
|
||||
state_flattener: |
|
||||
lambda state: state.data.get("conversation_summary", "")
|
||||
|
||||
preserve_subscriptions: true
|
||||
preserve_checkpointing: true
|
||||
|
||||
# Alternative: Define as graph that can downgrade to stream
|
||||
stateful_processor_route:
|
||||
type: graph # REQUIRED: Must specify type
|
||||
entry_point: start
|
||||
checkpointing: true
|
||||
checkpoint_dir: "./checkpoints/stateful"
|
||||
|
||||
nodes:
|
||||
process_with_state:
|
||||
type: agent
|
||||
agent: stateful_processor
|
||||
|
||||
edges:
|
||||
- source: start
|
||||
target: process_with_state
|
||||
- source: process_with_state
|
||||
target: end
|
||||
|
||||
# Bridge for potential downgrade
|
||||
bridge:
|
||||
downgrade_conditions:
|
||||
idle_time: 600 # Downgrade after 10 minutes
|
||||
state_size: 1 # When state is almost empty
|
||||
|
||||
# Pure bridge route - defines only conversion logic
|
||||
conversion_bridge:
|
||||
type: bridge # Special type for conversion-only routes
|
||||
bridge:
|
||||
upgrade_conditions:
|
||||
complexity_threshold: 10
|
||||
downgrade_conditions:
|
||||
idle_time: 300
|
||||
|
||||
# Route input based on initial analysis
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: adaptive_processor
|
||||
|
||||
context:
|
||||
global:
|
||||
app_name: "Adaptive Route Bridging Demo"
|
||||
description: |
|
||||
This example demonstrates how routes can dynamically convert between
|
||||
stream and graph types based on runtime conditions. The system starts
|
||||
with a simple stream and upgrades to a stateful graph when needed,
|
||||
or downgrades back to a stream when the complexity is no longer required.
|
||||
@@ -0,0 +1,635 @@
|
||||
# Scientific Paper Writer Configuration
|
||||
# Multi-stage system for writing publication-ready scientific papers
|
||||
# Features: Multi-stage creation process and special command system
|
||||
|
||||
agents:
|
||||
# Command processor for system navigation
|
||||
command_processor:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.1
|
||||
system_prompt: |
|
||||
You are the command processor for a scientific paper writing system.
|
||||
Process commands that start with "!" and route them appropriately.
|
||||
|
||||
Available commands:
|
||||
- !next [stage_name] - Proceed to next stage or specified stage
|
||||
- !accept - Accept current content and move to next section/stage
|
||||
- !help - List all available commands
|
||||
- !stage - Show current stage and its purpose
|
||||
- !stages - List all stages and mark current stage
|
||||
- !context [N|all] - Show context (N hops back or all history)
|
||||
|
||||
When processing commands, provide clear feedback about the action taken.
|
||||
For non-command input, indicate it will be passed to the current stage.
|
||||
|
||||
# Introduction stage agent
|
||||
intro_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.7
|
||||
system_prompt: |
|
||||
Welcome to the Scientific Paper Writer!
|
||||
|
||||
This system will guide you through writing a publication-ready scientific paper
|
||||
using a multi-stage process. The stages are:
|
||||
|
||||
1. Introduction (current) - System overview and welcome
|
||||
2. Discovery - Define paper parameters and requirements
|
||||
3. Brainstorming - Refine high-level ideas and components
|
||||
4. Vetting - Research and compile high-quality sources
|
||||
5. Structure - Define complete table of contents
|
||||
6. Deep Research - Detailed research for each section
|
||||
7. Core Content - Write main body sections
|
||||
8. Framing Content - Write conclusion and abstract
|
||||
9. Proofreading - Review for errors and consistency
|
||||
10. Formatting - Convert to desired format and compile
|
||||
|
||||
You can use commands like !next to proceed, !help for assistance,
|
||||
or !stages to see your progress.
|
||||
|
||||
# Discovery stage agent
|
||||
discovery_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.8
|
||||
system_prompt: |
|
||||
You are in the DISCOVERY stage of scientific paper writing.
|
||||
|
||||
Your goal is to help define the parameters for the paper:
|
||||
- Subject or general category
|
||||
- Interesting insights or key points to cover
|
||||
- Overall length of the paper
|
||||
- Target audience
|
||||
- Target publications for submission
|
||||
- Paper format (IMPORTANT: currently only LaTeX is supported)
|
||||
|
||||
When asked about format, only accept "latex" as an answer.
|
||||
If user provides any other format, respond: "That format isn't supported yet.
|
||||
The only supported formats are: latex"
|
||||
|
||||
Engage in conversation to gather all requirements that will constrain
|
||||
the writing process. Save the final requirements to context when complete.
|
||||
|
||||
# Brainstorming stage agent
|
||||
brainstorming_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.9
|
||||
system_prompt: |
|
||||
You are in the BRAINSTORMING stage of scientific paper writing.
|
||||
|
||||
Work with the user to refine the high-level idea for the paper and
|
||||
identify fundamental components. Use the requirements from the Discovery
|
||||
stage to guide the conversation.
|
||||
|
||||
Focus on:
|
||||
- Refining the core thesis or research question
|
||||
- Identifying key arguments and supporting points
|
||||
- Determining the logical flow of ideas
|
||||
- Establishing the scope and boundaries
|
||||
- Identifying potential challenges or counterarguments
|
||||
|
||||
Save the refined concept and components to context when complete.
|
||||
|
||||
# Vetting stage agent
|
||||
vetting_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.3
|
||||
system_prompt: |
|
||||
You are in the VETTING stage of scientific paper writing.
|
||||
|
||||
Your task is to research the topic at a high level and compile
|
||||
high-quality sources. For each source, provide:
|
||||
- One page summary of the source
|
||||
- Direct link/URL to retrieve the full text
|
||||
- Complete bibliographic metadata (author, title, journal, etc.)
|
||||
- Proper citation format
|
||||
|
||||
Use web search capabilities to find authoritative, peer-reviewed sources.
|
||||
Focus on recent publications and seminal works in the field.
|
||||
Organize sources by relevance and quality.
|
||||
|
||||
# Structure stage agent
|
||||
structure_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.6
|
||||
system_prompt: |
|
||||
You are in the STRUCTURE stage of scientific paper writing.
|
||||
|
||||
Create a complete table of contents with:
|
||||
- All main sections and subsections
|
||||
- 2-3 sentences describing the focus and purpose of each section
|
||||
- Logical flow from introduction through conclusion
|
||||
- Standard academic paper structure adapted to your topic
|
||||
|
||||
Consider typical sections like:
|
||||
- Abstract, Introduction, Literature Review
|
||||
- Methodology, Results, Discussion
|
||||
- Conclusion, References, Appendices (if needed)
|
||||
|
||||
The descriptions will be replaced with full content in later stages.
|
||||
|
||||
# Deep research stage agent
|
||||
deep_research_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.4
|
||||
system_prompt: |
|
||||
You are in the DEEP RESEARCH stage of scientific paper writing.
|
||||
|
||||
Research each section of the document in detail. For each section,
|
||||
compile sources with:
|
||||
- Complete bibliographic metadata and citations
|
||||
- Summary of the source material
|
||||
- Specific relevance to the section being written
|
||||
- Key quotes or data points that support your arguments
|
||||
|
||||
Use web search to find detailed, section-specific sources beyond
|
||||
those found in the Vetting stage. Focus on depth and specificity.
|
||||
|
||||
# Core content writing agent
|
||||
core_content_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.7
|
||||
system_prompt: |
|
||||
You are in the CORE CONTENT stage of scientific paper writing.
|
||||
|
||||
Write each section of the paper from top to bottom, one section at a time.
|
||||
You have access to:
|
||||
- Complete table of contents
|
||||
- Paper summary from brainstorming
|
||||
- All research sources and summaries
|
||||
|
||||
Instructions:
|
||||
- Write ONLY the section you're asked to write
|
||||
- Do NOT write subsections under the current section yet
|
||||
- Do NOT attempt to write other sections
|
||||
- Use the research sources to support your arguments
|
||||
- Follow academic writing standards
|
||||
- Maintain consistent style and voice throughout
|
||||
|
||||
Each subsection will be addressed separately to keep focus narrow.
|
||||
|
||||
# Framing content agent (conclusion and abstract)
|
||||
framing_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.6
|
||||
system_prompt: |
|
||||
You are in the FRAMING CONTENT stage of scientific paper writing.
|
||||
|
||||
Write the Conclusion and Abstract sections based on the completed
|
||||
main body of the paper. These sections should:
|
||||
|
||||
Conclusion:
|
||||
- Summarize key findings and arguments
|
||||
- Address implications and significance
|
||||
- Suggest future research directions
|
||||
|
||||
Abstract:
|
||||
- Concisely summarize the entire paper
|
||||
- Include purpose, methods, key findings, conclusions
|
||||
- Stay within word limits (typically 150-300 words)
|
||||
|
||||
Both sections must accurately reflect the content in the main body.
|
||||
|
||||
# Proofreading agent
|
||||
proofreading_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.2
|
||||
system_prompt: |
|
||||
You are in the PROOFREADING stage of scientific paper writing.
|
||||
|
||||
Review the complete paper for:
|
||||
- Spelling and grammar errors
|
||||
- Logical inconsistencies
|
||||
- Factual errors or unsupported claims
|
||||
- Unclear or awkward phrasing
|
||||
- Active voice usage (prefer active over passive)
|
||||
- Consistent terminology and style
|
||||
- Proper citation format
|
||||
- Flow and transitions between sections
|
||||
|
||||
Make corrections while preserving the author's voice and intent.
|
||||
Provide explanations for significant changes.
|
||||
|
||||
# Formatting agent using Gemini for large context
|
||||
formatting_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: google
|
||||
model: gemini-pro-2.5
|
||||
temperature: 0.1
|
||||
system_prompt: |
|
||||
You are in the FORMATTING stage of scientific paper writing.
|
||||
|
||||
Convert the complete paper text into the specified format (currently LaTeX).
|
||||
Your tasks:
|
||||
- Convert all content to proper LaTeX format
|
||||
- Include necessary packages and document structure
|
||||
- Format citations, figures, tables properly
|
||||
- Ensure compilation without errors
|
||||
|
||||
After formatting, attempt to compile the LaTeX.
|
||||
If compilation fails, analyze errors and fix them iteratively.
|
||||
Only present the final result when it compiles successfully
|
||||
and contains all original content.
|
||||
|
||||
# Content validator for formatting stage
|
||||
content_validator:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.1
|
||||
system_prompt: |
|
||||
Validate that the formatted output contains all content from
|
||||
the original text. Compare the original text with the formatted
|
||||
version and identify any missing content.
|
||||
|
||||
If content is missing, provide specific details about what
|
||||
is missing and where it should be located.
|
||||
|
||||
# Routes for the multi-stage system
|
||||
routes:
|
||||
# Main workflow coordinator
|
||||
paper_writer:
|
||||
type: graph
|
||||
entry_point: start
|
||||
|
||||
nodes:
|
||||
# Command processing decision point - not a real node, just routing
|
||||
# Removed conditional node - using edge conditions instead
|
||||
|
||||
# Command processor
|
||||
process_command:
|
||||
type: agent
|
||||
agent: command_processor
|
||||
|
||||
# Stage nodes
|
||||
intro_stage:
|
||||
type: agent
|
||||
agent: intro_agent
|
||||
|
||||
discovery_stage:
|
||||
type: agent
|
||||
agent: discovery_agent
|
||||
|
||||
brainstorming_stage:
|
||||
type: agent
|
||||
agent: brainstorming_agent
|
||||
|
||||
vetting_stage:
|
||||
type: agent
|
||||
agent: vetting_agent
|
||||
|
||||
structure_stage:
|
||||
type: agent
|
||||
agent: structure_agent
|
||||
|
||||
deep_research_stage:
|
||||
type: agent
|
||||
agent: deep_research_agent
|
||||
|
||||
core_content_stage:
|
||||
type: agent
|
||||
agent: core_content_agent
|
||||
|
||||
framing_stage:
|
||||
type: agent
|
||||
agent: framing_agent
|
||||
|
||||
proofreading_stage:
|
||||
type: agent
|
||||
agent: proofreading_agent
|
||||
|
||||
formatting_stage:
|
||||
type: agent
|
||||
agent: formatting_agent
|
||||
|
||||
# Content validation
|
||||
validate_content:
|
||||
type: agent
|
||||
agent: content_validator
|
||||
|
||||
edges:
|
||||
# Initial routing - directly from start
|
||||
# Command processing path
|
||||
- source: start
|
||||
target: process_command
|
||||
condition:
|
||||
type: content_starts_with
|
||||
text: "!"
|
||||
|
||||
# Normal flow path (start with intro)
|
||||
- source: start
|
||||
target: intro_stage
|
||||
condition:
|
||||
type: content_not_contains
|
||||
text: "!"
|
||||
|
||||
# Command processor can route to any stage based on commands
|
||||
- source: process_command
|
||||
target: intro_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "intro"
|
||||
|
||||
- source: process_command
|
||||
target: discovery_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "discovery"
|
||||
|
||||
- source: process_command
|
||||
target: brainstorming_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "brainstorming"
|
||||
|
||||
- source: process_command
|
||||
target: vetting_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "vetting"
|
||||
|
||||
- source: process_command
|
||||
target: structure_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "structure"
|
||||
|
||||
- source: process_command
|
||||
target: deep_research_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "deep_research"
|
||||
|
||||
- source: process_command
|
||||
target: core_content_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "core_content"
|
||||
|
||||
- source: process_command
|
||||
target: framing_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "framing"
|
||||
|
||||
- source: process_command
|
||||
target: proofreading_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "proofreading"
|
||||
|
||||
- source: process_command
|
||||
target: formatting_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "formatting"
|
||||
|
||||
# Sequential stage flow (normal progression)
|
||||
- source: intro_stage
|
||||
target: discovery_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "!next"
|
||||
|
||||
# Default path from intro_stage to end if no !next command
|
||||
- source: intro_stage
|
||||
target: end
|
||||
condition:
|
||||
type: content_not_contains
|
||||
text: "!next"
|
||||
|
||||
- source: discovery_stage
|
||||
target: brainstorming_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "!next"
|
||||
|
||||
# Default path from discovery_stage to end
|
||||
- source: discovery_stage
|
||||
target: end
|
||||
condition:
|
||||
type: content_not_contains
|
||||
text: "!next"
|
||||
|
||||
- source: brainstorming_stage
|
||||
target: vetting_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "!next"
|
||||
|
||||
# Default path from brainstorming_stage to end
|
||||
- source: brainstorming_stage
|
||||
target: end
|
||||
condition:
|
||||
type: content_not_contains
|
||||
text: "!next"
|
||||
|
||||
- source: vetting_stage
|
||||
target: structure_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "!next"
|
||||
|
||||
# Default path from vetting_stage to end
|
||||
- source: vetting_stage
|
||||
target: end
|
||||
condition:
|
||||
type: content_not_contains
|
||||
text: "!next"
|
||||
|
||||
- source: structure_stage
|
||||
target: deep_research_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "!next"
|
||||
|
||||
# Default path from structure_stage to end
|
||||
- source: structure_stage
|
||||
target: end
|
||||
condition:
|
||||
type: content_not_contains
|
||||
text: "!next"
|
||||
|
||||
- source: deep_research_stage
|
||||
target: core_content_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "!next"
|
||||
|
||||
# Default path from deep_research_stage to end
|
||||
- source: deep_research_stage
|
||||
target: end
|
||||
condition:
|
||||
type: content_not_contains
|
||||
text: "!next"
|
||||
|
||||
- source: core_content_stage
|
||||
target: framing_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "!next"
|
||||
|
||||
# Default path from core_content_stage to end
|
||||
- source: core_content_stage
|
||||
target: end
|
||||
condition:
|
||||
type: content_not_contains
|
||||
text: "!next"
|
||||
|
||||
- source: framing_stage
|
||||
target: proofreading_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "!next"
|
||||
|
||||
# Default path from framing_stage to end
|
||||
- source: framing_stage
|
||||
target: end
|
||||
condition:
|
||||
type: content_not_contains
|
||||
text: "!next"
|
||||
|
||||
- source: proofreading_stage
|
||||
target: formatting_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "!next"
|
||||
|
||||
# Default path from proofreading_stage to end
|
||||
- source: proofreading_stage
|
||||
target: end
|
||||
condition:
|
||||
type: content_not_contains
|
||||
text: "!next"
|
||||
|
||||
# Formatting validation loop
|
||||
- source: formatting_stage
|
||||
target: validate_content
|
||||
|
||||
- source: validate_content
|
||||
target: formatting_stage
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "missing content"
|
||||
|
||||
- source: validate_content
|
||||
target: end
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "validation complete"
|
||||
|
||||
# Input stream handler
|
||||
input_handler:
|
||||
type: stream
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: graph_execute
|
||||
params:
|
||||
graph: paper_writer
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Connect input to the workflow
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: input_handler
|
||||
|
||||
# Context management for the paper writing process
|
||||
context:
|
||||
global:
|
||||
app_name: "Scientific Paper Writer"
|
||||
current_stage: "intro"
|
||||
|
||||
# Stage definitions for reference
|
||||
stages:
|
||||
- name: "intro"
|
||||
description: "Welcome and system introduction"
|
||||
purpose: "Introduce the multi-stage paper writing system"
|
||||
|
||||
- name: "discovery"
|
||||
description: "Requirements gathering"
|
||||
purpose: "Define paper parameters, subject, audience, format, etc."
|
||||
|
||||
- name: "brainstorming"
|
||||
description: "Idea refinement"
|
||||
purpose: "Refine high-level ideas and fundamental components"
|
||||
|
||||
- name: "vetting"
|
||||
description: "High-level research"
|
||||
purpose: "Research topic and compile high-quality sources with summaries"
|
||||
|
||||
- name: "structure"
|
||||
description: "Table of contents creation"
|
||||
purpose: "Define complete paper structure with section descriptions"
|
||||
|
||||
- name: "deep_research"
|
||||
description: "Detailed section research"
|
||||
purpose: "Research each section in detail with specific sources"
|
||||
|
||||
- name: "core_content"
|
||||
description: "Main content writing"
|
||||
purpose: "Write paper sections one at a time from top to bottom"
|
||||
|
||||
- name: "framing"
|
||||
description: "Conclusion and abstract writing"
|
||||
purpose: "Write conclusion and abstract based on completed content"
|
||||
|
||||
- name: "proofreading"
|
||||
description: "Review and correction"
|
||||
purpose: "Proofread for errors, consistency, and style"
|
||||
|
||||
- name: "formatting"
|
||||
description: "Format conversion and compilation"
|
||||
purpose: "Convert to LaTeX, compile, and validate completeness"
|
||||
|
||||
# Paper state tracking
|
||||
paper_state:
|
||||
requirements: null
|
||||
concept: null
|
||||
sources: []
|
||||
structure: null
|
||||
sections: {}
|
||||
current_section: null
|
||||
format: null
|
||||
final_text: null
|
||||
latex_source: null
|
||||
pdf_path: null
|
||||
|
||||
# Command help text
|
||||
commands:
|
||||
"!next": "Proceed to next stage or specified stage: !next [stage_name]"
|
||||
"!accept": "Accept current content and move to next section/stage"
|
||||
"!help": "List all available commands and their usage"
|
||||
"!stage": "Show current stage name and detailed purpose"
|
||||
"!stages": "List all stages with current stage marked"
|
||||
"!context": "Show context: !context [N] (N hops back) or !context all"
|
||||
@@ -0,0 +1,110 @@
|
||||
# Scientific Paper Writer Configuration - FIXED VERSION
|
||||
# Multi-stage system for writing publication-ready scientific papers
|
||||
# Features: Simple initial routing and proper LLM responses
|
||||
|
||||
agents:
|
||||
# Welcome agent that introduces the system
|
||||
welcome_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.7
|
||||
system_prompt: |
|
||||
Welcome to the Scientific Paper Writer!
|
||||
|
||||
This system will guide you through writing a publication-ready scientific paper
|
||||
using a multi-stage process. The stages are:
|
||||
|
||||
1. **Discovery** - Define paper parameters and requirements
|
||||
2. **Brainstorming** - Refine high-level ideas and components
|
||||
3. **Research** - Research and compile high-quality sources
|
||||
4. **Structure** - Define complete table of contents
|
||||
5. **Writing** - Write sections of the paper
|
||||
6. **Review** - Review and finalize the paper
|
||||
|
||||
**Available Commands:**
|
||||
- `!start` - Begin the paper writing process
|
||||
- `!help` - Show available commands
|
||||
- `!stages` - List all stages
|
||||
|
||||
To get started, type `!start` or simply tell me about the paper you want to write.
|
||||
I'll help guide you through the process step by step.
|
||||
|
||||
# Discovery stage agent
|
||||
discovery_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.8
|
||||
system_prompt: |
|
||||
You are in the DISCOVERY stage of scientific paper writing.
|
||||
|
||||
Your goal is to help define the parameters for the paper:
|
||||
- Subject or general category
|
||||
- Key research question or thesis
|
||||
- Target audience and publication venue
|
||||
- Paper length and scope
|
||||
- Required format (LaTeX recommended)
|
||||
|
||||
Ask focused questions to gather requirements that will guide
|
||||
the writing process. Once you have sufficient information,
|
||||
summarize the requirements and suggest moving to brainstorming.
|
||||
|
||||
# Routes for the simplified system
|
||||
routes:
|
||||
# Main paper writing workflow
|
||||
paper_writer:
|
||||
type: graph
|
||||
entry_point: start
|
||||
|
||||
nodes:
|
||||
# Welcome and initial routing
|
||||
welcome:
|
||||
type: agent
|
||||
agent: welcome_agent
|
||||
|
||||
# Discovery stage
|
||||
discovery:
|
||||
type: agent
|
||||
agent: discovery_agent
|
||||
|
||||
edges:
|
||||
# Start with welcome
|
||||
- source: start
|
||||
target: welcome
|
||||
|
||||
# Move to discovery when user types !start
|
||||
- source: welcome
|
||||
target: discovery
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "!start"
|
||||
|
||||
# Stay in welcome for other inputs
|
||||
- source: welcome
|
||||
target: end
|
||||
condition:
|
||||
type: always
|
||||
|
||||
# Input stream handler
|
||||
input_handler:
|
||||
type: stream
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: graph_execute
|
||||
params:
|
||||
graph: paper_writer
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Connect input to the workflow
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: input_handler
|
||||
|
||||
context:
|
||||
global:
|
||||
app_name: "Scientific Paper Writer"
|
||||
version: "2.0-fixed"
|
||||
@@ -0,0 +1,50 @@
|
||||
# Simple LangGraph Example
|
||||
# Demonstrates basic LangGraph functionality with linear flow
|
||||
|
||||
agents:
|
||||
assistant:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
temperature: 0.7
|
||||
system_prompt: "You are a helpful assistant."
|
||||
|
||||
# NEW: Unified routes section
|
||||
routes:
|
||||
# Define the graph as a route
|
||||
simple_chat:
|
||||
type: graph # REQUIRED: Must specify type (stream or graph)
|
||||
entry_point: start
|
||||
|
||||
nodes:
|
||||
# Process user input
|
||||
process_input:
|
||||
type: agent
|
||||
agent: assistant
|
||||
|
||||
edges:
|
||||
- source: start
|
||||
target: process_input
|
||||
- source: process_input
|
||||
target: end
|
||||
|
||||
# Input stream that triggers the graph
|
||||
chat_input:
|
||||
type: stream # REQUIRED: Must specify type
|
||||
stream_type: cold
|
||||
operators:
|
||||
- type: graph_execute
|
||||
params:
|
||||
graph: simple_chat
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Route input to the stream
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: chat_input
|
||||
|
||||
context:
|
||||
global:
|
||||
app_name: "Simple LangGraph Chat"
|
||||
@@ -0,0 +1,69 @@
|
||||
SERVICE AGREEMENT
|
||||
|
||||
This Service Agreement ("Agreement") is entered into as of January 15, 2024 (the "Effective Date"), by and between:
|
||||
|
||||
TechCorp Solutions Inc., a Delaware corporation with its principal place of business at 123 Tech Street, San Francisco, CA 94105 ("Provider"),
|
||||
|
||||
and
|
||||
|
||||
Global Enterprises LLC, a California limited liability company with its principal place of business at 456 Business Ave, Los Angeles, CA 90001 ("Client").
|
||||
|
||||
RECITALS
|
||||
|
||||
WHEREAS, Provider is engaged in the business of providing cloud computing and data analytics services;
|
||||
|
||||
WHEREAS, Client desires to engage Provider to provide certain cloud infrastructure and data processing services;
|
||||
|
||||
NOW, THEREFORE, in consideration of the mutual promises and covenants contained herein, the parties agree as follows:
|
||||
|
||||
1. SERVICES
|
||||
|
||||
1.1 Provider shall provide Client with access to its cloud computing platform, including but not limited to:
|
||||
- Virtual server hosting
|
||||
- Data storage and backup services
|
||||
- Network infrastructure management
|
||||
- 24/7 technical support
|
||||
|
||||
1.2 Service Level Agreement: Provider guarantees 99.9% uptime for all hosted services.
|
||||
|
||||
2. PAYMENT TERMS
|
||||
|
||||
2.1 Client shall pay Provider a monthly fee of $5,000.00 USD for the services described herein.
|
||||
|
||||
2.2 Payment shall be due within 30 days of invoice date.
|
||||
|
||||
2.3 Late payments shall incur a penalty of 1.5% per month.
|
||||
|
||||
3. TERM AND TERMINATION
|
||||
|
||||
3.1 This Agreement shall commence on the Effective Date and continue for a period of 24 months.
|
||||
|
||||
3.2 Either party may terminate this Agreement upon 90 days written notice.
|
||||
|
||||
3.3 Provider may terminate immediately for non-payment or material breach.
|
||||
|
||||
4. CONFIDENTIALITY
|
||||
|
||||
4.1 Both parties agree to maintain the confidentiality of proprietary information shared during the term of this Agreement.
|
||||
|
||||
5. GOVERNING LAW
|
||||
|
||||
5.1 This Agreement shall be governed by the laws of the State of California.
|
||||
|
||||
6. LIMITATION OF LIABILITY
|
||||
|
||||
6.1 Provider's total liability shall not exceed the amount paid by Client in the 12 months preceding the claim.
|
||||
|
||||
IN WITNESS WHEREOF, the parties have executed this Agreement as of the date first above written.
|
||||
|
||||
TechCorp Solutions Inc.
|
||||
|
||||
By: ___________________________
|
||||
Name: John Smith
|
||||
Title: Chief Technology Officer
|
||||
|
||||
Global Enterprises LLC
|
||||
|
||||
By: ___________________________
|
||||
Name: Sarah Johnson
|
||||
Title: Chief Executive Officer
|
||||
@@ -1,5 +1,4 @@
|
||||
[bdist_wheel]
|
||||
universal = 1
|
||||
|
||||
[flake8]
|
||||
max-line-length = 140
|
||||
@@ -28,13 +27,15 @@ addopts =
|
||||
--doctest-modules
|
||||
--doctest-glob=\*.rst
|
||||
--tb=short
|
||||
markers =
|
||||
asyncio: mark test as an asyncio test
|
||||
|
||||
[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
|
||||
|
||||
@@ -75,3 +76,6 @@ coverage_flags =
|
||||
|
||||
environment_variables =
|
||||
-
|
||||
|
||||
[pylint.messages control]
|
||||
disable = duplicate-code
|
||||
|
||||
@@ -11,56 +11,68 @@ 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="A Reactive Agent Based LLM tool using RxPy streams",
|
||||
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",
|
||||
"rx>=3.2.0",
|
||||
"jinja2",
|
||||
"pystache",
|
||||
"pyyaml",
|
||||
"langchain-core>=0.3.0",
|
||||
"langchain-openai>=0.2.0",
|
||||
"langchain-anthropic>=0.2.0",
|
||||
"langchain-google-genai>=2.0.0",
|
||||
"aiohttp",
|
||||
],
|
||||
extras_require={
|
||||
# eg:
|
||||
@@ -68,8 +80,8 @@ setup(
|
||||
# ':python_version=="2.6"': ['argparse'],
|
||||
},
|
||||
entry_points={
|
||||
'console_scripts': [
|
||||
'boilerplate = boilerplate.cli:main',
|
||||
"console_scripts": [
|
||||
"cleveragents = cleveragents.cli:main",
|
||||
]
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
__version__ = "0.1.0"
|
||||
@@ -1,23 +0,0 @@
|
||||
"""
|
||||
Module that contains the command line app.
|
||||
|
||||
Why does this file exist, and why not put this in __main__?
|
||||
|
||||
You might be tempted to import things from __main__ later, but that will cause
|
||||
problems: the code will get executed twice:
|
||||
|
||||
- When you run `python -mboilerplate` python will execute
|
||||
``__main__.py`` as a script. That means there won't be any
|
||||
``boilerplate.__main__`` in ``sys.modules``.
|
||||
- When you import __main__ it will get executed again (as a module) because
|
||||
there's no ``boilerplate.__main__`` in ``sys.modules``.
|
||||
|
||||
Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration
|
||||
"""
|
||||
import click
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument('names', nargs=-1)
|
||||
def main(names):
|
||||
click.echo(repr(names))
|
||||
@@ -0,0 +1,8 @@
|
||||
"""
|
||||
CleverAgents - An Agent Based LLM tool in Python.
|
||||
|
||||
This package provides a framework for creating a graph of interactions between
|
||||
various LLM agents using LangGraph by providing templates, prompts, and
|
||||
configuration files describing how the agents are configured and communicate
|
||||
with each other.
|
||||
"""
|
||||
@@ -1,6 +1,8 @@
|
||||
"""
|
||||
Entrypoint module, in case you use `python -mboilerplate`.
|
||||
Entrypoint module, in case you use `python -mcleveragents`.
|
||||
|
||||
This module serves as the entry point when the package is executed directly
|
||||
using `python -mcleveragents`. It delegates to the main CLI function.
|
||||
|
||||
Why does this file exist, and why __main__? For more info, read:
|
||||
|
||||
@@ -8,7 +10,8 @@ Why does this file exist, and why __main__? For more info, read:
|
||||
- https://docs.python.org/2/using/cmdline.html#cmdoption-m
|
||||
- https://docs.python.org/3/using/cmdline.html#cmdoption-m
|
||||
"""
|
||||
from boilerplate.cli import main
|
||||
|
||||
from cleveragents.cli import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,11 @@
|
||||
"""
|
||||
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
|
||||
|
||||
__all__ = ["Agent", "AgentWithMemory"]
|
||||
@@ -0,0 +1 @@
|
||||
"""Agent implementations for CleverAgents."""
|
||||
@@ -0,0 +1,438 @@
|
||||
"""
|
||||
Reactive base agent module for CleverAgents.
|
||||
|
||||
This module defines reactive agents that work with RxPy streams and
|
||||
provide async processing capabilities for the reactive architecture.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import copy
|
||||
import threading
|
||||
import warnings
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import rx
|
||||
from rx import operators as ops
|
||||
from rx.core import Observable # type: ignore[attr-defined]
|
||||
from rx.core import Observer # type: ignore[attr-defined]
|
||||
from rx.subject import Subject # type: ignore[attr-defined]
|
||||
|
||||
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 reactive agents.
|
||||
|
||||
This class defines the interface for agents that work within the RxPy-based
|
||||
reactive architecture, supporting both synchronous and asynchronous processing.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the agent.
|
||||
config (dict[str, Any]): The agent's configuration.
|
||||
template_renderer (TemplateRenderer): Renderer for processing templates.
|
||||
input_stream (Subject): Input stream for receiving messages.
|
||||
output_stream (Subject): Output stream for emitting processed messages.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, config: dict[str, Any], template_renderer: TemplateRenderer
|
||||
):
|
||||
"""
|
||||
Initialize a reactive 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.
|
||||
"""
|
||||
self.name = name
|
||||
self.config = config
|
||||
self.template_renderer = template_renderer
|
||||
|
||||
# Reactive streams
|
||||
self.input_stream = Subject()
|
||||
self.output_stream = Subject()
|
||||
|
||||
# Track pending tasks for cleanup
|
||||
self._pending_tasks: List[asyncio.Task[str]] = []
|
||||
|
||||
# Set up processing pipeline
|
||||
self._setup_processing_pipeline()
|
||||
|
||||
def _setup_processing_pipeline(self) -> None:
|
||||
"""Set up the reactive processing pipeline."""
|
||||
def create_future(message_data: tuple[str, dict[str, Any]]) -> asyncio.Future[str]:
|
||||
"""Create a future and schedule the coroutine on the event loop."""
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError as exc:
|
||||
raise RuntimeError(
|
||||
"No active event loop available."
|
||||
) from exc
|
||||
task = loop.create_task(self._process_wrapper(message_data))
|
||||
self._pending_tasks.append(task)
|
||||
# Remove completed tasks to prevent memory leak
|
||||
self._pending_tasks = [t for t in self._pending_tasks if not t.done()]
|
||||
return task
|
||||
|
||||
self.input_stream.pipe(
|
||||
ops.map(create_future),
|
||||
ops.flat_map(rx.from_future),
|
||||
).subscribe(
|
||||
on_next=self.output_stream.on_next, on_error=self.output_stream.on_error
|
||||
)
|
||||
|
||||
async def _process_wrapper(self, message_data: tuple[str, dict[str, Any]]) -> str:
|
||||
"""Wrapper for async processing."""
|
||||
try:
|
||||
if isinstance(message_data, tuple):
|
||||
message, context = message_data
|
||||
else:
|
||||
message, context = message_data, {}
|
||||
|
||||
result = await self.process_message(message, context)
|
||||
return result
|
||||
except Exception as e:
|
||||
raise ExecutionError(
|
||||
f"Agent {self.name} processing failed: {str(e)}"
|
||||
) from e
|
||||
|
||||
@abstractmethod
|
||||
async def process_message(
|
||||
self, message: str, context: Optional[dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message asynchronously.
|
||||
|
||||
Args:
|
||||
message: The message to process.
|
||||
context: Additional context information.
|
||||
|
||||
Returns:
|
||||
The agent's response to the message.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If message processing fails.
|
||||
"""
|
||||
|
||||
# Legacy method for backward compatibility
|
||||
async def process(
|
||||
self, message: str, context: Optional[dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""Legacy synchronous interface adapter."""
|
||||
return await self.process_message(message, context)
|
||||
|
||||
@abstractmethod
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""
|
||||
Get the capabilities of the agent.
|
||||
|
||||
Returns:
|
||||
A list of capability identifiers.
|
||||
"""
|
||||
|
||||
def get_metadata(self) -> dict[str, Any]:
|
||||
"""
|
||||
Get metadata about the agent.
|
||||
|
||||
Returns:
|
||||
A dictionary of agent metadata.
|
||||
"""
|
||||
metadata: dict[str, Any] = {
|
||||
"name": self.name,
|
||||
"type": self.__class__.__name__,
|
||||
"capabilities": self.get_capabilities(),
|
||||
"reactive": True,
|
||||
}
|
||||
|
||||
# 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
|
||||
|
||||
def send_message(self, message: str, context: Optional[dict[str, Any]] = None) -> None:
|
||||
"""Send a message to the agent's input stream."""
|
||||
self.input_stream.on_next((message, context or {}))
|
||||
|
||||
def subscribe_to_output(self, observer: Observer) -> None:
|
||||
"""Subscribe to the agent's output stream."""
|
||||
self.output_stream.subscribe(observer)
|
||||
|
||||
def create_observable(self) -> Observable:
|
||||
"""Create an observable from the agent's processing."""
|
||||
return self.output_stream.pipe()
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Clean up the agent's resources."""
|
||||
# Cancel pending tasks
|
||||
pending_tasks = getattr(self, '_pending_tasks', [])
|
||||
for task in pending_tasks:
|
||||
if not task.done():
|
||||
task.cancel()
|
||||
|
||||
# Wait for cancellation to complete (suppresses warnings)
|
||||
if pending_tasks:
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Schedule cleanup for later if loop is running
|
||||
loop.call_soon(lambda: None)
|
||||
else:
|
||||
# If loop is stopped, suppress warnings
|
||||
with warnings.catch_warnings():
|
||||
warnings.simplefilter("ignore", RuntimeWarning)
|
||||
except (RuntimeError, AttributeError): # pylint: disable=broad-exception-caught
|
||||
# No event loop available or loop doesn't have is_running()
|
||||
# This is expected in some cleanup scenarios
|
||||
pass
|
||||
self._pending_tasks.clear()
|
||||
|
||||
if hasattr(self.input_stream, "dispose"):
|
||||
self.input_stream.dispose()
|
||||
if hasattr(self.output_stream, "dispose"):
|
||||
self.output_stream.dispose()
|
||||
|
||||
|
||||
class AgentWithMemory(Agent):
|
||||
"""
|
||||
Reactive agent with memory capabilities.
|
||||
|
||||
This class extends Agent to add memory/state management
|
||||
capabilities for maintaining context between message processing calls.
|
||||
|
||||
Attributes:
|
||||
memory (dict[str, Any]): The agent's memory/state.
|
||||
"""
|
||||
|
||||
# Class-level threading lock to protect asyncio.Lock initialization
|
||||
_memory_lock_init_lock = threading.Lock()
|
||||
|
||||
def __init__(
|
||||
self, name: str, config: dict[str, Any], template_renderer: TemplateRenderer
|
||||
):
|
||||
"""
|
||||
Initialize a reactive agent with memory.
|
||||
|
||||
Args:
|
||||
name: The name of the agent.
|
||||
config: The agent's configuration.
|
||||
template_renderer: Renderer for processing templates.
|
||||
"""
|
||||
super().__init__(name, config, template_renderer)
|
||||
self.memory: dict[str, Any] = {}
|
||||
self._memory_lock_instance: Optional[asyncio.Lock] = None
|
||||
|
||||
@property
|
||||
def _memory_lock(self) -> asyncio.Lock:
|
||||
"""
|
||||
Lazily create and return the memory lock, ensuring an event loop exists.
|
||||
"""
|
||||
# Fast path: check without lock first
|
||||
if self._memory_lock_instance is None:
|
||||
# Thread-safe initialization using double-checked locking
|
||||
with self._memory_lock_init_lock:
|
||||
# Double-check: another thread might have initialized it
|
||||
if self._memory_lock_instance is None:
|
||||
# Try to get existing event loop
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
except RuntimeError:
|
||||
loop = None
|
||||
|
||||
# If no loop or loop is closed, create a new one
|
||||
if loop is None or loop.is_closed():
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
|
||||
self._memory_lock_instance = asyncio.Lock()
|
||||
return self._memory_lock_instance
|
||||
|
||||
async def _process_wrapper(self, message_data: tuple[str, dict[str, Any]]) -> str:
|
||||
"""Wrapper that manages memory access."""
|
||||
async with self._memory_lock:
|
||||
return await super()._process_wrapper(message_data)
|
||||
|
||||
def save_memory(self) -> dict[str, Any]:
|
||||
"""
|
||||
Save the agent's memory to a serializable format.
|
||||
|
||||
Returns:
|
||||
A dictionary representation of the agent's memory.
|
||||
"""
|
||||
return copy.deepcopy(self.memory)
|
||||
|
||||
def load_memory(self, memory: dict[str, Any]) -> None:
|
||||
"""
|
||||
Load the agent's memory from a serialized format.
|
||||
|
||||
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)}")
|
||||
self.memory = memory
|
||||
|
||||
async def update_memory(self, key: str, value: Any) -> None:
|
||||
"""
|
||||
Update a memory value asynchronously.
|
||||
|
||||
Args:
|
||||
key: The memory key to update.
|
||||
value: The new value.
|
||||
"""
|
||||
async with self._memory_lock:
|
||||
self.memory[key] = value
|
||||
|
||||
async def get_memory(self, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Get a memory value asynchronously.
|
||||
|
||||
Args:
|
||||
key: The memory key to retrieve.
|
||||
default: Default value if key doesn't exist.
|
||||
|
||||
Returns:
|
||||
The memory value or default.
|
||||
"""
|
||||
async with self._memory_lock:
|
||||
return self.memory.get(key, default)
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Clean up the agent's resources including memory lock."""
|
||||
# Clear the lock instance to allow proper cleanup
|
||||
self._memory_lock_instance = None
|
||||
|
||||
# Call parent dispose
|
||||
super().dispose()
|
||||
|
||||
|
||||
class StreamableAgent(Agent):
|
||||
"""
|
||||
Agent that can be directly integrated into RxPy streams.
|
||||
|
||||
This agent provides additional methods for stream integration,
|
||||
allowing it to be used as an operator in RxPy pipelines.
|
||||
"""
|
||||
|
||||
def as_operator(self) -> Callable[[Observable], Observable]:
|
||||
"""
|
||||
Return an RxPy operator that processes messages through this agent.
|
||||
|
||||
Returns:
|
||||
An RxPy operator function.
|
||||
"""
|
||||
|
||||
def operator(source: Observable) -> Observable:
|
||||
def subscribe(observer: Observer, scheduler: Any = None) -> Any:
|
||||
def on_next(value: Any) -> None:
|
||||
self.send_message(str(value))
|
||||
|
||||
def on_error(error: Exception) -> None:
|
||||
observer.on_error(error)
|
||||
|
||||
def on_completed() -> None:
|
||||
observer.on_completed()
|
||||
|
||||
# Subscribe to agent's output
|
||||
self.subscribe_to_output(observer)
|
||||
|
||||
# Subscribe to source
|
||||
return source.subscribe(
|
||||
on_next=on_next,
|
||||
on_error=on_error,
|
||||
on_completed=on_completed,
|
||||
scheduler=scheduler,
|
||||
)
|
||||
|
||||
return rx.create(subscribe)
|
||||
|
||||
return operator
|
||||
|
||||
def map_operator(self) -> Any:
|
||||
"""
|
||||
Return an RxPy map operator that processes values through this agent.
|
||||
|
||||
Returns:
|
||||
An RxPy map operator.
|
||||
"""
|
||||
|
||||
async def process_value(value: Any) -> Any:
|
||||
result_future: asyncio.Future[Any] = asyncio.Future()
|
||||
|
||||
def on_result(result: Any) -> None:
|
||||
if not result_future.done():
|
||||
result_future.set_result(result)
|
||||
|
||||
observer = Observer(on_next=on_result)
|
||||
self.subscribe_to_output(observer)
|
||||
self.send_message(str(value))
|
||||
|
||||
return await result_future
|
||||
|
||||
def create_future_task(value: Any) -> asyncio.Future[Any]:
|
||||
"""Create a task in the current event loop."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.create_task(process_value(value))
|
||||
|
||||
return ops.flat_map(lambda value: rx.from_future(create_future_task(value)))
|
||||
|
||||
def filter_operator(self, condition_func: Callable[[Any], bool]) -> Any:
|
||||
"""
|
||||
Return an RxPy filter operator based on agent processing.
|
||||
|
||||
Args:
|
||||
condition_func: Function to evaluate agent output for filtering.
|
||||
|
||||
Returns:
|
||||
An RxPy filter operator.
|
||||
"""
|
||||
|
||||
async def filter_with_agent(value: Any) -> tuple[Any, bool]:
|
||||
result_future: asyncio.Future[bool] = asyncio.Future()
|
||||
|
||||
def on_result(result: Any) -> None:
|
||||
if not result_future.done():
|
||||
result_future.set_result(condition_func(result))
|
||||
|
||||
observer = Observer(on_next=on_result)
|
||||
self.subscribe_to_output(observer)
|
||||
self.send_message(str(value))
|
||||
|
||||
should_keep = await result_future
|
||||
return (value, should_keep)
|
||||
|
||||
def create_future_task(value: Any) -> asyncio.Future[Any]:
|
||||
"""Create a task in the current event loop."""
|
||||
loop = asyncio.get_event_loop()
|
||||
return loop.create_task(filter_with_agent(value))
|
||||
|
||||
def extract_value(value_bool_tuple: tuple[Any, bool]) -> Any:
|
||||
"""Extract the value from the tuple."""
|
||||
return value_bool_tuple[0]
|
||||
|
||||
def composed_operator(source: Observable) -> Observable:
|
||||
"""Compose the operator."""
|
||||
return source.pipe(
|
||||
ops.flat_map(lambda value: rx.from_future(create_future_task(value))),
|
||||
ops.filter(lambda value_bool_tuple: value_bool_tuple[1]),
|
||||
ops.map(extract_value),
|
||||
)
|
||||
|
||||
return composed_operator
|
||||
@@ -0,0 +1,96 @@
|
||||
"""
|
||||
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 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 "
|
||||
f"'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_message(
|
||||
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}"
|
||||
|
||||
async def process(
|
||||
self, message: str, context: Optional[dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message (legacy method for compatibility).
|
||||
|
||||
Args:
|
||||
message: The message to process.
|
||||
context: Optional context for processing.
|
||||
|
||||
Returns:
|
||||
A response string after chain processing.
|
||||
"""
|
||||
return await self.process_message(message, context)
|
||||
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""
|
||||
Get the capabilities of the chain agent.
|
||||
|
||||
Returns:
|
||||
A list of capability identifiers.
|
||||
"""
|
||||
return ["chain-processing"]
|
||||
@@ -0,0 +1,365 @@
|
||||
"""
|
||||
Composite agent implementation.
|
||||
|
||||
This module provides a composite agent that can combine multiple agents
|
||||
to work together as a single unit.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from rx.core import Observer # type: ignore[attr-defined]
|
||||
|
||||
from ..core.exceptions import ConfigurationError
|
||||
from ..core.exceptions import ExecutionError
|
||||
from ..langgraph.bridge import RxPyLangGraphBridge
|
||||
from ..reactive.stream_router import ReactiveStreamRouter
|
||||
from ..templates.renderer import TemplateRenderer
|
||||
from .base import Agent
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
pass
|
||||
|
||||
|
||||
class CompositeAgent(Agent): # pylint: disable=too-many-instance-attributes
|
||||
"""
|
||||
CompositeAgent encapsulates complex workflows using agents, graphs, and streams.
|
||||
|
||||
This agent acts as a container that can encapsulate:
|
||||
- Other agents (including nested composite agents)
|
||||
- LangGraphs for stateful workflows
|
||||
- RxPy streams for reactive processing
|
||||
|
||||
The composite agent exposes a simple agent interface while hiding the
|
||||
complexity of its internal components.
|
||||
|
||||
Configuration:
|
||||
components (dict[str, Any]):
|
||||
Defines the internal components:
|
||||
- agents: Agent instances or templates
|
||||
- graphs: LangGraph definitions or templates
|
||||
- streams: RxPy stream definitions or templates
|
||||
|
||||
routing (dict[str, Any]):
|
||||
Defines how data flows through components:
|
||||
- input: Where incoming messages are sent
|
||||
- output: Where final results are collected
|
||||
- connections: How components are connected
|
||||
|
||||
expose_params (dict[str, Any]):
|
||||
Parameters exposed by this composite that can be
|
||||
overridden when instantiated.
|
||||
|
||||
Attributes:
|
||||
name (str): The name of the agent.
|
||||
config (dict[str, Any]): The agent's configuration.
|
||||
template_renderer (TemplateRenderer): Renderer for processing templates.
|
||||
components (dict[str, dict[str, Any]]): Internal components.
|
||||
routing (dict[str, Any]): Routing configuration.
|
||||
expose_params (dict[str, Any]): Exposed parameters.
|
||||
stream_router (Optional[ReactiveStreamRouter]): Stream router for RxPy.
|
||||
langgraph_bridge (Optional[RxPyLangGraphBridge]): Bridge for LangGraphs.
|
||||
"""
|
||||
|
||||
def __init__( # pylint: disable=too-many-arguments,too-many-positional-arguments
|
||||
self,
|
||||
name: str,
|
||||
config: dict[str, Any],
|
||||
template_renderer: TemplateRenderer,
|
||||
stream_router: Optional[ReactiveStreamRouter] = None,
|
||||
langgraph_bridge: Optional[RxPyLangGraphBridge] = None,
|
||||
):
|
||||
"""
|
||||
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.
|
||||
stream_router (Optional[ReactiveStreamRouter]): Stream router for RxPy integration.
|
||||
langgraph_bridge (Optional[RxPyLangGraphBridge]): Bridge for LangGraph integration.
|
||||
"""
|
||||
super().__init__(name, config, template_renderer)
|
||||
|
||||
logger.debug("CompositeAgent '%s' raw config: %s", name, self.config)
|
||||
|
||||
# New architecture components
|
||||
self.stream_router = stream_router
|
||||
self.langgraph_bridge = langgraph_bridge
|
||||
|
||||
# Parse configuration
|
||||
self.components = self.config.get("components", {})
|
||||
self.routing = self.config.get("routing", {})
|
||||
self.expose_params = self.config.get("expose_params", {})
|
||||
|
||||
# Internal component storage
|
||||
self.agents: dict[str, Agent] = {}
|
||||
self.graphs: dict[str, Any] = {}
|
||||
self.streams: dict[str, Any] = {}
|
||||
|
||||
# Reject legacy strategy-based configuration
|
||||
if "strategy" in self.config:
|
||||
raise ConfigurationError(
|
||||
f"CompositeAgent '{name}' uses deprecated strategy-based configuration. "
|
||||
"Please migrate to the new component-based configuration with unified routes."
|
||||
)
|
||||
|
||||
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
|
||||
if "agents" not in self.components:
|
||||
self.components["agents"] = {}
|
||||
self.components["agents"][name] = agent
|
||||
|
||||
def add_graph(self, name: str, graph: Any) -> None:
|
||||
"""
|
||||
Add a LangGraph to this composite agent.
|
||||
|
||||
Args:
|
||||
name (str): The name to assign to the graph.
|
||||
graph: The LangGraph instance.
|
||||
"""
|
||||
self.graphs[name] = graph
|
||||
if "graphs" not in self.components:
|
||||
self.components["graphs"] = {}
|
||||
self.components["graphs"][name] = graph
|
||||
|
||||
def add_stream(self, name: str, stream: Any) -> None:
|
||||
"""
|
||||
Add an RxPy stream to this composite agent.
|
||||
|
||||
Args:
|
||||
name (str): The name to assign to the stream.
|
||||
stream: The stream configuration.
|
||||
"""
|
||||
self.streams[name] = stream
|
||||
if "streams" not in self.components:
|
||||
self.components["streams"] = {}
|
||||
self.components["streams"][name] = stream
|
||||
|
||||
def set_param(self, param: str, value: Any) -> None:
|
||||
"""
|
||||
Set a parameter that propagates to internal components.
|
||||
|
||||
Args:
|
||||
param (str): Parameter name.
|
||||
value (Any): Parameter value.
|
||||
"""
|
||||
self.expose_params[param] = value
|
||||
# Parameter propagation to components is not yet implemented
|
||||
|
||||
async def process_message(
|
||||
self, message: str, context: Optional[dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message through the composite's internal workflow.
|
||||
|
||||
This method satisfies the abstract base class requirement.
|
||||
"""
|
||||
return await self.process(message, context)
|
||||
|
||||
async def process(
|
||||
self, message: str, context: Optional[dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message through the composite's internal workflow.
|
||||
|
||||
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 = {}
|
||||
|
||||
# Merge exposed parameters into context
|
||||
merged_context = {**context, **self.expose_params}
|
||||
|
||||
# New component-based processing
|
||||
input_config = self.routing.get("input", {})
|
||||
|
||||
# Determine input component
|
||||
input_type = input_config.get("type", "agent")
|
||||
input_name = input_config.get("name")
|
||||
|
||||
if not input_name:
|
||||
# If no explicit input routing, try to process with first available component
|
||||
if self.agents:
|
||||
return await self._process_via_agent(
|
||||
list(self.agents.keys())[0], message, merged_context
|
||||
)
|
||||
if self.graphs:
|
||||
return await self._process_via_graph(
|
||||
list(self.graphs.keys())[0], message, merged_context
|
||||
)
|
||||
if self.streams:
|
||||
return await self._process_via_stream(
|
||||
list(self.streams.keys())[0], message, merged_context
|
||||
)
|
||||
raise ConfigurationError(
|
||||
f"CompositeAgent '{self.name}' has no components to process with"
|
||||
)
|
||||
|
||||
# Route to appropriate component type
|
||||
if input_type == "agent":
|
||||
return await self._process_via_agent(input_name, message, merged_context)
|
||||
if input_type == "graph":
|
||||
return await self._process_via_graph(input_name, message, merged_context)
|
||||
if input_type == "stream":
|
||||
return await self._process_via_stream(input_name, message, merged_context)
|
||||
raise ConfigurationError(
|
||||
f"Unknown input type '{input_type}' in CompositeAgent '{self.name}'"
|
||||
)
|
||||
|
||||
async def _process_via_agent(
|
||||
self, agent_name: str, message: str, context: dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Process message through a specific agent.
|
||||
|
||||
Args:
|
||||
agent_name (str): Name of the agent to use.
|
||||
message (str): The message to process.
|
||||
context (dict[str, Any]): Processing context.
|
||||
|
||||
Returns:
|
||||
str: Processed response.
|
||||
"""
|
||||
if agent_name not in self.agents:
|
||||
raise ExecutionError(
|
||||
f"Agent '{agent_name}' not found in CompositeAgent '{self.name}'"
|
||||
)
|
||||
|
||||
agent = self.agents[agent_name]
|
||||
return await agent.process(message, context)
|
||||
|
||||
async def _process_via_graph(
|
||||
self, graph_name: str, message: str, context: dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Process message through a LangGraph.
|
||||
|
||||
Args:
|
||||
graph_name (str): Name of the graph to use.
|
||||
message (str): The message to process.
|
||||
context (dict[str, Any]): Processing context.
|
||||
|
||||
Returns:
|
||||
str: Processed response.
|
||||
"""
|
||||
if not self.langgraph_bridge:
|
||||
raise ExecutionError(
|
||||
f"LangGraph bridge not available for CompositeAgent '{self.name}'"
|
||||
)
|
||||
|
||||
if graph_name not in self.graphs:
|
||||
# Try to get from bridge
|
||||
graph = self.langgraph_bridge.get_graph(graph_name)
|
||||
if not graph:
|
||||
raise ExecutionError(f"Graph '{graph_name}' not found")
|
||||
else:
|
||||
graph = self.graphs[graph_name]
|
||||
|
||||
# Type guard to satisfy mypy --strict
|
||||
if graph is None:
|
||||
raise ExecutionError(f"Graph '{graph_name}' could not be loaded")
|
||||
|
||||
# Execute graph
|
||||
result = await graph.execute(
|
||||
{"messages": [{"role": "user", "content": message}], "metadata": context}
|
||||
)
|
||||
|
||||
# Extract result
|
||||
if hasattr(result, "messages") and result.messages:
|
||||
content = result.messages[-1].get("content", "")
|
||||
if not isinstance(content, str):
|
||||
return str(content)
|
||||
return content
|
||||
return str(result)
|
||||
|
||||
async def _process_via_stream(
|
||||
self, stream_name: str, message: str, context: dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Process message through an RxPy stream.
|
||||
|
||||
Args:
|
||||
stream_name (str): Name of the stream to use.
|
||||
message (str): The message to process.
|
||||
context (dict[str, Any]): Processing context.
|
||||
|
||||
Returns:
|
||||
str: Processed response.
|
||||
"""
|
||||
if not self.stream_router:
|
||||
raise ExecutionError(
|
||||
f"Stream router not available for CompositeAgent '{self.name}'"
|
||||
)
|
||||
|
||||
# Get output stream from routing config
|
||||
output_config = self.routing.get("output", {})
|
||||
output_stream = output_config.get("name", "__output__")
|
||||
|
||||
# Create a future to collect result
|
||||
result_future: asyncio.Future[str] = asyncio.Future()
|
||||
|
||||
def collect_output(msg: Any) -> None:
|
||||
if not result_future.done():
|
||||
result_future.set_result(
|
||||
msg.content if hasattr(msg, "content") else str(msg)
|
||||
)
|
||||
|
||||
# Subscribe to output
|
||||
observer = Observer(on_next=collect_output)
|
||||
subscription = self.stream_router.observables.get(
|
||||
output_stream, self.stream_router.observables["__output__"]
|
||||
).subscribe(observer)
|
||||
|
||||
try:
|
||||
# Send input
|
||||
self.stream_router.send_message(stream_name, message, context)
|
||||
|
||||
# Wait for result
|
||||
result = await asyncio.wait_for(result_future, timeout=0.05)
|
||||
return result
|
||||
finally:
|
||||
subscription.dispose()
|
||||
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""
|
||||
Get the capabilities of this agent.
|
||||
|
||||
Returns:
|
||||
List[str]: List of capabilities.
|
||||
"""
|
||||
capabilities = ["composite"]
|
||||
|
||||
# Add capabilities from all component types
|
||||
for agent in self.agents.values():
|
||||
capabilities.extend(agent.get_capabilities())
|
||||
|
||||
if self.graphs:
|
||||
capabilities.append("stateful-workflow")
|
||||
|
||||
if self.streams:
|
||||
capabilities.append("reactive-processing")
|
||||
|
||||
# Add legacy strategy if present
|
||||
if hasattr(self, "strategy"):
|
||||
capabilities.append(self.strategy)
|
||||
|
||||
return list(set(capabilities)) # Remove duplicates
|
||||
@@ -0,0 +1,18 @@
|
||||
"""
|
||||
This module contains decorators for agents.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
|
||||
|
||||
def log_action(func: Callable[..., Any]) -> Callable[..., Any]:
|
||||
"""
|
||||
Decorator to log the actions of an agent.
|
||||
"""
|
||||
|
||||
def wrapper(*args: Any, **kwargs: Any) -> Any:
|
||||
print(f"Executing {func.__name__}")
|
||||
return func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
@@ -0,0 +1,309 @@
|
||||
"""
|
||||
Reactive agent factory module for CleverAgents.
|
||||
|
||||
This module provides a factory for creating reactive agent instances based on
|
||||
configuration, supporting the RxPy-based reactive architecture.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
from typing import Type
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
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 reactive agent instances.
|
||||
|
||||
This class creates agent instances that work within the RxPy-based
|
||||
reactive architecture, providing stream processing capabilities.
|
||||
|
||||
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,
|
||||
stream_router: Optional[Any] = None,
|
||||
langgraph_bridge: Optional[Any] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the AgentFactory.
|
||||
|
||||
Args:
|
||||
config: Configuration for agent creation.
|
||||
template_renderer: Renderer for processing templates.
|
||||
stream_router: Optional stream router for composite agents.
|
||||
langgraph_bridge: Optional LangGraph bridge for composite agents.
|
||||
"""
|
||||
self.agent_types = {
|
||||
"llm": LLMAgent,
|
||||
"tool": ToolAgent,
|
||||
}
|
||||
self.template_renderer = template_renderer
|
||||
self.config = config
|
||||
self.stream_router = stream_router
|
||||
self.langgraph_bridge = langgraph_bridge
|
||||
self.agents: dict[str, Agent] = {} # Cache of created agents
|
||||
|
||||
def register_agent_type(self, type_name: str, agent_class: Type[Agent]) -> None:
|
||||
"""
|
||||
Register a new agent type.
|
||||
|
||||
Args:
|
||||
type_name: The name of the agent type.
|
||||
agent_class: The agent class to register.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If the agent class is invalid.
|
||||
"""
|
||||
if not issubclass(agent_class, Agent):
|
||||
raise ConfigurationError(
|
||||
f"Agent class {agent_class.__name__} must inherit from Agent"
|
||||
)
|
||||
|
||||
self.agent_types[type_name] = agent_class
|
||||
|
||||
def get_agent_types(self) -> dict[str, Type[Agent]]:
|
||||
"""
|
||||
Get registered agent types.
|
||||
|
||||
Returns:
|
||||
Dictionary of registered agent types.
|
||||
"""
|
||||
return self.agent_types.copy()
|
||||
|
||||
def create_agent(self, agent_name: str) -> Agent:
|
||||
"""
|
||||
Create an agent with the given name.
|
||||
|
||||
Args:
|
||||
agent_name: The name of the agent to create.
|
||||
|
||||
Returns:
|
||||
The created agent instance.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If the agent cannot be created.
|
||||
"""
|
||||
# Check cache first
|
||||
if agent_name in self.agents:
|
||||
return self.agents[agent_name]
|
||||
|
||||
# Get agent configuration
|
||||
agents_config = self.config.get("agents", {})
|
||||
if agent_name not in agents_config:
|
||||
raise AgentCreationError(f"No configuration found for agent '{agent_name}'")
|
||||
|
||||
agent_config = agents_config[agent_name]
|
||||
agent_type = agent_config.get("type", "llm")
|
||||
|
||||
# Create the agent
|
||||
agent = self._create_agent_instance(
|
||||
agent_name, agent_type, agent_config.get("config", {})
|
||||
)
|
||||
|
||||
# Cache the agent
|
||||
self.agents[agent_name] = agent
|
||||
|
||||
return agent
|
||||
|
||||
def _create_agent_instance(
|
||||
self, agent_name: str, agent_type: str, agent_config: dict[str, Any]
|
||||
) -> Agent:
|
||||
"""
|
||||
Create an agent instance of the specified type.
|
||||
|
||||
Args:
|
||||
agent_name: Name of the agent.
|
||||
agent_type: Type of the agent.
|
||||
agent_config: Configuration for the agent.
|
||||
|
||||
Returns:
|
||||
The created agent instance.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If the agent cannot be created.
|
||||
"""
|
||||
if agent_type == "composite":
|
||||
# Import here to avoid circular imports
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from cleveragents.agents.composite import CompositeAgent
|
||||
|
||||
# Add stream router and bridge to config
|
||||
agent_config = agent_config.copy()
|
||||
agent_config["_stream_router"] = self.stream_router
|
||||
agent_config["_langgraph_bridge"] = self.langgraph_bridge
|
||||
|
||||
composite_agent = CompositeAgent(
|
||||
name=agent_name,
|
||||
config=agent_config,
|
||||
template_renderer=self.template_renderer,
|
||||
stream_router=self.stream_router,
|
||||
langgraph_bridge=self.langgraph_bridge,
|
||||
)
|
||||
|
||||
# Handle new component-based configuration
|
||||
if "components" in agent_config:
|
||||
# Process component agents
|
||||
for comp_agent_name, comp_agent_config in (
|
||||
agent_config.get("components", {}).get("agents", {}).items()
|
||||
):
|
||||
if (
|
||||
isinstance(comp_agent_config, dict)
|
||||
and "type" in comp_agent_config
|
||||
):
|
||||
# Create nested agent
|
||||
nested_agent = self._create_agent_instance(
|
||||
comp_agent_name,
|
||||
comp_agent_config["type"],
|
||||
comp_agent_config.get("config", {}),
|
||||
)
|
||||
composite_agent.add_agent(comp_agent_name, nested_agent)
|
||||
# Note: Graphs and streams are handled by the composite agent itself
|
||||
else:
|
||||
# Legacy configuration support
|
||||
for step_agent_name in agent_config.get("agents", []):
|
||||
if step_agent_name in self.agents:
|
||||
composite_agent.add_agent(
|
||||
step_agent_name, self.agents[step_agent_name]
|
||||
)
|
||||
|
||||
return composite_agent
|
||||
|
||||
# Check if agent type is registered
|
||||
if agent_type not in self.agent_types:
|
||||
raise AgentCreationError(f"Unknown agent type: {agent_type}")
|
||||
|
||||
agent_class = self.agent_types[agent_type]
|
||||
|
||||
try:
|
||||
# Create agent instance
|
||||
agent: Agent = agent_class(
|
||||
name=agent_name,
|
||||
config=agent_config,
|
||||
template_renderer=self.template_renderer,
|
||||
)
|
||||
|
||||
return agent
|
||||
|
||||
except Exception as e:
|
||||
raise AgentCreationError(
|
||||
f"Failed to create agent '{agent_name}' of type '{agent_type}': {str(e)}"
|
||||
) from e
|
||||
|
||||
def create_agents_from_config(self) -> dict[str, Agent]:
|
||||
"""
|
||||
Create all agents from the configuration.
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to agent instances.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If any agent cannot be created.
|
||||
"""
|
||||
agents = {}
|
||||
agents_config = self.config.get("agents", {})
|
||||
|
||||
for agent_name in agents_config:
|
||||
agents[agent_name] = self.create_agent(agent_name)
|
||||
|
||||
return agents
|
||||
|
||||
def validate_configuration(self) -> None:
|
||||
"""
|
||||
Validate the agent configuration.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If the configuration is invalid.
|
||||
"""
|
||||
agents_config = self.config.get("agents", {})
|
||||
|
||||
if not isinstance(agents_config, dict):
|
||||
raise ConfigurationError("'agents' configuration must be a dictionary")
|
||||
|
||||
for agent_name, agent_config in agents_config.items():
|
||||
if not isinstance(agent_config, dict):
|
||||
raise ConfigurationError(
|
||||
f"Configuration for agent '{agent_name}' must be a dictionary"
|
||||
)
|
||||
|
||||
if "type" not in agent_config:
|
||||
raise ConfigurationError(f"Agent '{agent_name}' must specify a type")
|
||||
|
||||
agent_type = agent_config["type"]
|
||||
if agent_type not in self.agent_types and agent_type != "composite":
|
||||
raise ConfigurationError(
|
||||
f"Unknown agent type '{agent_type}' for agent '{agent_name}'"
|
||||
)
|
||||
|
||||
# Validate agent-specific configuration
|
||||
if "config" in agent_config:
|
||||
if not isinstance(agent_config["config"], dict):
|
||||
raise ConfigurationError(
|
||||
f"'config' for agent '{agent_name}' must be a dictionary"
|
||||
)
|
||||
|
||||
def get_agent_metadata(self, agent_name: str) -> dict[str, Any]:
|
||||
"""
|
||||
Get metadata for an agent without creating it.
|
||||
|
||||
Args:
|
||||
agent_name: The name of the agent.
|
||||
|
||||
Returns:
|
||||
Metadata dictionary for the agent.
|
||||
|
||||
Raises:
|
||||
AgentCreationError: If the agent configuration is invalid.
|
||||
"""
|
||||
agents_config = self.config.get("agents", {})
|
||||
if agent_name not in agents_config:
|
||||
raise AgentCreationError(f"No configuration found for agent '{agent_name}'")
|
||||
|
||||
agent_config = agents_config[agent_name]
|
||||
agent_type = agent_config.get("type", "llm")
|
||||
|
||||
metadata = {
|
||||
"name": agent_name,
|
||||
"type": agent_type,
|
||||
"reactive": True,
|
||||
}
|
||||
|
||||
# Add type-specific metadata
|
||||
if agent_type == "llm":
|
||||
config = agent_config.get("config", {})
|
||||
metadata.update(
|
||||
{
|
||||
"provider": config.get("provider", "openai"),
|
||||
"model": config.get("model", "gpt-3.5-turbo"),
|
||||
"temperature": config.get("temperature", 0.7),
|
||||
}
|
||||
)
|
||||
elif agent_type == "tool":
|
||||
config = agent_config.get("config", {})
|
||||
metadata.update(
|
||||
{
|
||||
"tools": config.get("tools", []),
|
||||
"allow_shell": config.get("allow_shell", False),
|
||||
"safe_mode": config.get("safe_mode", True),
|
||||
}
|
||||
)
|
||||
|
||||
return metadata
|
||||
|
||||
|
||||
# For backward compatibility
|
||||
ReactiveAgentFactory = AgentFactory
|
||||
@@ -0,0 +1,261 @@
|
||||
"""
|
||||
Reactive LLM agent module for CleverAgents.
|
||||
|
||||
This module defines the LLMAgent class, which extends the LLM agent
|
||||
capabilities to work within the RxPy-based reactive architecture using LangChain.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Type
|
||||
|
||||
from langchain_anthropic import ChatAnthropic
|
||||
from langchain_core.exceptions import LangChainException
|
||||
from langchain_core.language_models.chat_models import BaseChatModel
|
||||
from langchain_core.messages import AIMessage
|
||||
from langchain_core.messages import BaseMessage
|
||||
from langchain_core.messages import HumanMessage
|
||||
from langchain_core.messages import SystemMessage
|
||||
from langchain_google_genai import ChatGoogleGenerativeAI
|
||||
from langchain_openai import ChatOpenAI
|
||||
|
||||
from cleveragents.agents.base import AgentWithMemory
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
from cleveragents.core.exceptions import ExecutionError
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_SYSTEM_MESSAGE = "You are a helpful assistant."
|
||||
|
||||
|
||||
class LLMAgent(AgentWithMemory):
|
||||
"""
|
||||
Reactive agent powered by a large language model (LLM) using LangChain.
|
||||
|
||||
This class provides LLM capabilities within the reactive stream architecture,
|
||||
supporting asynchronous processing and stream integration through LangChain.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, config: dict[str, Any], template_renderer: TemplateRenderer
|
||||
):
|
||||
"""
|
||||
Initialize a reactive LLM agent using LangChain.
|
||||
|
||||
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.
|
||||
"""
|
||||
super().__init__(name, config, template_renderer)
|
||||
|
||||
self.provider = config.get("provider", "openai")
|
||||
self.model = config.get("model", self._get_default_model())
|
||||
self.temperature = config.get("temperature", 0.7)
|
||||
self.max_tokens = config.get("max_tokens", 1000)
|
||||
self.system_message = config.get("system_prompt", DEFAULT_SYSTEM_MESSAGE)
|
||||
|
||||
# Initialize LangChain chat model
|
||||
self.chat_model = self._create_chat_model()
|
||||
|
||||
logger.info(
|
||||
"Initialized reactive LLM agent %s with %s/%s", name, self.provider, self.model
|
||||
)
|
||||
|
||||
def _get_default_model(self) -> str:
|
||||
"""Get default model for the provider."""
|
||||
default_models = {
|
||||
"openai": "gpt-3.5-turbo",
|
||||
"anthropic": "claude-3-5-sonnet-20241022",
|
||||
"google": "gemini-1.5-flash",
|
||||
}
|
||||
return default_models.get(self.provider.lower(), "gpt-3.5-turbo")
|
||||
|
||||
def _create_chat_model(self) -> BaseChatModel:
|
||||
"""Create LangChain chat model based on provider configuration."""
|
||||
# Provider configuration mapping with explicit types
|
||||
provider_config: dict[str, dict[str, Any]] = {
|
||||
"openai": {
|
||||
"class": ChatOpenAI,
|
||||
"param_renames": {
|
||||
"max_tokens": "max_tokens",
|
||||
"api_key": "api_key",
|
||||
},
|
||||
},
|
||||
"anthropic": {
|
||||
"class": ChatAnthropic,
|
||||
"param_renames": {
|
||||
"max_tokens": "max_tokens",
|
||||
"api_key": "api_key",
|
||||
},
|
||||
},
|
||||
"google": {
|
||||
"class": ChatGoogleGenerativeAI,
|
||||
"param_renames": {
|
||||
"max_tokens": "max_output_tokens",
|
||||
"api_key": "google_api_key",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
try:
|
||||
provider_lower = self.provider.lower()
|
||||
|
||||
# Check if provider is supported
|
||||
if provider_lower not in provider_config:
|
||||
raise ConfigurationError(f"Unsupported provider: {self.provider}")
|
||||
|
||||
config = provider_config[provider_lower]
|
||||
|
||||
# Build common kwargs
|
||||
common_kwargs: dict[str, Any] = {
|
||||
"model": self.model,
|
||||
"temperature": self.temperature,
|
||||
}
|
||||
|
||||
# Apply parameter renames
|
||||
param_renames: dict[str, str] = config["param_renames"]
|
||||
|
||||
# Add max_tokens with provider-specific name
|
||||
max_tokens_param = param_renames["max_tokens"]
|
||||
common_kwargs[max_tokens_param] = self.max_tokens
|
||||
|
||||
# Add api_key if provided, with provider-specific name
|
||||
api_key = self.config.get("api_key")
|
||||
if api_key:
|
||||
api_key_param = param_renames["api_key"]
|
||||
common_kwargs[api_key_param] = api_key
|
||||
|
||||
# Instantiate the provider-specific chat model class
|
||||
chat_model_class: Type[BaseChatModel] = config["class"]
|
||||
return chat_model_class(**common_kwargs)
|
||||
|
||||
except Exception as e:
|
||||
raise ConfigurationError(
|
||||
f"Failed to initialize {self.provider} model: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def process_message( # pylint: disable=too-many-branches
|
||||
self, message: str, context: Optional[dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message through the LLM using LangChain.
|
||||
|
||||
Args:
|
||||
message: The message to process.
|
||||
context: Additional context information.
|
||||
|
||||
Returns:
|
||||
The LLM's response.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If message processing fails.
|
||||
"""
|
||||
try:
|
||||
# Process template if specified
|
||||
if "template" in self.config:
|
||||
template_name = self.config["template"]
|
||||
template_vars = {
|
||||
"message": message,
|
||||
"context": context or {},
|
||||
**self.config.get("template_vars", {}),
|
||||
}
|
||||
processed_message = self.template_renderer.render(
|
||||
template_name, template_vars
|
||||
)
|
||||
else:
|
||||
processed_message = message
|
||||
|
||||
# Build messages list
|
||||
messages: List[BaseMessage] = []
|
||||
|
||||
# Add system message
|
||||
if self.system_message:
|
||||
messages.append(SystemMessage(content=self.system_message))
|
||||
|
||||
# Prioritize conversation history from context (e.g., from graph state)
|
||||
# This allows agents in a LangGraph to see full conversation including other agents
|
||||
history = None
|
||||
if context and "conversation_history" in context:
|
||||
history = context["conversation_history"]
|
||||
elif self.config.get("memory_enabled", False):
|
||||
# Fall back to agent's own memory if no context history
|
||||
history = await self.get_memory("conversation_history", [])
|
||||
# Add conversation history to messages
|
||||
if history:
|
||||
for msg in history:
|
||||
if msg.get("role") == "user":
|
||||
messages.append(HumanMessage(content=msg.get("content", "")))
|
||||
elif msg.get("role") == "assistant":
|
||||
messages.append(AIMessage(content=msg.get("content", "")))
|
||||
|
||||
# Add current user message
|
||||
messages.append(HumanMessage(content=processed_message))
|
||||
|
||||
# Call LangChain model
|
||||
response = await self.chat_model.ainvoke(messages)
|
||||
response_text = str(response.content)
|
||||
|
||||
# Update memory if enabled
|
||||
if self.config.get("memory_enabled", False):
|
||||
await self.update_memory("last_message", message)
|
||||
await self.update_memory("last_response", response_text)
|
||||
|
||||
# Update conversation history
|
||||
history = await self.get_memory("conversation_history", [])
|
||||
history.append({"role": "user", "content": processed_message})
|
||||
history.append({"role": "assistant", "content": response_text})
|
||||
|
||||
# Keep only last N messages
|
||||
max_history = self.config.get("max_history", 10)
|
||||
if len(history) > max_history:
|
||||
history = history[-max_history:]
|
||||
await self.update_memory("conversation_history", history)
|
||||
|
||||
return response_text
|
||||
|
||||
except LangChainException as e:
|
||||
logger.error("LLM agent %s LangChain error: %s", self.name, e)
|
||||
raise ExecutionError(f"LLM processing failed: {str(e)}") from e
|
||||
except Exception as e:
|
||||
logger.error("LLM agent %s processing failed: %s", self.name, e)
|
||||
raise ExecutionError(f"LLM processing failed: {str(e)}") from e
|
||||
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""Get the capabilities of the LLM agent."""
|
||||
capabilities = [
|
||||
"text-generation",
|
||||
"conversation",
|
||||
"reasoning",
|
||||
"analysis",
|
||||
"creative-writing",
|
||||
]
|
||||
|
||||
# Add structured output capability for supported providers
|
||||
if self.provider.lower() in ["openai", "anthropic", "google"]:
|
||||
capabilities.append("structured-output")
|
||||
|
||||
return capabilities
|
||||
|
||||
def get_metadata(self) -> dict[str, Any]:
|
||||
"""Get metadata about the LLM agent."""
|
||||
metadata = super().get_metadata()
|
||||
metadata.update(
|
||||
{
|
||||
"provider": self.provider,
|
||||
"model": self.model,
|
||||
"temperature": self.temperature,
|
||||
"max_tokens": self.max_tokens,
|
||||
"memory_enabled": self.config.get("memory_enabled", False),
|
||||
"langchain_integration": True,
|
||||
"supports_structured_output": self.provider.lower()
|
||||
in ["openai", "anthropic", "google"],
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
@@ -0,0 +1,12 @@
|
||||
"""
|
||||
This module contains state management for agents.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def initial_state() -> dict[str, Any]:
|
||||
"""
|
||||
Returns the initial state for an agent.
|
||||
"""
|
||||
return {}
|
||||
@@ -0,0 +1,615 @@
|
||||
"""
|
||||
Reactive tool agent module for CleverAgents.
|
||||
|
||||
This module defines the ToolAgent class, which provides tool execution
|
||||
capabilities within the RxPy-based reactive architecture.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import subprocess
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional, Union, Literal
|
||||
|
||||
import aiohttp
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.core.exceptions import AgentCreationError
|
||||
from cleveragents.core.exceptions import ExecutionError
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ToolAgent(Agent):
|
||||
"""
|
||||
Reactive agent that executes tools and external functions.
|
||||
|
||||
This class provides tool execution capabilities within the reactive
|
||||
stream architecture, supporting both built-in and custom tools.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, name: str, config: dict[str, Any], template_renderer: TemplateRenderer
|
||||
):
|
||||
"""
|
||||
Initialize a reactive tool 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.
|
||||
"""
|
||||
super().__init__(name, config, template_renderer)
|
||||
|
||||
self.tools = config.get("tools", [])
|
||||
self.allow_shell = config.get("allow_shell", False)
|
||||
self.timeout = config.get("timeout", 1)
|
||||
self.safe_mode = config.get("safe_mode", True)
|
||||
|
||||
# Set up built-in tools
|
||||
self.builtin_tools = {
|
||||
"echo": self._echo_tool,
|
||||
"math": self._math_tool,
|
||||
"json_parse": self._json_parse_tool,
|
||||
"http_request": self._http_request_tool,
|
||||
"file_read": self._file_read_tool,
|
||||
"file_write": self._file_write_tool,
|
||||
}
|
||||
|
||||
# Validate tools
|
||||
self._validate_tools()
|
||||
|
||||
logger.info("Initialized reactive tool agent %s with tools: %s", name, self.tools)
|
||||
|
||||
def _validate_tools(self) -> None:
|
||||
"""Validate the configured tools."""
|
||||
for tool in self.tools:
|
||||
if isinstance(tool, str):
|
||||
if tool not in self.builtin_tools and not self.allow_shell:
|
||||
raise AgentCreationError(
|
||||
f"Unknown tool '{tool}' and shell execution disabled"
|
||||
)
|
||||
elif isinstance(tool, dict):
|
||||
if "name" not in tool:
|
||||
raise AgentCreationError("Tool configuration must include 'name'")
|
||||
else:
|
||||
raise AgentCreationError(f"Invalid tool configuration: {tool}")
|
||||
|
||||
def _extract_json_from_message(self, message: str) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Extract JSON from a message that might contain markdown code blocks or extra text.
|
||||
|
||||
Args:
|
||||
message: The message that might contain JSON.
|
||||
|
||||
Returns:
|
||||
Parsed JSON dict if found, None otherwise.
|
||||
"""
|
||||
# pylint: disable=too-many-return-statements
|
||||
message_stripped = message.strip()
|
||||
|
||||
# If message looks like JSON (starts with { and ends with }), try to parse it
|
||||
if message_stripped.startswith("{") and message_stripped.endswith("}"):
|
||||
try:
|
||||
parsed = json.loads(message_stripped)
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
# Not a dict, fall through to extraction logic
|
||||
except json.JSONDecodeError:
|
||||
# Not valid complete JSON, fall through to extraction logic
|
||||
pass
|
||||
|
||||
# Try to extract JSON from markdown code blocks
|
||||
# Pattern: ```json ... ``` or ``` ... ```
|
||||
code_block_pattern = r'```(?:json)?\s*(\{.*?\})\s*```'
|
||||
match = re.search(code_block_pattern, message, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group(1))
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
pass # Fall through
|
||||
|
||||
# Try to find any JSON object in the message
|
||||
json_pattern = r'\{[^{}]*(?:\{[^{}]*\}[^{}]*)*\}'
|
||||
match = re.search(json_pattern, message, re.DOTALL)
|
||||
if match:
|
||||
try:
|
||||
parsed = json.loads(match.group(0))
|
||||
if isinstance(parsed, dict):
|
||||
return parsed
|
||||
except json.JSONDecodeError:
|
||||
pass # Already handled by fall-through
|
||||
|
||||
return None
|
||||
|
||||
async def process_message(
|
||||
self, message: str, context: Optional[dict[str, Any]] = None
|
||||
) -> str:
|
||||
"""
|
||||
Process a message by executing the appropriate tool.
|
||||
|
||||
Args:
|
||||
message: The message containing tool execution request.
|
||||
context: Additional context information.
|
||||
|
||||
Returns:
|
||||
The result of tool execution.
|
||||
|
||||
Raises:
|
||||
ExecutionError: If tool execution fails.
|
||||
"""
|
||||
try:
|
||||
# Check if message looks like JSON but might be invalid
|
||||
message_stripped = message.strip()
|
||||
looks_like_json = message_stripped.startswith("{") and message_stripped.endswith("}")
|
||||
|
||||
# Try to extract JSON tool request from message
|
||||
tool_request = self._extract_json_from_message(message)
|
||||
|
||||
if tool_request:
|
||||
tool_name = tool_request.get("tool")
|
||||
tool_args = tool_request.get("args", {})
|
||||
|
||||
if tool_name:
|
||||
# Execute the tool
|
||||
result = await self._execute_tool(tool_name, tool_args, context)
|
||||
return str(result)
|
||||
elif looks_like_json:
|
||||
# Message looks like JSON but couldn't be parsed - report as invalid JSON
|
||||
raise ExecutionError("Invalid JSON in tool request")
|
||||
|
||||
# Fall back to simple format: "tool_name arg1 arg2"
|
||||
parts = message_stripped.split()
|
||||
if not parts:
|
||||
raise ExecutionError("Empty tool request")
|
||||
|
||||
tool_name = parts[0]
|
||||
tool_args = {"args": parts[1:]} if len(parts) > 1 else {}
|
||||
|
||||
# Execute the tool
|
||||
result = await self._execute_tool(tool_name, tool_args, context)
|
||||
|
||||
return str(result)
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
raise ExecutionError(f"Invalid JSON in tool request: {e}") from e
|
||||
except Exception as e:
|
||||
logger.error("Tool agent %s execution failed: %s", self.name, e)
|
||||
raise ExecutionError(f"Tool execution failed: {str(e)}") from e
|
||||
|
||||
async def _execute_tool(
|
||||
self,
|
||||
tool_name: str,
|
||||
tool_args: dict[str, Any],
|
||||
context: Optional[dict[str, Any]],
|
||||
) -> Any:
|
||||
"""Execute a specific tool."""
|
||||
# Check if tool is in the allowed list first
|
||||
dict_tool_names = [t.get("name") for t in self.tools if isinstance(t, dict)]
|
||||
|
||||
if tool_name not in self.tools and tool_name not in dict_tool_names:
|
||||
logger.error("Tool '%s' not in allowed list for %s", tool_name, self.name)
|
||||
raise ExecutionError(f"Tool '{tool_name}' not in allowed tools list")
|
||||
|
||||
# Check if it's a built-in tool
|
||||
if tool_name in self.builtin_tools:
|
||||
result = await self.builtin_tools[tool_name](tool_args, context)
|
||||
return result
|
||||
|
||||
# Execute custom tool or shell command
|
||||
if self.allow_shell:
|
||||
return await self._execute_shell_command(tool_name, tool_args)
|
||||
|
||||
logger.error(
|
||||
"Tool '%s' not found and shell execution disabled for %s",
|
||||
tool_name, self.name
|
||||
)
|
||||
raise ExecutionError(
|
||||
f"Shell execution disabled, cannot execute '{tool_name}'"
|
||||
)
|
||||
|
||||
async def _execute_shell_command(self, command: str, args: dict[str, Any]) -> str:
|
||||
"""Execute a shell command safely."""
|
||||
if self.safe_mode:
|
||||
# Basic safety checks
|
||||
dangerous_commands = ["rm", "del", "format", "shutdown", "reboot", "kill"]
|
||||
if any(cmd in command.lower() for cmd in dangerous_commands):
|
||||
raise ExecutionError(
|
||||
f"Dangerous command '{command}' blocked in safe mode"
|
||||
)
|
||||
|
||||
# Build command with arguments
|
||||
cmd_parts = [command]
|
||||
if "args" in args:
|
||||
cmd_parts.extend(str(arg) for arg in args["args"])
|
||||
|
||||
try:
|
||||
# Execute with timeout
|
||||
process = await asyncio.create_subprocess_exec(
|
||||
*cmd_parts, stdout=subprocess.PIPE, stderr=subprocess.PIPE
|
||||
)
|
||||
|
||||
stdout, stderr = await asyncio.wait_for(
|
||||
process.communicate(), timeout=self.timeout
|
||||
)
|
||||
|
||||
if process.returncode != 0:
|
||||
error_msg = stderr.decode().strip()
|
||||
raise ExecutionError(
|
||||
f"Command failed with code {process.returncode}: {error_msg}"
|
||||
)
|
||||
|
||||
return stdout.decode().strip()
|
||||
|
||||
except asyncio.TimeoutError as timeout_err:
|
||||
raise ExecutionError(
|
||||
f"Command '{command}' timed out after {self.timeout} seconds"
|
||||
) from timeout_err
|
||||
|
||||
# Built-in tools
|
||||
async def _echo_tool(
|
||||
self, args: dict[str, Any], context: Optional[dict[str, Any]] # pylint: disable=unused-argument
|
||||
) -> str:
|
||||
"""Simple echo tool."""
|
||||
text = args.get("text", "")
|
||||
if "args" in args:
|
||||
text = " ".join(str(arg) for arg in args["args"])
|
||||
return str(text)
|
||||
|
||||
async def _math_tool(
|
||||
self, args: dict[str, Any], context: Optional[dict[str, Any]] # pylint: disable=unused-argument
|
||||
) -> str:
|
||||
"""Basic math evaluation tool."""
|
||||
expression = args.get("expression", "")
|
||||
if "args" in args and args["args"]:
|
||||
expression = args["args"][0]
|
||||
|
||||
if not expression:
|
||||
raise ExecutionError("Math tool requires an expression")
|
||||
|
||||
try:
|
||||
# Safe evaluation using eval with restricted builtins
|
||||
allowed_names = {
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"pow": pow,
|
||||
"int": int,
|
||||
"float": float,
|
||||
"__builtins__": {},
|
||||
}
|
||||
result = eval(expression, allowed_names) # pylint: disable=eval-used
|
||||
return str(result)
|
||||
except Exception as e:
|
||||
raise ExecutionError(f"Math evaluation failed: {e}") from e
|
||||
|
||||
async def _json_parse_tool(
|
||||
self, args: dict[str, Any], context: Optional[dict[str, Any]] # pylint: disable=unused-argument
|
||||
) -> str:
|
||||
"""JSON parsing tool."""
|
||||
json_str = args.get("json", "")
|
||||
if "args" in args and args["args"]:
|
||||
json_str = args["args"][0]
|
||||
|
||||
try:
|
||||
parsed = json.loads(json_str)
|
||||
return json.dumps(parsed, indent=2)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ExecutionError(f"JSON parsing failed: {e}") from e
|
||||
|
||||
async def _http_request_tool(
|
||||
self, args: dict[str, Any], context: Optional[dict[str, Any]] # pylint: disable=unused-argument
|
||||
) -> str:
|
||||
"""HTTP request tool."""
|
||||
url = args.get("url", "")
|
||||
method = args.get("method", "GET").upper()
|
||||
headers = args.get("headers", {})
|
||||
data = args.get("data")
|
||||
|
||||
if not url:
|
||||
raise ExecutionError("HTTP tool requires a URL")
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.request(
|
||||
method, url, headers=headers, json=data, timeout=self.timeout
|
||||
) as response:
|
||||
content = await response.text()
|
||||
return f"Status: {response.status}\n\n{content}"
|
||||
except Exception as e:
|
||||
raise ExecutionError(f"HTTP request failed: {e}") from e
|
||||
|
||||
async def _file_read_tool(
|
||||
self, args: dict[str, Any], context: Optional[dict[str, Any]]
|
||||
) -> str:
|
||||
"""File reading tool."""
|
||||
filepath = args.get("file", "")
|
||||
if "args" in args and args["args"]:
|
||||
filepath = args["args"][0]
|
||||
|
||||
if not filepath:
|
||||
raise ExecutionError("File read tool requires a file path")
|
||||
|
||||
if self.safe_mode:
|
||||
# Always block directory traversal attempts
|
||||
if ".." in filepath:
|
||||
raise ExecutionError("Unsafe file path blocked in safe mode")
|
||||
# Block absolute paths unless in unsafe mode
|
||||
if filepath.startswith("/") and not (
|
||||
context and context.get("_unsafe_mode", False)
|
||||
):
|
||||
raise ExecutionError("Unsafe file path blocked in safe mode")
|
||||
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
# Return content with clean terminal indicator
|
||||
# The content is still in the response for context/memory
|
||||
# but we add a prefix for clean terminal display
|
||||
line_count = content.count('\n') + 1
|
||||
char_count = len(content)
|
||||
# Format: Special marker + metadata + full content
|
||||
# The special marker helps identify this for clean display
|
||||
return (
|
||||
f"[FILE_READ_SUCCESS]📄 File: {filepath} | "
|
||||
f"Lines: {line_count} | Size: {char_count} chars\n"
|
||||
f"[FILE_CONTENT_START]\n{content}\n[FILE_CONTENT_END]"
|
||||
)
|
||||
except Exception as e:
|
||||
raise ExecutionError(f"File read failed: {e}") from e
|
||||
|
||||
def _validate_file_write_args(
|
||||
self, filepath: str, content: str
|
||||
) -> None:
|
||||
"""Validate file write arguments."""
|
||||
if not filepath or not content:
|
||||
logger.error("File write requires filepath and content")
|
||||
raise ExecutionError("File write tool requires file path and content")
|
||||
|
||||
def _validate_file_path_safety(
|
||||
self, filepath: str, unsafe_mode: bool
|
||||
) -> None:
|
||||
"""
|
||||
Validate file path for safety with comprehensive checks.
|
||||
|
||||
Args:
|
||||
filepath: The file path to validate
|
||||
unsafe_mode: If True, allows absolute paths (caller must explicitly opt-in)
|
||||
|
||||
Raises:
|
||||
ExecutionError: If the path is deemed unsafe
|
||||
"""
|
||||
# Normalize the path to resolve . and .. components
|
||||
# This also handles multiple slashes and other path oddities
|
||||
try:
|
||||
normalized_path = os.path.normpath(filepath)
|
||||
except (ValueError, TypeError) as e:
|
||||
raise ExecutionError(f"Invalid file path: {e}") from e
|
||||
|
||||
# Resolve to absolute path to detect directory traversal
|
||||
# This also expands ~ and handles symlinks
|
||||
try:
|
||||
absolute_path = os.path.abspath(normalized_path)
|
||||
# Get the working directory as absolute path
|
||||
working_dir = os.path.abspath(os.getcwd())
|
||||
except (ValueError, OSError) as e:
|
||||
raise ExecutionError(f"Cannot resolve file path: {e}") from e
|
||||
|
||||
# Check if path is absolute (cross-platform) in the INPUT
|
||||
is_absolute = os.path.isabs(normalized_path)
|
||||
|
||||
|
||||
has_traversal = ".." in filepath or ".." in normalized_path
|
||||
|
||||
if self.safe_mode and has_traversal:
|
||||
# ALWAYS block directory traversal attempts when agent has safe_mode=True
|
||||
logger.error("Directory traversal pattern detected in: %s", filepath)
|
||||
raise ExecutionError(
|
||||
"Unsafe file path blocked in safe mode"
|
||||
)
|
||||
|
||||
# Check if path escapes working directory
|
||||
path_escapes_working_dir = (
|
||||
not absolute_path.startswith(working_dir + os.sep)
|
||||
and absolute_path != working_dir
|
||||
)
|
||||
|
||||
if not unsafe_mode:
|
||||
# Caller didn't opt-in to unsafe_mode: enforce working directory restriction
|
||||
if path_escapes_working_dir:
|
||||
logger.error(
|
||||
"Directory traversal blocked: %s resolves to %s outside %s",
|
||||
filepath, absolute_path, working_dir
|
||||
)
|
||||
raise ExecutionError(
|
||||
"Unsafe file path blocked in safe mode"
|
||||
)
|
||||
|
||||
# Also block absolute paths in input
|
||||
if is_absolute:
|
||||
logger.error("Absolute path blocked for %s", filepath)
|
||||
raise ExecutionError(
|
||||
"Unsafe file path blocked in safe mode"
|
||||
)
|
||||
else:
|
||||
# Caller opted-in to unsafe_mode
|
||||
if path_escapes_working_dir and self.safe_mode:
|
||||
logger.debug("Unsafe mode: allowing path outside working dir: %s", filepath)
|
||||
|
||||
|
||||
if is_absolute and self.safe_mode:
|
||||
# Allow absolute paths when caller provides unsafe_mode=True
|
||||
logger.debug("Unsafe mode: allowing absolute path: %s", filepath)
|
||||
|
||||
# Additional check: block paths starting with ~ (home directory)
|
||||
if filepath.startswith("~"):
|
||||
logger.error("Home directory expansion blocked for %s", filepath)
|
||||
raise ExecutionError("Home directory paths (~) are not allowed")
|
||||
|
||||
def _prepare_append_content(
|
||||
self, filepath: str, content: str
|
||||
) -> str:
|
||||
"""Prepare content for append mode with proper spacing for sections."""
|
||||
# Only add spacing if content looks like a document section (starts with #)
|
||||
# This prevents breaking simple append operations
|
||||
is_section = content.lstrip().startswith('#')
|
||||
|
||||
if not is_section:
|
||||
# Simple append without spacing
|
||||
return content
|
||||
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
existing_content = f.read()
|
||||
|
||||
# Add spacing for section-based content
|
||||
if existing_content and not existing_content.endswith('\n\n'):
|
||||
if existing_content.endswith('\n'):
|
||||
prefix = '\n' # Has one newline, add one more
|
||||
else:
|
||||
prefix = '\n\n' # No newline at all, add two
|
||||
else:
|
||||
prefix = ''
|
||||
except FileNotFoundError:
|
||||
prefix = '' # File doesn't exist yet
|
||||
|
||||
return prefix + content
|
||||
|
||||
def _handle_insert_position(
|
||||
self,
|
||||
filepath: str,
|
||||
content: str,
|
||||
position: Union[None, int, Literal["start", "end"]],
|
||||
) -> tuple[list[str], int]:
|
||||
"""Handle insert mode positioning logic."""
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
existing_lines = f.readlines()
|
||||
except FileNotFoundError:
|
||||
existing_lines = []
|
||||
|
||||
# Ensure content ends with newline
|
||||
formatted_content = (
|
||||
content if content.endswith('\n')
|
||||
else content + '\n'
|
||||
)
|
||||
|
||||
# Determine insertion position
|
||||
if position is None or position == "end":
|
||||
existing_lines.append(formatted_content)
|
||||
insert_location = len(existing_lines)
|
||||
elif position == "start":
|
||||
existing_lines.insert(0, formatted_content)
|
||||
insert_location = 1
|
||||
elif isinstance(position, int):
|
||||
line_idx = max(0, min(position - 1, len(existing_lines)))
|
||||
existing_lines.insert(line_idx, formatted_content)
|
||||
insert_location = line_idx + 1
|
||||
else:
|
||||
raise ExecutionError(
|
||||
f"Invalid position '{position}'. "
|
||||
f"Use 'start', 'end', or line number."
|
||||
)
|
||||
|
||||
return existing_lines, insert_location
|
||||
|
||||
async def _file_write_tool(
|
||||
self, args: dict[str, Any], context: Optional[dict[str, Any]]
|
||||
) -> str:
|
||||
"""File writing tool with support for write, append, and insert modes."""
|
||||
filepath = args.get("file", "")
|
||||
content = args.get("content", "")
|
||||
mode = args.get("mode", "w") # w=write, a=append, insert=insert at pos
|
||||
position = args.get("position", None)
|
||||
|
||||
# Validate inputs
|
||||
self._validate_file_write_args(filepath, content)
|
||||
# Check unsafe mode requirement
|
||||
unsafe_mode = context and context.get("_unsafe_mode", False)
|
||||
if not unsafe_mode:
|
||||
logger.error("File writing requires unsafe mode")
|
||||
raise ExecutionError("File writing requires unsafe mode")
|
||||
|
||||
# Validate file path safety
|
||||
self._validate_file_path_safety(filepath, unsafe_mode)
|
||||
|
||||
try:
|
||||
if mode == "w":
|
||||
# Standard write (overwrite)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.write(content)
|
||||
return f"Successfully wrote {len(content)} characters to {filepath}"
|
||||
|
||||
if mode == "a":
|
||||
# Append mode with proper spacing
|
||||
content_to_append = self._prepare_append_content(filepath, content)
|
||||
with open(filepath, "a", encoding="utf-8") as f:
|
||||
f.write(content_to_append)
|
||||
return f"Successfully appended {len(content)} characters to {filepath}"
|
||||
|
||||
if mode == "insert":
|
||||
# Insert mode at specified position
|
||||
existing_lines, insert_location = self._handle_insert_position(
|
||||
filepath, content, position
|
||||
)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
f.writelines(existing_lines)
|
||||
return (
|
||||
f"Successfully inserted {len(content)} characters "
|
||||
f"at line {insert_location} in {filepath}"
|
||||
)
|
||||
|
||||
raise ExecutionError(
|
||||
f"Invalid mode '{mode}'. Use 'w' (write), 'a' (append), or 'insert'."
|
||||
)
|
||||
except Exception as e:
|
||||
logger.error("File write failed for %s: %s", filepath, e)
|
||||
raise ExecutionError(f"File write failed: {e}") from e
|
||||
|
||||
def get_capabilities(self) -> List[str]:
|
||||
"""Get the capabilities of the tool agent."""
|
||||
capabilities = ["tool-execution", "command-execution"]
|
||||
|
||||
if "http_request" in self.tools or "http_request" in [
|
||||
t.get("name") for t in self.tools if isinstance(t, dict)
|
||||
]:
|
||||
capabilities.append("http-requests")
|
||||
|
||||
dict_tool_names = [t.get("name") for t in self.tools if isinstance(t, dict)]
|
||||
if (
|
||||
"file_read" in self.tools
|
||||
or "file_write" in self.tools
|
||||
or "file_read" in dict_tool_names
|
||||
or "file_write" in dict_tool_names
|
||||
):
|
||||
capabilities.append("file-operations")
|
||||
|
||||
if "math" in self.tools or "math" in dict_tool_names:
|
||||
capabilities.append("math-evaluation")
|
||||
|
||||
return capabilities
|
||||
|
||||
def get_metadata(self) -> dict[str, Any]:
|
||||
"""Get metadata about the tool agent."""
|
||||
metadata: dict[str, Any] = super().get_metadata()
|
||||
metadata.update(
|
||||
{
|
||||
"tools": self.tools,
|
||||
"allow_shell": self.allow_shell,
|
||||
"safe_mode": self.safe_mode,
|
||||
"timeout": self.timeout,
|
||||
}
|
||||
)
|
||||
return metadata
|
||||
@@ -0,0 +1,761 @@
|
||||
"""
|
||||
Reactive CLI module for CleverAgents.
|
||||
|
||||
This module provides a command-line interface for the reactive CleverAgents
|
||||
system, supporting RxPy-based stream processing and agent orchestration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import click
|
||||
|
||||
from cleveragents.core.application import ReactiveCleverAgentsApp
|
||||
from cleveragents.core.exceptions import CleverAgentsException
|
||||
from cleveragents.core.exceptions import UnsafeConfigurationError
|
||||
from cleveragents.reactive.config_parser import ReactiveConfig
|
||||
|
||||
|
||||
@click.group()
|
||||
@click.version_option()
|
||||
def main() -> None:
|
||||
"""Reactive CleverAgents - An RxPy-based Agent Network Framework."""
|
||||
|
||||
|
||||
@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 reactive 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 stream processing details.",
|
||||
)
|
||||
@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,
|
||||
unsafe: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Run the reactive agent network in single-shot mode.
|
||||
|
||||
This command processes a single prompt through the configured reactive
|
||||
agent network using RxPy streams and returns the final output.
|
||||
"""
|
||||
try:
|
||||
# Early validation for obviously invalid configs
|
||||
_validate_config_files(config)
|
||||
|
||||
app = ReactiveCleverAgentsApp(config, verbose, unsafe)
|
||||
result = asyncio.run(app.run_single_shot(prompt))
|
||||
except UnsafeConfigurationError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
except CleverAgentsException as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
except FileNotFoundError as e:
|
||||
click.echo(f"Error: Configuration file not found: {e}", err=True)
|
||||
sys.exit(2)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
# Catch all exceptions to provide user-friendly error messages
|
||||
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 reactive 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 stream processing details.",
|
||||
)
|
||||
@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,
|
||||
unsafe: bool,
|
||||
) -> None:
|
||||
"""
|
||||
Start an interactive session with the reactive agent network.
|
||||
|
||||
This command starts an interactive chat session with RxPy-based
|
||||
stream processing, allowing real-time interaction with the agent network.
|
||||
"""
|
||||
try:
|
||||
app = ReactiveCleverAgentsApp(config, verbose, unsafe)
|
||||
asyncio.run(app.start_interactive_session(history_file=history))
|
||||
except UnsafeConfigurationError as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
except CleverAgentsException 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 reactive example configuration files to.",
|
||||
)
|
||||
def generate_examples(output: Path) -> None:
|
||||
"""
|
||||
Generate reactive example configuration files.
|
||||
|
||||
This command creates example configuration files demonstrating
|
||||
various RxPy stream processing patterns and agent orchestration.
|
||||
"""
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Basic reactive example
|
||||
basic_config = """# Basic Reactive CleverAgents Configuration
|
||||
# This example shows a simple stream processing pipeline
|
||||
|
||||
agents:
|
||||
llm_agent:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
temperature: 0.7
|
||||
|
||||
streams:
|
||||
# Input processing stream
|
||||
input_processor:
|
||||
type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: llm_agent
|
||||
- type: filter
|
||||
params:
|
||||
condition:
|
||||
type: content_contains
|
||||
text: "response"
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Error handling stream
|
||||
error_handler:
|
||||
type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: format
|
||||
template: "Error processed: {content}"
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Connect input to processing
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: input_processor
|
||||
|
||||
# Split processing for error handling
|
||||
splits:
|
||||
- source: input_processor
|
||||
targets:
|
||||
error_stream:
|
||||
type: metadata_has
|
||||
key: error
|
||||
"""
|
||||
|
||||
with open(output / "basic_reactive.yaml", "w", encoding="utf-8") as f:
|
||||
f.write(basic_config)
|
||||
|
||||
# Advanced streaming example
|
||||
advanced_config = """# Advanced Reactive Stream Processing
|
||||
# This example demonstrates complex RxPy patterns
|
||||
|
||||
agents:
|
||||
classifier:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
system_prompt: "Classify the input as: question, command, or statement"
|
||||
|
||||
question_handler:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
system_prompt: "Answer questions concisely and accurately"
|
||||
|
||||
command_handler:
|
||||
type: tool
|
||||
config:
|
||||
tools: ["execute", "search"]
|
||||
|
||||
statement_processor:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
system_prompt: "Acknowledge and provide relevant commentary"
|
||||
|
||||
streams:
|
||||
# Classification stream
|
||||
classifier_stream:
|
||||
type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: classifier
|
||||
- type: debounce
|
||||
params:
|
||||
duration: 0.5
|
||||
publications:
|
||||
- router_stream
|
||||
|
||||
# Router stream for distribution
|
||||
router_stream:
|
||||
type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: extract
|
||||
field: classification
|
||||
|
||||
# Question processing pipeline
|
||||
question_pipeline:
|
||||
type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: question_handler
|
||||
- type: buffer
|
||||
params:
|
||||
count: 1
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: format
|
||||
template: "Q&A: {content}"
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Command processing pipeline
|
||||
command_pipeline:
|
||||
type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: command_handler
|
||||
- type: catch
|
||||
- type: retry
|
||||
params:
|
||||
count: 2
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Statement processing pipeline
|
||||
statement_pipeline:
|
||||
type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: statement_processor
|
||||
- type: throttle
|
||||
params:
|
||||
duration: 1.0
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Aggregation stream
|
||||
aggregator:
|
||||
type: hot
|
||||
operators:
|
||||
- type: scan
|
||||
params:
|
||||
accumulator:
|
||||
type: collect
|
||||
- type: sample
|
||||
params:
|
||||
interval: 5.0
|
||||
|
||||
# Stream flow setup
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: classifier_stream
|
||||
|
||||
splits:
|
||||
- source: router_stream
|
||||
targets:
|
||||
question_pipeline:
|
||||
type: content_contains
|
||||
text: "question"
|
||||
command_pipeline:
|
||||
type: content_contains
|
||||
text: "command"
|
||||
statement_pipeline:
|
||||
type: content_contains
|
||||
text: "statement"
|
||||
|
||||
# Global configuration
|
||||
context:
|
||||
global:
|
||||
max_retries: 3
|
||||
timeout: 30
|
||||
"""
|
||||
|
||||
with open(output / "advanced_reactive.yaml", "w", encoding="utf-8") as f:
|
||||
f.write(advanced_config)
|
||||
|
||||
# Multi-agent collaboration example
|
||||
collaboration_config = """# Multi-Agent Reactive Collaboration
|
||||
# Demonstrates agent-to-agent communication via streams
|
||||
|
||||
agents:
|
||||
researcher:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
system_prompt: "Research and gather information on topics"
|
||||
|
||||
analyzer:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
system_prompt: "Analyze and synthesize research findings"
|
||||
|
||||
writer:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-4
|
||||
system_prompt: "Write comprehensive reports based on analysis"
|
||||
|
||||
editor:
|
||||
type: llm
|
||||
config:
|
||||
provider: openai
|
||||
model: gpt-3.5-turbo
|
||||
system_prompt: "Edit and improve written content"
|
||||
|
||||
streams:
|
||||
# Research phase
|
||||
research_stream:
|
||||
type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: researcher
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: wrap
|
||||
wrapper:
|
||||
phase: "research"
|
||||
timestamp: "{{now}}"
|
||||
publications:
|
||||
- analysis_stream
|
||||
|
||||
# Analysis phase
|
||||
analysis_stream:
|
||||
type: cold
|
||||
operators:
|
||||
- type: buffer
|
||||
params:
|
||||
time: 2.0
|
||||
- type: map
|
||||
params:
|
||||
agent: analyzer
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: wrap
|
||||
wrapper:
|
||||
phase: "analysis"
|
||||
publications:
|
||||
- writing_stream
|
||||
|
||||
# Writing phase
|
||||
writing_stream:
|
||||
type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: writer
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: wrap
|
||||
wrapper:
|
||||
phase: "writing"
|
||||
publications:
|
||||
- editing_stream
|
||||
|
||||
# Editing phase
|
||||
editing_stream:
|
||||
type: cold
|
||||
operators:
|
||||
- type: map
|
||||
params:
|
||||
agent: editor
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: wrap
|
||||
wrapper:
|
||||
phase: "final"
|
||||
completed: true
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Quality control stream (parallel processing)
|
||||
quality_control:
|
||||
type: cold
|
||||
operators:
|
||||
- type: filter
|
||||
params:
|
||||
condition:
|
||||
type: metadata_has
|
||||
key: quality_check
|
||||
- type: map
|
||||
params:
|
||||
transform:
|
||||
type: format
|
||||
template: "QC: {content}"
|
||||
publications:
|
||||
- __output__
|
||||
|
||||
# Pipeline setup
|
||||
merges:
|
||||
- sources: [__input__]
|
||||
target: research_stream
|
||||
|
||||
# Parallel quality control
|
||||
splits:
|
||||
- source: writing_stream
|
||||
targets:
|
||||
quality_control:
|
||||
type: metadata_has
|
||||
key: enable_qc
|
||||
|
||||
prompts: {}
|
||||
"""
|
||||
|
||||
with open(output / "collaboration_reactive.yaml", "w", encoding="utf-8") as f:
|
||||
f.write(collaboration_config)
|
||||
|
||||
click.echo(f"Generated reactive example configurations in {output}/")
|
||||
click.echo("Examples created:")
|
||||
click.echo(" - basic_reactive.yaml: Simple stream processing")
|
||||
click.echo(" - advanced_reactive.yaml: Complex RxPy patterns")
|
||||
click.echo(" - collaboration_reactive.yaml: Multi-agent collaboration")
|
||||
|
||||
|
||||
@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 reactive 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 stream diagram to.",
|
||||
)
|
||||
@click.option(
|
||||
"--format",
|
||||
"-f",
|
||||
"output_format",
|
||||
type=click.Choice(["dot", "mermaid", "ascii"]),
|
||||
default="mermaid",
|
||||
help="Output format for the stream diagram.",
|
||||
)
|
||||
def visualize(config: List[Path], output: Optional[Path], output_format: str) -> None:
|
||||
"""
|
||||
Visualize the reactive stream network.
|
||||
|
||||
This command generates a diagram showing how streams, agents,
|
||||
and operators are connected in the reactive network.
|
||||
"""
|
||||
try:
|
||||
app = ReactiveCleverAgentsApp(config, verbose=False, unsafe=False)
|
||||
|
||||
if not app.config:
|
||||
raise CleverAgentsException("No configuration loaded")
|
||||
|
||||
# Generate visualization based on format
|
||||
if output_format == "mermaid":
|
||||
diagram = _generate_mermaid_diagram(app.config)
|
||||
elif output_format == "dot":
|
||||
diagram = _generate_dot_diagram(app.config)
|
||||
else: # ascii
|
||||
diagram = _generate_ascii_diagram(app.config)
|
||||
|
||||
if output:
|
||||
with open(output, "w", encoding="utf-8") as f:
|
||||
f.write(diagram)
|
||||
click.echo(f"Stream diagram written to {output}")
|
||||
else:
|
||||
click.echo(diagram)
|
||||
|
||||
except CleverAgentsException as e:
|
||||
click.echo(f"Error: {e}", err=True)
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def _normalize_targets(targets_data: Any) -> List[str]:
|
||||
"""
|
||||
Normalize targets data from various formats into a list of strings.
|
||||
|
||||
Args:
|
||||
targets_data: The targets data in various formats (dict, str, list, etc.)
|
||||
|
||||
Returns:
|
||||
List of target names as strings.
|
||||
"""
|
||||
# Handle both dict and string formats for targets
|
||||
if isinstance(targets_data, dict):
|
||||
return list(targets_data.keys())
|
||||
if isinstance(targets_data, str):
|
||||
return [targets_data]
|
||||
if isinstance(targets_data, list):
|
||||
return targets_data
|
||||
return []
|
||||
|
||||
|
||||
def _generate_mermaid_diagram(config: ReactiveConfig) -> str:
|
||||
"""Generate a Mermaid diagram of the stream network."""
|
||||
lines = ["graph TD"]
|
||||
|
||||
# Add agents
|
||||
for agent_name in config.agents:
|
||||
lines.append(f" {agent_name}[{agent_name}]")
|
||||
|
||||
# Add routes
|
||||
for route_name, route_config in config.routes.items():
|
||||
if route_config.type.value == "stream":
|
||||
stream_type_value = (
|
||||
route_config.stream_type.value if route_config.stream_type else "cold"
|
||||
)
|
||||
shape = "(" if stream_type_value == "hot" else "["
|
||||
end_shape = ")" if stream_type_value == "hot" else "]"
|
||||
lines.append(f" {route_name}{shape}{route_name}{end_shape}")
|
||||
|
||||
# Add agent connections
|
||||
for agent_name in route_config.agents:
|
||||
lines.append(f" {agent_name} --> {route_name}")
|
||||
elif route_config.type.value == "graph":
|
||||
lines.append(f" {route_name}[<{route_name}>]")
|
||||
|
||||
# Add merges
|
||||
for merge in config.merges:
|
||||
target = merge.get("target")
|
||||
for source in merge.get("sources", []):
|
||||
lines.append(f" {source} --> {target}")
|
||||
|
||||
# Add splits
|
||||
for split in config.splits:
|
||||
source = split.get("source")
|
||||
targets_data = split.get("targets", {})
|
||||
targets = _normalize_targets(targets_data)
|
||||
|
||||
for target in targets:
|
||||
lines.append(f" {source} --> {target}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_dot_diagram(config: ReactiveConfig) -> str:
|
||||
"""Generate a Graphviz DOT diagram of the stream network."""
|
||||
lines = ["digraph StreamNetwork {", " rankdir=LR;"]
|
||||
|
||||
# Add agents
|
||||
for agent_name in config.agents:
|
||||
lines.append(f" {agent_name} [shape=box, color=blue];")
|
||||
|
||||
# Add routes
|
||||
for route_name, route_config in config.routes.items():
|
||||
if route_config.type.value == "stream":
|
||||
stream_type_value = (
|
||||
route_config.stream_type.value if route_config.stream_type else "cold"
|
||||
)
|
||||
shape = "ellipse" if stream_type_value == "hot" else "box"
|
||||
lines.append(f" {route_name} [shape={shape}, color=green];")
|
||||
elif route_config.type.value == "graph":
|
||||
lines.append(f" {route_name} [shape=hexagon, color=purple];")
|
||||
|
||||
# Add agent connections for stream routes
|
||||
if route_config.type.value == "stream" and route_config.agents:
|
||||
for agent_name in route_config.agents:
|
||||
lines.append(f" {agent_name} -> {route_name};")
|
||||
|
||||
# Add merges and splits
|
||||
for merge in config.merges:
|
||||
target = merge.get("target")
|
||||
for source in merge.get("sources", []):
|
||||
lines.append(f" {source} -> {target};")
|
||||
|
||||
for split in config.splits:
|
||||
source = split.get("source")
|
||||
targets_data = split.get("targets", {})
|
||||
targets = _normalize_targets(targets_data)
|
||||
|
||||
for target in targets:
|
||||
lines.append(f" {source} -> {target};")
|
||||
|
||||
lines.append("}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_ascii_diagram(config: ReactiveConfig) -> str:
|
||||
"""Generate a simple ASCII diagram of the stream network."""
|
||||
lines = ["Reactive Stream Network", "=" * 25, ""]
|
||||
|
||||
lines.append("Agents:")
|
||||
for agent_name, agent_config in config.agents.items():
|
||||
lines.append(f" [{agent_config.type}] {agent_name}")
|
||||
|
||||
lines.append("\nRoutes:")
|
||||
for route_name, route_config in config.routes.items():
|
||||
if route_config.type.value == "stream":
|
||||
stream_type_value = (
|
||||
route_config.stream_type.value if route_config.stream_type else "cold"
|
||||
)
|
||||
lines.append(f" [stream] ({stream_type_value}) {route_name}")
|
||||
if route_config.agents:
|
||||
lines.append(f" <- Agents: {', '.join(route_config.agents)}")
|
||||
if route_config.operators:
|
||||
lines.append(f" <- Operators: {len(route_config.operators)}")
|
||||
elif route_config.type.value == "graph":
|
||||
lines.append(f" [graph] {route_name}")
|
||||
if route_config.nodes:
|
||||
lines.append(f" <- Nodes: {len(route_config.nodes)}")
|
||||
if route_config.edges:
|
||||
lines.append(f" <- Edges: {len(route_config.edges)}")
|
||||
|
||||
if config.merges:
|
||||
lines.append("\nMerges:")
|
||||
for merge in config.merges:
|
||||
sources = merge.get("sources", [])
|
||||
target = merge.get("target")
|
||||
lines.append(f" {' + '.join(sources)} -> {target}")
|
||||
|
||||
if config.splits:
|
||||
lines.append("\nSplits:")
|
||||
for split in config.splits:
|
||||
source = split.get("source")
|
||||
targets_data = split.get("targets", {})
|
||||
targets = _normalize_targets(targets_data)
|
||||
|
||||
lines.append(f" {source} -> {' | '.join(targets)}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _validate_config_files(config_files: List[Path]) -> None:
|
||||
"""Validate configuration files for obvious issues.
|
||||
|
||||
Args:
|
||||
config_files: List of configuration file paths.
|
||||
|
||||
Raises:
|
||||
CleverAgentsException: If configuration files are invalid.
|
||||
"""
|
||||
if not config_files:
|
||||
raise CleverAgentsException("No configuration files provided")
|
||||
|
||||
for config_file in config_files:
|
||||
if not config_file.exists():
|
||||
raise FileNotFoundError(str(config_file))
|
||||
|
||||
# Check for obviously empty or problematic files
|
||||
try:
|
||||
stat = config_file.stat()
|
||||
if stat.st_size == 0:
|
||||
raise CleverAgentsException(
|
||||
f"Configuration file '{config_file}' is empty. "
|
||||
"Please provide a valid YAML configuration file."
|
||||
)
|
||||
|
||||
# Special case for /dev/null and similar
|
||||
if str(config_file) in ["/dev/null", "/dev/zero"] or config_file.name in [
|
||||
"null",
|
||||
"NUL",
|
||||
]:
|
||||
raise CleverAgentsException(
|
||||
f"Configuration file '{config_file}' is not a valid configuration file. "
|
||||
"Please provide a valid YAML configuration file."
|
||||
)
|
||||
|
||||
# Check if file is readable and has basic content
|
||||
with open(config_file, "r", encoding="utf-8") as f:
|
||||
content = f.read().strip()
|
||||
if not content:
|
||||
raise CleverAgentsException(
|
||||
f"Configuration file '{config_file}' is empty or contains only whitespace. "
|
||||
"Please provide a valid YAML configuration file."
|
||||
)
|
||||
|
||||
except (OSError, IOError) as e:
|
||||
raise CleverAgentsException(
|
||||
f"Cannot read configuration file '{config_file}': {e}"
|
||||
) from e
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1 @@
|
||||
"""Core functionality for CleverAgents."""
|
||||
@@ -0,0 +1,987 @@
|
||||
"""
|
||||
Reactive application module for CleverAgents.
|
||||
|
||||
This module contains the main application class that orchestrates reactive
|
||||
agent networks using RxPy streams for complex message routing and processing.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Type
|
||||
from typing import Union
|
||||
|
||||
from rx.core import Observer # type: ignore[attr-defined]
|
||||
from rx.scheduler.eventloop import AsyncIOScheduler # type: ignore[attr-defined]
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.agents.factory import AgentFactory
|
||||
from cleveragents.agents.llm import LLMAgent
|
||||
from cleveragents.agents.tool import ToolAgent
|
||||
from cleveragents.core.exceptions import AgentCreationError
|
||||
from cleveragents.core.exceptions import CleverAgentsException
|
||||
from cleveragents.core.exceptions import UnsafeConfigurationError
|
||||
from cleveragents.langgraph.bridge import RxPyLangGraphBridge
|
||||
from cleveragents.langgraph.graph import LangGraph
|
||||
from cleveragents.reactive.config_parser import ReactiveConfig
|
||||
from cleveragents.reactive.config_parser import ReactiveConfigParser
|
||||
from cleveragents.reactive.route import RouteType
|
||||
from cleveragents.reactive.route_bridge import RouteBridge
|
||||
from cleveragents.reactive.stream_router import ReactiveStreamRouter
|
||||
from cleveragents.reactive.stream_router import StreamMessage
|
||||
from cleveragents.reactive.stream_router import StreamType
|
||||
from cleveragents.templates.base import TemplateType
|
||||
from cleveragents.templates.enhanced_registry import EnhancedTemplateRegistry
|
||||
from cleveragents.templates.registry import TemplateRegistry
|
||||
from cleveragents.templates.renderer import TemplateEngine
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class ReactiveCleverAgentsApp: # pylint: disable=too-many-instance-attributes
|
||||
"""
|
||||
Reactive application class for CleverAgents.
|
||||
|
||||
This class serves as the central orchestrator for reactive agent networks,
|
||||
managing RxPy streams, agent creation, and complex message routing patterns.
|
||||
It provides full reactive programming capabilities with stream processing.
|
||||
|
||||
Attributes:
|
||||
stream_router (ReactiveStreamRouter): Manages reactive streams and routing.
|
||||
agent_factory (ReactiveAgentFactory): Creates agent instances based on configuration.
|
||||
config_parser (ReactiveConfigParser): Parses reactive configuration files.
|
||||
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 reactive CleverAgents application.
|
||||
|
||||
Args:
|
||||
config_files: List of paths to configuration files.
|
||||
verbose: Whether to enable verbose logging.
|
||||
unsafe: Whether to enable unsafe mode for code execution.
|
||||
|
||||
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
|
||||
self.verbose = verbose
|
||||
|
||||
# Initialize reactive components
|
||||
try:
|
||||
self.loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop, create a new one
|
||||
self.loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(self.loop)
|
||||
self.scheduler = AsyncIOScheduler(self.loop)
|
||||
self.stream_router = ReactiveStreamRouter(self.scheduler)
|
||||
self.config_parser = ReactiveConfigParser()
|
||||
self.agent_factory: Optional[AgentFactory] = None
|
||||
|
||||
# Initialize LangGraph bridge
|
||||
self.langgraph_bridge = RxPyLangGraphBridge(self.stream_router)
|
||||
# Connect bridge to stream router
|
||||
self.stream_router._langgraph_bridge = self.langgraph_bridge
|
||||
|
||||
# Initialize route bridge for dynamic type conversion
|
||||
self.route_bridge: Optional[RouteBridge] = None
|
||||
|
||||
# Initialize template registry
|
||||
# Check if we need enhanced registry for complex templates
|
||||
self.template_registry: Optional[
|
||||
Union[TemplateRegistry, EnhancedTemplateRegistry]
|
||||
] = None
|
||||
self._use_enhanced_registry = False
|
||||
|
||||
# Configuration storage
|
||||
self.config: Optional[ReactiveConfig] = None
|
||||
self.agents: dict[str, Agent] = {}
|
||||
self.template_renderer: Optional[TemplateRenderer] = None
|
||||
|
||||
# Result tracking
|
||||
self.results: List[Any] = []
|
||||
self.errors: List[Exception] = []
|
||||
|
||||
# Load configuration if provided
|
||||
if config_files:
|
||||
self.load_configuration(config_files)
|
||||
# Check unsafe flag after loading configuration
|
||||
self._enforce_unsafe_flag()
|
||||
|
||||
def _enforce_unsafe_flag(self) -> None:
|
||||
"""
|
||||
Abort execution if unsafe mode is required but not enabled.
|
||||
"""
|
||||
if (
|
||||
self.config
|
||||
and self.config.global_context.get("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 reactive configuration from files.
|
||||
|
||||
Args:
|
||||
config_files: List of paths to configuration files.
|
||||
|
||||
Raises:
|
||||
CleverAgentsException: If configuration loading or validation fails.
|
||||
"""
|
||||
try:
|
||||
# Parse reactive configuration
|
||||
self.logger.info(
|
||||
"Loading reactive configuration from %d files", len(config_files)
|
||||
)
|
||||
self.config = self.config_parser.parse_files(config_files)
|
||||
|
||||
# Set up template engine
|
||||
template_engine = TemplateEngine[self.config.template_engine.upper()]
|
||||
self.template_renderer = TemplateRenderer(template_engine)
|
||||
|
||||
# Register templates
|
||||
for name, template_def in self.config.prompts.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
|
||||
self.template_renderer.register_template(name, template_content)
|
||||
|
||||
# Register templates
|
||||
self._register_templates()
|
||||
|
||||
# Initialize agent factory with reactive config
|
||||
config_dict = self._config_to_dict()
|
||||
self.agent_factory = AgentFactory(
|
||||
config_dict,
|
||||
self.template_renderer,
|
||||
stream_router=self.stream_router,
|
||||
langgraph_bridge=self.langgraph_bridge,
|
||||
)
|
||||
|
||||
# Create agents (including from templates)
|
||||
self._create_agents()
|
||||
|
||||
# Set up routes (unified streams and graphs)
|
||||
self._setup_routes()
|
||||
|
||||
# Set up stream operations
|
||||
self._setup_stream_operations()
|
||||
|
||||
# Set up hybrid pipelines
|
||||
self._setup_pipelines()
|
||||
|
||||
self.logger.info("Reactive configuration loaded successfully")
|
||||
except Exception as e:
|
||||
if isinstance(e, CleverAgentsException):
|
||||
raise
|
||||
raise CleverAgentsException(f"Failed to load configuration: {str(e)}") from e
|
||||
|
||||
async def run_single_shot(self, prompt: str, **kwargs: Any) -> str:
|
||||
"""
|
||||
Run the reactive agent network in single-shot mode.
|
||||
|
||||
Args:
|
||||
prompt: The initial prompt to send to the agent network.
|
||||
**kwargs: Additional metadata for the message.
|
||||
|
||||
Returns:
|
||||
The final output from the agent network.
|
||||
|
||||
Raises:
|
||||
CleverAgentsException: If the agent network is not configured or execution fails.
|
||||
"""
|
||||
self._enforce_unsafe_flag()
|
||||
|
||||
if not self.config:
|
||||
raise CleverAgentsException("Configuration not loaded")
|
||||
|
||||
try:
|
||||
self.logger.info("Running reactive network in single-shot mode")
|
||||
|
||||
# Set up result collector
|
||||
result_future: asyncio.Future[str] = asyncio.Future()
|
||||
|
||||
def on_output(msg: StreamMessage) -> None:
|
||||
if not result_future.done():
|
||||
# Handle None message or missing content
|
||||
if msg is None:
|
||||
result_future.set_result("")
|
||||
elif hasattr(msg, "content") and msg.content is not None:
|
||||
content_str = str(msg.content)
|
||||
# Process tool commands if present
|
||||
processed_content = self._process_tool_commands(content_str)
|
||||
result_future.set_result(processed_content)
|
||||
else:
|
||||
result_future.set_result("")
|
||||
|
||||
def on_error(error: Exception) -> None:
|
||||
if not result_future.done():
|
||||
result_future.set_exception(error)
|
||||
|
||||
# Subscribe to output stream
|
||||
output_observer = Observer(
|
||||
on_next=on_output, on_error=on_error, on_completed=lambda: None
|
||||
)
|
||||
self.stream_router.subscribe_to_output(output_observer)
|
||||
|
||||
# Send input message
|
||||
metadata = {**kwargs, "context": self.config.global_context}
|
||||
metadata["_unsafe_mode"] = self.unsafe
|
||||
|
||||
self.stream_router.send_message("__input__", prompt, metadata)
|
||||
|
||||
# Wait for result with timeout
|
||||
try:
|
||||
result = await asyncio.wait_for(
|
||||
result_future, timeout=30.0
|
||||
) # 30 second timeout for single-shot mode
|
||||
self.logger.info("Single-shot processing complete")
|
||||
return str(result)
|
||||
except asyncio.TimeoutError as timeout_err:
|
||||
raise CleverAgentsException(
|
||||
"Processing timed out after 30 seconds"
|
||||
) from timeout_err
|
||||
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
if isinstance(e, CleverAgentsException):
|
||||
raise
|
||||
raise CleverAgentsException(
|
||||
f"Failed to run in single-shot mode: {str(e)}"
|
||||
) from e
|
||||
|
||||
async def start_interactive_session( # pylint: disable=too-many-statements
|
||||
self,
|
||||
history_file: Optional[Path] = None, # pylint: disable=unused-argument
|
||||
) -> None:
|
||||
"""
|
||||
Start an interactive session with the reactive agent network.
|
||||
|
||||
Args:
|
||||
history_file: Optional path to a file to load/save conversation history.
|
||||
(Not yet implemented - reserved for future use)
|
||||
|
||||
Raises:
|
||||
CleverAgentsException: If the agent network is not configured.
|
||||
"""
|
||||
self._enforce_unsafe_flag()
|
||||
|
||||
if not self.config:
|
||||
raise CleverAgentsException("Configuration not loaded")
|
||||
|
||||
try:
|
||||
self.logger.info("Starting reactive interactive session")
|
||||
|
||||
# Create a variable to track completion of each message
|
||||
completion_future: Optional[asyncio.Future[bool]] = None
|
||||
|
||||
# Set up observers for output and errors
|
||||
def on_output(msg: StreamMessage) -> None:
|
||||
nonlocal completion_future # Access the outer variable
|
||||
content_str = str(msg.content)
|
||||
|
||||
if not content_str or content_str.strip() == "":
|
||||
self.logger.debug("Empty output received")
|
||||
else:
|
||||
# Check for tool execution commands
|
||||
processed_content = self._process_tool_commands(content_str)
|
||||
print(f"\n{processed_content}\n")
|
||||
|
||||
# Signal completion
|
||||
if completion_future and not completion_future.done():
|
||||
completion_future.set_result(True)
|
||||
|
||||
def on_error(msg: StreamMessage) -> None:
|
||||
self.logger.error("%s", msg.content)
|
||||
|
||||
output_observer = Observer(
|
||||
on_next=on_output, on_error=lambda e: self.logger.error("Error: %s", e)
|
||||
)
|
||||
error_observer = Observer(
|
||||
on_next=on_error, on_error=lambda e: self.logger.error("Error: %s", e)
|
||||
)
|
||||
|
||||
self.stream_router.subscribe_to_output(output_observer)
|
||||
self.stream_router.observables["__error__"].subscribe(error_observer)
|
||||
|
||||
print("\nReactive CleverAgents Interactive Session")
|
||||
print("Type 'exit' to quit, 'help' for commands\n")
|
||||
|
||||
while True:
|
||||
try:
|
||||
user_input = input(">>> ").strip()
|
||||
|
||||
if user_input.lower() == "exit":
|
||||
break
|
||||
if user_input.lower() == "help":
|
||||
self._print_help()
|
||||
continue
|
||||
if user_input.startswith("/stream "):
|
||||
self._handle_stream_command(user_input[8:])
|
||||
continue
|
||||
if user_input.startswith("/graph "):
|
||||
await self._handle_graph_command(user_input[7:])
|
||||
continue
|
||||
if not user_input:
|
||||
continue
|
||||
|
||||
# Send user input to the stream network
|
||||
metadata: dict[str, Any] = {"context": self.config.global_context}
|
||||
metadata["_unsafe_mode"] = self.unsafe
|
||||
|
||||
# Create a new future for this message
|
||||
completion_future = asyncio.Future()
|
||||
|
||||
self.stream_router.send_message("__input__", user_input, metadata)
|
||||
|
||||
# Wait for actual completion (not a guess!)
|
||||
try:
|
||||
await asyncio.wait_for(completion_future, timeout=30.0) # Safety timeout
|
||||
except asyncio.TimeoutError:
|
||||
self.logger.warning("Stream processing timeout")
|
||||
|
||||
except KeyboardInterrupt:
|
||||
print("\nUse 'exit' to quit.")
|
||||
continue
|
||||
except EOFError:
|
||||
break
|
||||
|
||||
print("\nGoodbye!")
|
||||
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
if isinstance(e, CleverAgentsException):
|
||||
raise
|
||||
raise CleverAgentsException(
|
||||
f"Failed to start interactive session: {str(e)}"
|
||||
) from e
|
||||
|
||||
def _config_to_dict(self) -> dict[str, Any]:
|
||||
"""Convert reactive config to dictionary format for agent factory."""
|
||||
if not self.config:
|
||||
return {}
|
||||
|
||||
agents_dict = {}
|
||||
for name, agent_config in self.config.agents.items():
|
||||
agents_dict[name] = {
|
||||
"type": agent_config.type,
|
||||
"config": agent_config.config,
|
||||
}
|
||||
|
||||
return {
|
||||
"agents": agents_dict,
|
||||
"context": {"global": self.config.global_context},
|
||||
"prompts": self.config.prompts,
|
||||
}
|
||||
|
||||
def _register_templates(self) -> None: # pylint: disable=too-many-branches
|
||||
"""Register all templates from configuration."""
|
||||
if not self.config:
|
||||
return
|
||||
|
||||
# Check if we have any templates that need preprocessing
|
||||
needs_enhanced = False
|
||||
if self.config.templates:
|
||||
for template_type, templates in self.config.templates.items():
|
||||
# Skip if templates is not a dictionary (e.g., string templates)
|
||||
if not isinstance(templates, dict):
|
||||
continue
|
||||
for name, template_def in templates.items():
|
||||
if isinstance(template_def, dict) and (
|
||||
template_def.get("_needs_preprocessing")
|
||||
or template_def.get("__jinja_template__")
|
||||
or any(
|
||||
isinstance(v, dict) and v.get("__is_template__")
|
||||
for v in template_def.values()
|
||||
)
|
||||
):
|
||||
needs_enhanced = True
|
||||
break
|
||||
if needs_enhanced:
|
||||
break
|
||||
|
||||
# Initialize appropriate registry
|
||||
if needs_enhanced:
|
||||
self.template_registry = EnhancedTemplateRegistry()
|
||||
self._use_enhanced_registry = True
|
||||
self.logger.info("Using enhanced template registry for complex templates")
|
||||
else:
|
||||
self.template_registry = TemplateRegistry()
|
||||
|
||||
# Register all templates
|
||||
if self.config.templates:
|
||||
if self._use_enhanced_registry and isinstance(
|
||||
self.template_registry, EnhancedTemplateRegistry
|
||||
):
|
||||
# Process raw templates
|
||||
for template_type, templates in self.config.templates.items():
|
||||
# Skip if templates is not a dictionary (e.g., string templates)
|
||||
if not isinstance(templates, dict):
|
||||
continue
|
||||
for name, template_def in templates.items():
|
||||
if isinstance(template_def, dict) and template_def.get(
|
||||
"_needs_preprocessing"
|
||||
):
|
||||
# Raw template string
|
||||
t_type = (
|
||||
TemplateType.AGENT
|
||||
if template_type == "agents"
|
||||
else (
|
||||
TemplateType.GRAPH
|
||||
if template_type == "graphs"
|
||||
else TemplateType.STREAM
|
||||
)
|
||||
)
|
||||
self.template_registry.register_template_string(
|
||||
t_type, name, template_def["_raw_template"]
|
||||
)
|
||||
else:
|
||||
# Regular template
|
||||
self.template_registry.register_template_dict(
|
||||
t_type, name, template_def
|
||||
)
|
||||
else:
|
||||
# Use regular registration
|
||||
# Type guard to satisfy mypy --strict
|
||||
if self.template_registry is None:
|
||||
raise CleverAgentsException("Template registry not initialized")
|
||||
# Both TemplateRegistry and EnhancedTemplateRegistry have register_all_templates
|
||||
# Use isinstance to help mypy understand the type
|
||||
if isinstance(self.template_registry, (TemplateRegistry, EnhancedTemplateRegistry)):
|
||||
self.template_registry.register_all_templates(self.config.templates)
|
||||
|
||||
templates = self.config.templates or {}
|
||||
self.logger.info(
|
||||
"Registered %d agent templates, %d graph templates, %d stream templates",
|
||||
len(templates.get('agents', {})),
|
||||
len(templates.get('graphs', {})),
|
||||
len(templates.get('streams', {}))
|
||||
)
|
||||
|
||||
def _create_agents(self) -> None:
|
||||
"""Create all configured agents."""
|
||||
if not self.agent_factory or not self.config:
|
||||
raise AgentCreationError("Agent factory or configuration not initialized")
|
||||
|
||||
# Register built-in agent types
|
||||
registered_types = self.agent_factory.get_agent_types()
|
||||
type_map: dict[str, Type[Agent]] = {
|
||||
"llm": LLMAgent,
|
||||
"tool": ToolAgent,
|
||||
}
|
||||
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)
|
||||
|
||||
# Create agents
|
||||
for agent_name, agent_config in self.config.agents.items():
|
||||
if agent_config.type == "template_instance":
|
||||
# Create from template
|
||||
instance_config = agent_config.config
|
||||
|
||||
if self._use_enhanced_registry:
|
||||
# Use enhanced registry for complex templates
|
||||
template_name = instance_config.get(
|
||||
"template"
|
||||
) or instance_config.get("agent_template")
|
||||
params = instance_config.get("params", {})
|
||||
|
||||
if (
|
||||
isinstance(self.template_registry, EnhancedTemplateRegistry)
|
||||
and template_name
|
||||
):
|
||||
agent_def = self.template_registry.instantiate(
|
||||
TemplateType.AGENT, template_name, params
|
||||
)
|
||||
else:
|
||||
# Fallback for regular registry
|
||||
agent_def = {"template": template_name, "params": params}
|
||||
else:
|
||||
# Use regular instantiation
|
||||
if self.template_registry and hasattr(
|
||||
self.template_registry, "instantiate_from_config"
|
||||
):
|
||||
agent_def = self.template_registry.instantiate_from_config(
|
||||
instance_config
|
||||
)
|
||||
else:
|
||||
agent_def = instance_config
|
||||
|
||||
# Update agent factory config with template instance
|
||||
self.agent_factory.config["agents"][agent_name] = {
|
||||
"type": agent_def.get("type", "llm"),
|
||||
"config": agent_def.get("config", {}),
|
||||
}
|
||||
|
||||
# Create agent (either from template or direct)
|
||||
agent = self.agent_factory.create_agent(agent_name)
|
||||
self.agents[agent_name] = agent
|
||||
self.stream_router.register_agent(agent_name, agent)
|
||||
self.logger.debug("Created agent: %s", agent_name)
|
||||
|
||||
def _setup_routes(self) -> None: # pylint: disable=too-many-branches
|
||||
"""Set up all configured routes (unified streams and graphs)."""
|
||||
if not self.config or not self.config.routes:
|
||||
return
|
||||
|
||||
# Initialize route bridge if not already done
|
||||
if not self.route_bridge:
|
||||
self.route_bridge = RouteBridge(
|
||||
self.stream_router,
|
||||
self.agents,
|
||||
self.scheduler,
|
||||
)
|
||||
|
||||
# Process each route based on its type
|
||||
for route_name, route_config in self.config.routes.items():
|
||||
# Check if this is a template instance
|
||||
if (
|
||||
hasattr(route_config, "template_config")
|
||||
and route_config.template_config
|
||||
):
|
||||
# Instantiate from template
|
||||
template_config = route_config.template_config
|
||||
if self.template_registry and hasattr(
|
||||
self.template_registry, "instantiate_from_config"
|
||||
):
|
||||
route_def = self.template_registry.instantiate_from_config(
|
||||
template_config
|
||||
)
|
||||
else:
|
||||
route_def = template_config
|
||||
|
||||
# Update route config from template
|
||||
route_type_str = route_def.get("type", "stream")
|
||||
route_config.type = RouteType(route_type_str)
|
||||
|
||||
if route_config.type == RouteType.STREAM:
|
||||
# Update stream-specific fields
|
||||
route_config.stream_type = StreamType(
|
||||
route_def.get("stream_type", "cold")
|
||||
)
|
||||
route_config.operators = route_def.get("operators", [])
|
||||
route_config.subscriptions = route_def.get("subscriptions", [])
|
||||
route_config.publications = route_def.get("publications", [])
|
||||
route_config.agents = route_def.get("agents", [])
|
||||
elif route_config.type == RouteType.GRAPH:
|
||||
# Update graph-specific fields
|
||||
route_config.nodes = route_def.get("nodes", {})
|
||||
route_config.edges = route_def.get("edges", [])
|
||||
route_config.entry_point = route_def.get("entry_point", "start")
|
||||
route_config.checkpointing = route_def.get("checkpointing", False)
|
||||
|
||||
# Create the route based on type
|
||||
if route_config.type == RouteType.STREAM:
|
||||
# Create as stream
|
||||
stream_config = route_config.to_stream_config()
|
||||
self.stream_router.create_stream(stream_config)
|
||||
self.logger.debug("Created stream route: %s", route_name)
|
||||
|
||||
elif route_config.type == RouteType.GRAPH:
|
||||
# Create as graph
|
||||
graph_config = route_config.to_graph_config()
|
||||
|
||||
# Resolve state class if specified
|
||||
if route_config.state_class:
|
||||
try:
|
||||
module_path, class_name = route_config.state_class.rsplit(
|
||||
".", 1
|
||||
)
|
||||
module = __import__(module_path, fromlist=[class_name])
|
||||
graph_config.state_class = getattr(module, class_name)
|
||||
except (ValueError, ImportError, AttributeError) as e:
|
||||
self.logger.warning(
|
||||
"Failed to load state class '%s': %s",
|
||||
route_config.state_class, e
|
||||
)
|
||||
|
||||
# Create the graph
|
||||
graph = LangGraph(
|
||||
config=graph_config,
|
||||
agents=self.agents,
|
||||
stream_router=self.stream_router,
|
||||
scheduler=self.scheduler,
|
||||
)
|
||||
|
||||
# Store graph in bridge
|
||||
self.langgraph_bridge.graphs[graph.name] = graph
|
||||
self.logger.debug("Created graph route: %s", route_name)
|
||||
|
||||
elif route_config.type == RouteType.BRIDGE:
|
||||
# Bridge routes are special - they don't create anything immediately
|
||||
self.logger.debug("Registered bridge route: %s", route_name)
|
||||
|
||||
# Removed _setup_streams - use routes instead
|
||||
|
||||
def _setup_stream_operations(self) -> None:
|
||||
"""Set up stream merge and split operations."""
|
||||
if not self.config:
|
||||
return
|
||||
|
||||
# Set up merges
|
||||
for merge in self.config.merges:
|
||||
sources = merge.get("sources", [])
|
||||
target = merge.get("target")
|
||||
if sources and target:
|
||||
self.stream_router.merge_streams(sources, target)
|
||||
self.logger.debug("Merged streams %s into %s", sources, target)
|
||||
|
||||
# Set up splits
|
||||
for split in self.config.splits:
|
||||
source = split.get("source")
|
||||
targets = split.get("targets", {})
|
||||
if source and targets:
|
||||
self.stream_router.split_stream(source, targets)
|
||||
self.logger.debug("Split stream %s into %s", source, list(targets.keys()))
|
||||
|
||||
# NOTE: Subscriptions are already set up in create_stream()
|
||||
# Re-setting them here would create duplicates, causing double output
|
||||
# Merge and split operations automatically handle stream connections
|
||||
self.logger.debug("Stream operations setup completed - subscriptions already configured")
|
||||
|
||||
# Removed _setup_langgraphs - use routes instead
|
||||
|
||||
def _setup_pipelines(self) -> None:
|
||||
"""Set up all configured hybrid pipelines."""
|
||||
if not self.config:
|
||||
return
|
||||
|
||||
for pipeline_name, pipeline_config in self.config.pipelines.items():
|
||||
# Convert config to dict format for bridge
|
||||
config_dict = {
|
||||
"name": pipeline_config.name,
|
||||
"stages": pipeline_config.stages,
|
||||
"metadata": pipeline_config.metadata,
|
||||
}
|
||||
|
||||
# Create pipeline through bridge
|
||||
self.langgraph_bridge.create_hybrid_pipeline(config_dict)
|
||||
self.logger.debug("Created hybrid pipeline: %s", pipeline_name)
|
||||
|
||||
@staticmethod
|
||||
def _sanitize_json_string(json_str: str) -> str:
|
||||
"""
|
||||
Sanitize JSON string by escaping control characters that LLMs often forget to escape.
|
||||
|
||||
... existing docstring ...
|
||||
"""
|
||||
|
||||
def is_valid_json(s: str) -> bool:
|
||||
"""Check if string is valid JSON."""
|
||||
try:
|
||||
json.loads(s)
|
||||
return True
|
||||
except json.JSONDecodeError:
|
||||
return False
|
||||
|
||||
# Try to parse as-is first
|
||||
if is_valid_json(json_str):
|
||||
return json_str # Already valid, no sanitization needed
|
||||
|
||||
# Strategy: Find all quoted string values and escape control characters
|
||||
def escape_string_content(match: Any) -> str:
|
||||
"""Escape control characters in a matched string value."""
|
||||
content = match.group(1)
|
||||
|
||||
# Escape backslashes first (to avoid double-escaping)
|
||||
content = content.replace('\\', '\\\\')
|
||||
# Escape control characters
|
||||
content = content.replace('\n', '\\n')
|
||||
content = content.replace('\r', '\\r')
|
||||
content = content.replace('\t', '\\t')
|
||||
content = content.replace('\b', '\\b')
|
||||
content = content.replace('\f', '\\f')
|
||||
content = content.replace('"', '\\"') # Escape quotes
|
||||
|
||||
# Return with quotes
|
||||
return f'"{content}"'
|
||||
|
||||
# Pattern to match any content between quotes, including newlines
|
||||
pattern = r'"([^"\\]*(?:\\.[^"\\]*)*)"'
|
||||
|
||||
sanitized = re.sub(pattern, escape_string_content, json_str, flags=re.DOTALL)
|
||||
|
||||
# ✅ NEW: Validate the sanitized result
|
||||
if not is_valid_json(sanitized):
|
||||
logger.warning(
|
||||
"JSON sanitization produced invalid JSON. "
|
||||
"Original length: %d, Sanitized length: %d. "
|
||||
"The sanitization logic may need improvement.",
|
||||
len(json_str),
|
||||
len(sanitized)
|
||||
)
|
||||
|
||||
return sanitized
|
||||
|
||||
def _execute_single_tool(self, tool_name: str, tool_params: Any) -> str:
|
||||
"""
|
||||
Execute a single tool synchronously.
|
||||
|
||||
Args:
|
||||
tool_name: Name of the tool to execute
|
||||
tool_params: Parameters to pass to the tool
|
||||
|
||||
Returns:
|
||||
str: Tool execution result with status indicator
|
||||
"""
|
||||
try:
|
||||
# Find the appropriate agent that can execute this tool
|
||||
target_agent = None
|
||||
for agent in self.agents.values():
|
||||
if hasattr(agent, 'tools') and tool_name in agent.tools:
|
||||
target_agent = agent
|
||||
break
|
||||
|
||||
if target_agent is None:
|
||||
self.logger.error("No agent found that can execute tool '%s'", tool_name)
|
||||
return f"Error: No agent available to execute tool '{tool_name}'"
|
||||
|
||||
# Prepare tool request
|
||||
tool_request = json.dumps({"tool": tool_name, "args": tool_params})
|
||||
context = {"_unsafe_mode": self.unsafe}
|
||||
|
||||
# Execute based on whether we're in an async context
|
||||
result = None
|
||||
try:
|
||||
asyncio.get_running_loop()
|
||||
# Already in async context, use thread pool
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(
|
||||
lambda: asyncio.run(target_agent.process_message(tool_request, context))
|
||||
)
|
||||
result = future.result(timeout=30)
|
||||
except RuntimeError:
|
||||
# No running loop, use asyncio.run
|
||||
result = asyncio.run(target_agent.process_message(tool_request, context))
|
||||
if result is None:
|
||||
return "\n❌ Error: Tool execution produced no result"
|
||||
|
||||
return f"\n✅ {result}"
|
||||
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
self.logger.error("Tool execution failed: %s", e)
|
||||
return f"\n❌ Error: {str(e)}"
|
||||
|
||||
def _process_tool_commands(self, content: str) -> str:
|
||||
"""
|
||||
Process tool execution commands embedded in orchestrator output.
|
||||
|
||||
Detects [TOOL_EXECUTE:tool_name] commands and executes actual tools,
|
||||
replacing the commands with the execution results.
|
||||
|
||||
Args:
|
||||
content: Content containing potential tool execution commands
|
||||
|
||||
Returns:
|
||||
str: Content with tool commands replaced by execution results
|
||||
"""
|
||||
# Pattern to match tool execution commands
|
||||
pattern = r'\[TOOL_EXECUTE:(\w+)\]\s*(.*?)\s*\[/TOOL_EXECUTE\]'
|
||||
matches = list(re.finditer(pattern, content, re.DOTALL))
|
||||
|
||||
if not matches:
|
||||
return content
|
||||
|
||||
# Process each match in reverse to maintain string positions
|
||||
result_content = content
|
||||
for match in reversed(matches):
|
||||
try:
|
||||
sanitized_params = self._sanitize_json_string(match.group(2))
|
||||
tool_params = json.loads(sanitized_params)
|
||||
tool_result = self._execute_single_tool(match.group(1), tool_params)
|
||||
result_content = (
|
||||
result_content[:match.start()] +
|
||||
tool_result +
|
||||
result_content[match.end():]
|
||||
)
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error("Failed to parse tool params: %s", e)
|
||||
error_msg = "\n❌ Error: Invalid tool parameters format"
|
||||
result_content = (
|
||||
result_content[:match.start()] +
|
||||
error_msg +
|
||||
result_content[match.end():]
|
||||
)
|
||||
|
||||
return result_content
|
||||
|
||||
def _print_help(self) -> None:
|
||||
"""Print help information for interactive session."""
|
||||
print("\nAvailable commands:")
|
||||
print(" exit - Exit the session")
|
||||
print(" help - Show this help message")
|
||||
print(" /stream <name> <message> - Send message to specific stream")
|
||||
print(" /graph <name> <message> - Execute a LangGraph with message")
|
||||
print("\nJust type a message to send it to the input stream.\n")
|
||||
|
||||
def _handle_stream_command(self, command: str) -> None:
|
||||
"""Handle stream-specific commands."""
|
||||
parts = command.split(" ", 1)
|
||||
if len(parts) < 2:
|
||||
print("Usage: /stream <name> <message>")
|
||||
return
|
||||
|
||||
stream_name, message = parts
|
||||
if stream_name not in self.stream_router.streams:
|
||||
print(f"Stream '{stream_name}' not found")
|
||||
return
|
||||
|
||||
metadata: dict[str, Any] = {
|
||||
"context": self.config.global_context if self.config else {}
|
||||
}
|
||||
metadata["_unsafe_mode"] = self.unsafe
|
||||
|
||||
self.stream_router.send_message(stream_name, message, metadata)
|
||||
print(f"Message sent to stream '{stream_name}'")
|
||||
|
||||
async def _handle_graph_command(self, command: str) -> None:
|
||||
"""Handle graph-specific commands."""
|
||||
parts = command.split(" ", 1)
|
||||
if len(parts) < 2:
|
||||
print("Usage: /graph <name> <message>")
|
||||
return
|
||||
|
||||
graph_name, message = parts
|
||||
# Check if it's a graph route
|
||||
route = self.config.routes.get(graph_name) if self.config else None
|
||||
if not route or route.type != RouteType.GRAPH:
|
||||
print(f"Graph route '{graph_name}' not found")
|
||||
available_graphs = (
|
||||
[
|
||||
name
|
||||
for name, r in self.config.routes.items()
|
||||
if r.type == RouteType.GRAPH
|
||||
]
|
||||
if self.config
|
||||
else []
|
||||
)
|
||||
if available_graphs:
|
||||
print(f"Available graph routes: {', '.join(available_graphs)}")
|
||||
return
|
||||
|
||||
graph = self.langgraph_bridge.get_graph(graph_name)
|
||||
if not graph:
|
||||
print(f"Graph '{graph_name}' not initialized")
|
||||
return
|
||||
|
||||
# Execute graph
|
||||
try:
|
||||
print(f"Executing graph '{graph_name}'...")
|
||||
|
||||
# Prepare metadata with unsafe mode and context
|
||||
|
||||
if not self.config:
|
||||
raise CleverAgentsException("Configuration not loaded")
|
||||
metadata: dict[str, Any] = {"context": self.config.global_context}
|
||||
metadata["_unsafe_mode"] = self.unsafe
|
||||
|
||||
result = await graph.execute(
|
||||
{
|
||||
"messages": [{"role": "user", "content": message}],
|
||||
"metadata": metadata
|
||||
}
|
||||
)
|
||||
|
||||
# Display result
|
||||
if result.messages:
|
||||
print(f"\n>>> {result.messages[-1].get('content', '')}")
|
||||
else:
|
||||
print(f"\n>>> Graph completed with state: {result.to_dict()}")
|
||||
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
self.logger.error("Error executing graph: %s", e)
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Clean up resources."""
|
||||
if self.stream_router:
|
||||
self.stream_router.dispose()
|
||||
self.logger.info("Application disposed")
|
||||
|
||||
def visualize_network(self, output_format: str = "mermaid") -> str: # pylint: disable=too-many-locals,too-many-branches,too-many-nested-blocks
|
||||
"""
|
||||
Visualize the stream network.
|
||||
|
||||
Args:
|
||||
output_format: Format for visualization (currently only 'mermaid' supported)
|
||||
|
||||
Returns:
|
||||
str: Visualization string in the requested format
|
||||
"""
|
||||
if output_format != "mermaid":
|
||||
return f"Format {output_format} not supported"
|
||||
|
||||
lines = ["graph TD"]
|
||||
|
||||
# Add agents
|
||||
for agent_name in self.agents:
|
||||
lines.append(f" {agent_name}[Agent: {agent_name}]")
|
||||
|
||||
# Add routes
|
||||
if self.config:
|
||||
for route_name, route_config in self.config.routes.items():
|
||||
if route_config.type == RouteType.STREAM:
|
||||
lines.append(f" {route_name}{{Stream: {route_name}}}")
|
||||
|
||||
# Show operators
|
||||
for op in route_config.operators:
|
||||
if op.get("type") == "map" and "agent" in op.get("params", {}):
|
||||
agent = op["params"]["agent"]
|
||||
lines.append(f" {route_name} --> {agent}")
|
||||
|
||||
# Show publications
|
||||
for pub in route_config.publications:
|
||||
lines.append(f" {route_name} --> {pub}")
|
||||
elif route_config.type == RouteType.GRAPH:
|
||||
lines.append(f" {route_name}{{Graph: {route_name}}}")
|
||||
|
||||
# Add merges
|
||||
for merge in self.config.merges:
|
||||
sources = merge.get("sources", [])
|
||||
target = merge.get("target")
|
||||
for source in sources:
|
||||
lines.append(f" {source} --> {target}")
|
||||
|
||||
# Add LangGraphs as subgraphs
|
||||
for graph_name in self.langgraph_bridge.list_graphs():
|
||||
lines.append(f"\n subgraph {graph_name}")
|
||||
graph = self.langgraph_bridge.get_graph(graph_name)
|
||||
if graph:
|
||||
# Get graph visualization and indent it
|
||||
graph_viz = graph.visualize(output_format="mermaid")
|
||||
for line in graph_viz.split("\n")[1:]: # Skip first "graph TD"
|
||||
lines.append(f" {line}")
|
||||
lines.append(" end")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# Main application class - for consistency with imports
|
||||
CleverAgentsApp = ReactiveCleverAgentsApp
|
||||
@@ -0,0 +1,339 @@
|
||||
"""
|
||||
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 copy
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
from re import Match
|
||||
from typing import Any
|
||||
from typing import List
|
||||
|
||||
import yaml
|
||||
|
||||
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) -> None:
|
||||
"""
|
||||
Initialize the ConfigurationManager.
|
||||
|
||||
Sets up an empty configuration and initializes the schema validator.
|
||||
"""
|
||||
self.config: dict[str, Any] = {}
|
||||
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", encoding="utf-8") 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)}"
|
||||
) from e
|
||||
except Exception as e:
|
||||
if isinstance(e, ConfigurationError):
|
||||
raise
|
||||
raise ConfigurationError(
|
||||
f"Failed to load configuration file {file_path.name}: {str(e)}"
|
||||
) from 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)}") from 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} or ${VAR_NAME:default_value} 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
|
||||
and no default provided.
|
||||
"""
|
||||
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):
|
||||
# Pattern to match ${VAR_NAME} or ${VAR_NAME:default_value}
|
||||
env_var_pattern = r"\${([A-Za-z0-9_]+)(?::([^}]*))?\}"
|
||||
|
||||
def replace_env_var(match: Match[str]) -> str:
|
||||
env_var = match.group(1)
|
||||
default_value = match.group(2) if match.group(2) is not None else None
|
||||
|
||||
env_value = os.environ.get(env_var)
|
||||
if env_value is None:
|
||||
if default_value is not None:
|
||||
# Convert default value to appropriate type
|
||||
if default_value.lower() in ("true", "false"):
|
||||
return str(default_value.lower() == "true")
|
||||
if default_value.isdigit():
|
||||
return default_value
|
||||
if default_value.replace(".", "").isdigit():
|
||||
return default_value
|
||||
return default_value
|
||||
raise ConfigurationError(
|
||||
f"Environment variable '{env_var}' is not set"
|
||||
)
|
||||
return env_value
|
||||
|
||||
config = re.sub(env_var_pattern, replace_env_var, config)
|
||||
|
||||
# Convert numeric and boolean strings to appropriate types
|
||||
if config.lower() in ("true", "false"):
|
||||
return config.lower() == "true"
|
||||
if config.isdigit():
|
||||
return int(config)
|
||||
if config.replace(".", "").isdigit() and config.count(".") == 1:
|
||||
return float(config)
|
||||
return config
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""
|
||||
Convert the configuration to a dictionary.
|
||||
|
||||
Returns:
|
||||
A deep copy of the complete configuration dictionary.
|
||||
"""
|
||||
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: # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
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) -> None:
|
||||
"""
|
||||
Initialize the SchemaValidator.
|
||||
|
||||
Sets up the schema definition for CleverAgents configuration.
|
||||
"""
|
||||
# Schema will be defined here
|
||||
|
||||
def validate(self, config: dict[str, Any]) -> None:
|
||||
"""
|
||||
Validate a configuration dictionary against the schema.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary to validate.
|
||||
|
||||
Raises:
|
||||
ConfigurationError: If the configuration does not conform to the schema.
|
||||
"""
|
||||
# Check for required top-level sections
|
||||
if "agents" not in config:
|
||||
raise ConfigurationError("Configuration must contain an 'agents' section")
|
||||
|
||||
if not isinstance(config["agents"], dict):
|
||||
raise ConfigurationError("'agents' section must be a dictionary")
|
||||
|
||||
# Validate each agent configuration
|
||||
for agent_name, agent_config in config["agents"].items():
|
||||
if not isinstance(agent_config, dict):
|
||||
raise ConfigurationError(
|
||||
f"Agent '{agent_name}' configuration must be a dictionary"
|
||||
)
|
||||
|
||||
if "type" not in agent_config:
|
||||
raise ConfigurationError(
|
||||
f"Agent '{agent_name}' is missing required 'type' field"
|
||||
)
|
||||
|
||||
if "config" not in agent_config:
|
||||
agent_config["config"] = {}
|
||||
|
||||
if not isinstance(agent_config["config"], dict):
|
||||
raise ConfigurationError(
|
||||
f"Agent '{agent_name}' 'config' must be a dictionary"
|
||||
)
|
||||
|
||||
if "routes" in config and isinstance(config["routes"], dict):
|
||||
if not config["routes"]:
|
||||
raise ConfigurationError("'routes' section cannot be empty")
|
||||
|
||||
clever_opts = config.get("cleveragents", {})
|
||||
if "default_router" not in clever_opts:
|
||||
raise ConfigurationError(
|
||||
"Configuration with 'routes' section must specify 'cleveragents.default_router'"
|
||||
)
|
||||
|
||||
default_router = clever_opts.get("default_router")
|
||||
if default_router not in config["routes"]:
|
||||
raise ConfigurationError(
|
||||
f"Default router '{default_router}' not found within 'routes'"
|
||||
)
|
||||
else:
|
||||
raise ConfigurationError("Configuration must have 'routes' section")
|
||||
@@ -0,0 +1,46 @@
|
||||
"""
|
||||
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."""
|
||||
|
||||
|
||||
class ConfigurationError(CleverAgentsException):
|
||||
"""Exception raised for errors in the configuration."""
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
|
||||
class AgentCreationError(CleverAgentsException):
|
||||
"""Exception raised when agent creation fails."""
|
||||
|
||||
|
||||
class RoutingError(CleverAgentsException):
|
||||
"""Exception raised for errors in the routing configuration or execution."""
|
||||
|
||||
|
||||
class TemplateError(CleverAgentsException):
|
||||
"""Exception raised for errors in template rendering."""
|
||||
|
||||
|
||||
class ExecutionError(CleverAgentsException):
|
||||
"""Exception raised when agent execution fails."""
|
||||
|
||||
|
||||
class ApplicationError(CleverAgentsException):
|
||||
"""Exception raised for application-level errors."""
|
||||
|
||||
|
||||
class StreamRoutingError(CleverAgentsException):
|
||||
"""Exception raised for errors in stream routing."""
|
||||
@@ -0,0 +1,30 @@
|
||||
"""
|
||||
Provides sandboxing utilities for executing configuration-supplied Python code
|
||||
in a restricted environment.
|
||||
|
||||
SAFE_BUILTINS is a curated subset of Python built-in functions that are deemed
|
||||
safe for use in conditions and custom tools executed when the application is
|
||||
*not* running with the ``--unsafe`` flag enabled. The list intentionally
|
||||
excludes any function that can perform I/O, introspection, or dynamic imports.
|
||||
"""
|
||||
|
||||
SAFE_BUILTINS = {
|
||||
# Basic types / constructors
|
||||
"bool": bool,
|
||||
"dict": dict,
|
||||
"float": float,
|
||||
"int": int,
|
||||
"list": list,
|
||||
"set": set,
|
||||
"str": str,
|
||||
"tuple": tuple,
|
||||
# Introspection-free utilities
|
||||
"abs": abs,
|
||||
"all": all,
|
||||
"any": any,
|
||||
"len": len,
|
||||
"max": max,
|
||||
"min": min,
|
||||
"round": round,
|
||||
"sum": sum,
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"""
|
||||
LangGraph integration for CleverAgents.
|
||||
|
||||
This module provides LangGraph functionality integrated with RxPy streams.
|
||||
"""
|
||||
|
||||
from cleveragents.langgraph.graph import LangGraph
|
||||
from cleveragents.langgraph.nodes import Node
|
||||
from cleveragents.langgraph.nodes import NodeType
|
||||
from cleveragents.langgraph.state import GraphState
|
||||
from cleveragents.langgraph.state import StateManager
|
||||
|
||||
__all__ = [
|
||||
"LangGraph",
|
||||
"GraphState",
|
||||
"StateManager",
|
||||
"Node",
|
||||
"NodeType",
|
||||
]
|
||||
@@ -0,0 +1,439 @@
|
||||
"""
|
||||
Bridge between RxPy streams and LangGraph execution.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import rx
|
||||
from rx import operators as ops
|
||||
from rx.core import Observable # type: ignore[attr-defined]
|
||||
from rx.core import Observer # type: ignore[attr-defined]
|
||||
from rx.subject import Subject # type: ignore[attr-defined]
|
||||
|
||||
from cleveragents.langgraph.graph import GraphConfig
|
||||
from cleveragents.langgraph.graph import LangGraph
|
||||
from cleveragents.langgraph.nodes import Edge
|
||||
from cleveragents.langgraph.nodes import NodeConfig
|
||||
from cleveragents.langgraph.nodes import NodeType
|
||||
from cleveragents.langgraph.state import GraphState
|
||||
from cleveragents.reactive.stream_router import ReactiveStreamRouter
|
||||
from cleveragents.reactive.stream_router import StreamConfig
|
||||
from cleveragents.reactive.stream_router import StreamMessage
|
||||
from cleveragents.reactive.stream_router import StreamType
|
||||
|
||||
|
||||
class RxPyLangGraphBridge:
|
||||
"""
|
||||
Bridge that allows LangGraph nodes to be used as RxPy operators
|
||||
and RxPy streams to trigger LangGraph execution.
|
||||
"""
|
||||
|
||||
def __init__(self, stream_router: ReactiveStreamRouter):
|
||||
"""Initialize the bridge."""
|
||||
self.stream_router = stream_router
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.graphs: dict[str, LangGraph] = {}
|
||||
|
||||
# Register custom operators
|
||||
self._register_langgraph_operators()
|
||||
|
||||
def _run_async_safely(self, coro: Any) -> Any:
|
||||
"""Run an async coroutine safely, handling existing event loops."""
|
||||
# Just return the coroutine - it will be handled by the async scheduler
|
||||
return coro
|
||||
|
||||
def _register_langgraph_operators(self) -> None:
|
||||
"""Register LangGraph-specific operators with the stream router."""
|
||||
# Register graph execution operator
|
||||
self._register_operator("graph_execute", self._create_graph_executor)
|
||||
|
||||
# Register state management operators
|
||||
self._register_operator("state_update", self._create_state_updater)
|
||||
self._register_operator("state_checkpoint", self._create_state_checkpointer)
|
||||
|
||||
# Register node operators
|
||||
self._register_operator("langgraph_node", self._create_node_operator)
|
||||
|
||||
# Register conditional routing
|
||||
self._register_operator("conditional_route", self._create_conditional_router)
|
||||
|
||||
def _register_operator(self, name: str, factory_func: Callable[..., Any]) -> None:
|
||||
"""Register a custom operator with the stream router."""
|
||||
# Store operator factory function
|
||||
setattr(self, f"_operator_{name}", factory_func)
|
||||
|
||||
def create_graph_from_config(self, config: dict[str, Any]) -> LangGraph:
|
||||
"""Create a LangGraph from configuration."""
|
||||
# Parse graph configuration
|
||||
graph_config = GraphConfig(
|
||||
name=config.get("name", "default"),
|
||||
entry_point=config.get("entry_point", "start"),
|
||||
checkpointing=config.get("checkpointing", False),
|
||||
enable_time_travel=config.get("enable_time_travel", False),
|
||||
parallel_execution=config.get("parallel_execution", True),
|
||||
)
|
||||
|
||||
# Parse nodes
|
||||
for node_name, node_data in config.get("nodes", {}).items():
|
||||
node_config = NodeConfig(
|
||||
name=node_name,
|
||||
type=NodeType(node_data.get("type", "function")),
|
||||
agent=node_data.get("agent"),
|
||||
function=node_data.get("function"),
|
||||
tools=node_data.get("tools", []),
|
||||
retry_policy=node_data.get("retry_policy"),
|
||||
timeout=node_data.get("timeout"),
|
||||
parallel=node_data.get("parallel", False),
|
||||
condition=node_data.get("condition"),
|
||||
subgraph=node_data.get("subgraph"),
|
||||
metadata=node_data.get("metadata", {}),
|
||||
)
|
||||
graph_config.nodes[node_name] = node_config
|
||||
|
||||
# Parse edges
|
||||
for edge_data in config.get("edges", []):
|
||||
edge = Edge(
|
||||
source=edge_data["source"],
|
||||
target=edge_data["target"],
|
||||
condition=edge_data.get("condition"),
|
||||
metadata=edge_data.get("metadata", {}),
|
||||
)
|
||||
graph_config.edges.append(edge)
|
||||
|
||||
# Create graph with shared stream router
|
||||
graph = LangGraph(
|
||||
config=graph_config,
|
||||
agents=self.stream_router.agents,
|
||||
stream_router=self.stream_router,
|
||||
scheduler=self.stream_router.scheduler,
|
||||
)
|
||||
|
||||
# Store graph
|
||||
self.graphs[graph.name] = graph
|
||||
|
||||
return graph
|
||||
|
||||
def create_graph_stream(self, graph_name: str) -> StreamConfig:
|
||||
"""Create a stream that executes a LangGraph."""
|
||||
if graph_name not in self.graphs:
|
||||
raise ValueError(f"Graph '{graph_name}' not found")
|
||||
|
||||
# Create stream configuration
|
||||
stream_config = StreamConfig(
|
||||
name=f"graph_{graph_name}",
|
||||
type=StreamType.COLD,
|
||||
operators=[
|
||||
{
|
||||
"type": "graph_execute",
|
||||
"params": {
|
||||
"graph": graph_name,
|
||||
},
|
||||
}
|
||||
],
|
||||
publications=["__output__"],
|
||||
)
|
||||
|
||||
return stream_config
|
||||
|
||||
def _create_graph_executor(self, params: dict[str, Any]) -> Any:
|
||||
"""Create an operator that executes a LangGraph."""
|
||||
graph_name = params.get("graph")
|
||||
if not graph_name or graph_name not in self.graphs:
|
||||
raise ValueError(f"Invalid graph name: {graph_name}")
|
||||
|
||||
graph = self.graphs[graph_name]
|
||||
|
||||
# Create a lock for this graph to serialize execution
|
||||
execution_lock = asyncio.Lock()
|
||||
|
||||
async def execute_graph(msg: StreamMessage) -> StreamMessage:
|
||||
# Extract input from message
|
||||
input_data = msg.content
|
||||
if isinstance(input_data, str):
|
||||
input_data = {"messages": [{"role": "user", "content": input_data}]}
|
||||
|
||||
# Pass metadata (like _unsafe_mode) to graph state
|
||||
if msg.metadata:
|
||||
input_data["metadata"] = msg.metadata
|
||||
|
||||
# Execute graph with lock to prevent concurrent execution
|
||||
async with execution_lock:
|
||||
final_state = await graph.execute(input_data)
|
||||
|
||||
# Extract result from final state
|
||||
if final_state.messages:
|
||||
result = final_state.messages[-1].get("content", "")
|
||||
else:
|
||||
result = final_state.to_dict()
|
||||
|
||||
return StreamMessage(
|
||||
content=result,
|
||||
metadata={
|
||||
**msg.metadata,
|
||||
"graph": graph_name,
|
||||
"execution_history": graph.get_execution_history(),
|
||||
"final_state": final_state.to_dict(),
|
||||
},
|
||||
)
|
||||
|
||||
def create_future_task(msg: StreamMessage) -> Any:
|
||||
# Create a task in the current event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
task = loop.create_task(execute_graph(msg))
|
||||
return task
|
||||
|
||||
return ops.flat_map(lambda msg: rx.from_future(create_future_task(msg)))
|
||||
|
||||
def _create_state_updater(self, params: dict[str, Any]) -> Any:
|
||||
"""Create an operator that updates graph state."""
|
||||
graph_name = params.get("graph")
|
||||
|
||||
if not graph_name or graph_name not in self.graphs:
|
||||
raise ValueError(f"Invalid graph name: {graph_name}")
|
||||
|
||||
graph = self.graphs[graph_name]
|
||||
|
||||
def update_state(msg: StreamMessage) -> StreamMessage:
|
||||
# Update graph state
|
||||
updates = (
|
||||
msg.content if isinstance(msg.content, dict) else {"data": msg.content}
|
||||
)
|
||||
graph.state_manager.update_state(updates)
|
||||
|
||||
return msg.copy_with(
|
||||
metadata={
|
||||
**msg.metadata,
|
||||
"state_updated": True,
|
||||
"graph": graph_name,
|
||||
}
|
||||
)
|
||||
|
||||
return ops.map(update_state)
|
||||
|
||||
def _create_state_checkpointer(self, params: dict[str, Any]) -> Any:
|
||||
"""Create an operator that checkpoints graph state."""
|
||||
graph_name = params.get("graph")
|
||||
|
||||
if not graph_name or graph_name not in self.graphs:
|
||||
raise ValueError(f"Invalid graph name: {graph_name}")
|
||||
|
||||
graph = self.graphs[graph_name]
|
||||
|
||||
def checkpoint_state(msg: StreamMessage) -> StreamMessage:
|
||||
# Save checkpoint
|
||||
graph.state_manager._save_checkpoint() # pylint: disable=protected-access
|
||||
|
||||
return msg.copy_with(
|
||||
metadata={
|
||||
**msg.metadata,
|
||||
"checkpointed": True,
|
||||
"graph": graph_name,
|
||||
}
|
||||
)
|
||||
|
||||
return ops.map(checkpoint_state)
|
||||
|
||||
def _create_node_operator(self, params: dict[str, Any]) -> Any:
|
||||
"""Create an operator that executes a specific LangGraph node."""
|
||||
graph_name = params.get("graph")
|
||||
node_name = params.get("node")
|
||||
|
||||
if not graph_name or graph_name not in self.graphs:
|
||||
raise ValueError(f"Invalid graph name: {graph_name}")
|
||||
|
||||
graph = self.graphs[graph_name]
|
||||
|
||||
if not node_name or node_name not in graph.nodes:
|
||||
raise ValueError(f"Invalid node name: {node_name}")
|
||||
|
||||
node = graph.nodes[node_name]
|
||||
|
||||
async def execute_node(msg: StreamMessage) -> StreamMessage:
|
||||
# Get current state
|
||||
state = graph.state_manager.get_state()
|
||||
|
||||
# Update state with message content
|
||||
if isinstance(msg.content, str):
|
||||
state.messages.append({"role": "user", "content": msg.content})
|
||||
|
||||
# Execute node
|
||||
updates = await node.execute(state)
|
||||
|
||||
# Update graph state
|
||||
graph.state_manager.update_state(updates, node_id=node_name)
|
||||
|
||||
# Extract result
|
||||
if "messages" in updates and updates["messages"]:
|
||||
result = updates["messages"][-1].get("content", "")
|
||||
else:
|
||||
result = updates
|
||||
|
||||
return StreamMessage(
|
||||
content=result,
|
||||
metadata={
|
||||
**msg.metadata,
|
||||
"node": node_name,
|
||||
"graph": graph_name,
|
||||
"updates": updates,
|
||||
},
|
||||
)
|
||||
|
||||
def create_future_task(msg: StreamMessage) -> Any:
|
||||
# Create a task in the current event loop
|
||||
loop = asyncio.get_event_loop()
|
||||
task = loop.create_task(execute_node(msg))
|
||||
return task
|
||||
|
||||
return ops.flat_map(lambda msg: rx.from_future(create_future_task(msg)))
|
||||
|
||||
def _create_conditional_router(
|
||||
self, params: dict[str, Any]
|
||||
) -> Callable[[Observable], Observable]:
|
||||
"""Create an operator that routes based on LangGraph conditions."""
|
||||
routes = params.get("routes", {})
|
||||
default_route = params.get("default")
|
||||
|
||||
def route_message(msg: StreamMessage) -> str:
|
||||
# Evaluate conditions for each route
|
||||
for route_name, condition in routes.items():
|
||||
if self._evaluate_route_condition(msg, condition):
|
||||
# Ensure route_name is a string
|
||||
if not isinstance(route_name, str):
|
||||
return str(route_name)
|
||||
return route_name
|
||||
|
||||
default_str = default_route if default_route else "__output__"
|
||||
if not isinstance(default_str, str):
|
||||
return "__output__"
|
||||
return default_str
|
||||
|
||||
# Return a routing operator
|
||||
def router(source: Observable) -> Observable:
|
||||
return source.pipe(
|
||||
ops.group_by(route_message),
|
||||
ops.flat_map(
|
||||
lambda group: group.pipe(ops.map(lambda msg: (group.key, msg)))
|
||||
),
|
||||
)
|
||||
|
||||
return router
|
||||
|
||||
def _evaluate_route_condition(
|
||||
self, msg: StreamMessage, condition: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Evaluate a routing condition."""
|
||||
condition_type = condition.get("type", "always")
|
||||
|
||||
if condition_type == "always":
|
||||
return True
|
||||
if condition_type == "content_type":
|
||||
expected_type = condition.get("value")
|
||||
return type(msg.content).__name__ == expected_type
|
||||
if condition_type == "metadata_has":
|
||||
key = condition.get("key")
|
||||
return key in msg.metadata
|
||||
if condition_type == "content_contains":
|
||||
text = condition.get("text", "")
|
||||
return text in str(msg.content)
|
||||
if condition_type == "content_not_contains":
|
||||
text = condition.get("text", "")
|
||||
return text not in str(msg.content)
|
||||
|
||||
return False
|
||||
|
||||
def connect_stream_to_graph(self, stream_name: str, graph_name: str) -> None:
|
||||
"""Connect an RxPy stream to trigger graph execution."""
|
||||
if stream_name not in self.stream_router.observables:
|
||||
raise ValueError(f"Stream '{stream_name}' not found")
|
||||
|
||||
if graph_name not in self.graphs:
|
||||
raise ValueError(f"Graph '{graph_name}' not found")
|
||||
|
||||
graph = self.graphs[graph_name]
|
||||
|
||||
# Create observer that executes graph
|
||||
def on_message(msg: StreamMessage) -> None:
|
||||
loop = asyncio.get_event_loop()
|
||||
loop.create_task(graph.execute(msg.content))
|
||||
|
||||
observer = Observer(on_next=on_message)
|
||||
|
||||
# Subscribe to stream
|
||||
subscription = self.stream_router.observables[stream_name].subscribe(observer)
|
||||
self.stream_router.subscriptions.append(subscription)
|
||||
|
||||
def connect_graph_to_stream(self, graph_name: str, stream_name: str) -> None:
|
||||
"""Connect graph state updates to an RxPy stream."""
|
||||
if graph_name not in self.graphs:
|
||||
raise ValueError(f"Graph '{graph_name}' not found")
|
||||
|
||||
graph = self.graphs[graph_name]
|
||||
|
||||
# Get or create target stream
|
||||
if stream_name not in self.stream_router.streams:
|
||||
stream = Subject()
|
||||
self.stream_router.streams[stream_name] = stream
|
||||
self.stream_router.observables[stream_name] = stream
|
||||
|
||||
target_stream = self.stream_router.streams[stream_name]
|
||||
|
||||
# Subscribe to graph state updates
|
||||
def on_state_update(state: GraphState) -> None:
|
||||
msg = StreamMessage(
|
||||
content=state.to_dict(),
|
||||
metadata={
|
||||
"source": "langgraph",
|
||||
"graph": graph_name,
|
||||
},
|
||||
)
|
||||
target_stream.on_next(msg)
|
||||
|
||||
observer = Observer(on_next=on_state_update)
|
||||
graph.state_manager.get_state_observable().subscribe(observer)
|
||||
|
||||
def create_hybrid_pipeline(self, config: dict[str, Any]) -> None:
|
||||
"""
|
||||
Create a hybrid pipeline that combines RxPy streams and LangGraph nodes.
|
||||
|
||||
This allows for complex workflows that leverage both paradigms.
|
||||
"""
|
||||
# Parse pipeline configuration
|
||||
stages = config.get("stages", [])
|
||||
|
||||
for i, stage in enumerate(stages):
|
||||
stage_type = stage.get("type")
|
||||
|
||||
if stage_type == "stream":
|
||||
# Create RxPy stream
|
||||
stream_config = StreamConfig(
|
||||
name=stage["name"],
|
||||
type=StreamType(stage.get("stream_type", "cold")),
|
||||
operators=stage.get("operators", []),
|
||||
publications=stage.get("publications", []),
|
||||
)
|
||||
self.stream_router.create_stream(stream_config)
|
||||
|
||||
elif stage_type == "graph":
|
||||
# Create LangGraph
|
||||
graph = self.create_graph_from_config(stage["config"])
|
||||
|
||||
# Connect to previous stage if specified
|
||||
if i > 0 and "input_from" in stage:
|
||||
self.connect_stream_to_graph(stage["input_from"], graph.name)
|
||||
|
||||
# Connect to next stage if specified
|
||||
if "output_to" in stage:
|
||||
self.connect_graph_to_stream(graph.name, stage["output_to"])
|
||||
|
||||
def get_graph(self, name: str) -> Optional[LangGraph]:
|
||||
"""Get a graph by name."""
|
||||
return self.graphs.get(name)
|
||||
|
||||
def list_graphs(self) -> List[str]:
|
||||
"""List all registered graphs."""
|
||||
return list(self.graphs.keys())
|
||||
@@ -0,0 +1,577 @@
|
||||
"""
|
||||
LangGraph implementation integrated with RxPy.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import concurrent.futures
|
||||
import logging
|
||||
from collections import defaultdict
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Set
|
||||
|
||||
from rx.core import Observer # type: ignore[attr-defined]
|
||||
from rx.scheduler.eventloop import AsyncIOScheduler # type: ignore[attr-defined]
|
||||
from rx.subject import Subject # type: ignore[attr-defined]
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.langgraph.nodes import Edge
|
||||
from cleveragents.langgraph.nodes import Node
|
||||
from cleveragents.langgraph.nodes import NodeConfig
|
||||
from cleveragents.langgraph.nodes import NodeType
|
||||
from cleveragents.langgraph.state import GraphState
|
||||
from cleveragents.langgraph.state import StateManager
|
||||
from cleveragents.langgraph.state import StateUpdateMode
|
||||
from cleveragents.reactive.stream_router import ReactiveStreamRouter
|
||||
from cleveragents.reactive.stream_router import StreamConfig
|
||||
from cleveragents.reactive.stream_router import StreamMessage
|
||||
from cleveragents.reactive.stream_router import StreamType
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphConfig: # pylint: disable=too-many-instance-attributes
|
||||
"""Configuration for a LangGraph."""
|
||||
|
||||
name: str
|
||||
nodes: dict[str, NodeConfig] = field(default_factory=dict)
|
||||
edges: List[Edge] = field(default_factory=list)
|
||||
entry_point: str = "start"
|
||||
state_class: Optional[type] = None
|
||||
checkpointing: bool = False
|
||||
checkpoint_dir: Optional[Path] = None
|
||||
enable_time_travel: bool = False
|
||||
parallel_execution: bool = True
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class LangGraph: # pylint: disable=too-many-instance-attributes
|
||||
"""
|
||||
LangGraph implementation with RxPy integration.
|
||||
|
||||
This class provides LangGraph functionality while using RxPy for
|
||||
message routing between nodes.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config: GraphConfig,
|
||||
agents: Optional[dict[str, Agent]] = None,
|
||||
stream_router: Optional[ReactiveStreamRouter] = None,
|
||||
scheduler: Optional[AsyncIOScheduler] = None,
|
||||
):
|
||||
"""Initialize LangGraph."""
|
||||
self.config = config
|
||||
self.name = config.name
|
||||
self.agents = agents or {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Create scheduler if not provided
|
||||
if not scheduler:
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
scheduler = AsyncIOScheduler(loop)
|
||||
|
||||
self.scheduler = scheduler
|
||||
|
||||
# Use provided stream router or create new one
|
||||
self.stream_router = stream_router or ReactiveStreamRouter(self.scheduler)
|
||||
|
||||
# Initialize nodes
|
||||
self.nodes: dict[str, Node] = {}
|
||||
self._initialize_nodes()
|
||||
|
||||
# State management
|
||||
state_class = config.state_class or GraphState
|
||||
self.state_manager = StateManager(
|
||||
initial_state=state_class(),
|
||||
checkpoint_dir=config.checkpoint_dir if config.checkpointing else None,
|
||||
enable_time_travel=config.enable_time_travel,
|
||||
)
|
||||
|
||||
# Execution tracking
|
||||
self.execution_history: List[str] = []
|
||||
self.is_running = False
|
||||
|
||||
# Create streams for graph execution
|
||||
self._create_graph_streams()
|
||||
|
||||
# Graph analysis
|
||||
self._analyze_graph()
|
||||
|
||||
def _initialize_nodes(self) -> None:
|
||||
"""Initialize all nodes in the graph."""
|
||||
# Add start and end nodes if not present
|
||||
if "start" not in self.config.nodes:
|
||||
self.config.nodes["start"] = NodeConfig(
|
||||
name="start",
|
||||
type=NodeType.START,
|
||||
)
|
||||
|
||||
if "end" not in self.config.nodes:
|
||||
self.config.nodes["end"] = NodeConfig(
|
||||
name="end",
|
||||
type=NodeType.END,
|
||||
)
|
||||
|
||||
# Create node instances
|
||||
for node_name, node_config in self.config.nodes.items():
|
||||
self.nodes[node_name] = Node(node_config, self.agents)
|
||||
|
||||
def _create_graph_streams(self) -> None:
|
||||
"""Create RxPy streams for graph execution."""
|
||||
# Create a stream for each node
|
||||
for node_name in self.nodes:
|
||||
stream_name = f"__{self.name}_node_{node_name}__"
|
||||
|
||||
# Create stream with node execution operator
|
||||
operators: List[dict[str, Any]] = [
|
||||
{"type": "map", "params": {"function": f"execute_node_{node_name}"}}
|
||||
]
|
||||
|
||||
# Register custom function for node execution
|
||||
self._register_node_executor(node_name)
|
||||
|
||||
# Create the stream
|
||||
config = StreamConfig(
|
||||
name=stream_name,
|
||||
type=StreamType.COLD,
|
||||
operators=operators,
|
||||
)
|
||||
self.stream_router.create_stream(config)
|
||||
|
||||
# Create control streams
|
||||
self._create_control_streams()
|
||||
|
||||
# Subscribe to node streams to enable processing
|
||||
self._setup_node_stream_subscriptions()
|
||||
|
||||
def _create_control_streams(self) -> None:
|
||||
"""Create control streams for graph execution."""
|
||||
# Execution control stream
|
||||
control_stream_name = f"__{self.name}_control__"
|
||||
control_stream = Subject()
|
||||
self.stream_router.streams[control_stream_name] = control_stream
|
||||
self.stream_router.observables[control_stream_name] = control_stream
|
||||
|
||||
# State update stream
|
||||
state_stream_name = f"__{self.name}_state__"
|
||||
self.stream_router.streams[state_stream_name] = self.state_manager.state_stream
|
||||
self.stream_router.observables[state_stream_name] = (
|
||||
self.state_manager.state_stream
|
||||
)
|
||||
|
||||
def _setup_node_stream_subscriptions(self) -> None:
|
||||
"""Set up subscriptions to node streams to enable processing."""
|
||||
for node_name in self.nodes:
|
||||
stream_name = f"__{self.name}_node_{node_name}__"
|
||||
|
||||
# Subscribe to the node stream to enable processing
|
||||
if stream_name in self.stream_router.observables:
|
||||
observable = self.stream_router.observables[stream_name]
|
||||
|
||||
# Create a simple observer that just acknowledges the message
|
||||
# The actual processing is handled by the stream operators
|
||||
def on_next(msg: Any) -> None: # pylint: disable=unused-argument
|
||||
pass # Processing handled by stream operators
|
||||
|
||||
def on_error(error: Exception, name: str = stream_name) -> None:
|
||||
self.logger.error("Error in node stream %s: %s", name, error)
|
||||
|
||||
def on_completed() -> None:
|
||||
pass
|
||||
|
||||
observer = Observer(
|
||||
on_next=on_next, on_error=on_error, on_completed=on_completed
|
||||
)
|
||||
observable.subscribe(observer)
|
||||
|
||||
def _register_node_executor(self, node_name: str) -> None:
|
||||
"""Register a function to execute a specific node."""
|
||||
node = self.nodes[node_name]
|
||||
|
||||
async def async_executor(msg: StreamMessage) -> StreamMessage: # pylint: disable=unused-argument
|
||||
# Get current state
|
||||
state = self.state_manager.get_state()
|
||||
|
||||
# Execute node
|
||||
updates = await node.execute(state)
|
||||
|
||||
# Update state
|
||||
self.state_manager.update_state(updates, node_id=node_name)
|
||||
|
||||
# Record execution
|
||||
self.execution_history.append(node_name)
|
||||
|
||||
# Prepare output message
|
||||
return StreamMessage(
|
||||
content=updates,
|
||||
metadata={
|
||||
"node": node_name,
|
||||
"execution_count": node.execution_count,
|
||||
"graph": self.name,
|
||||
},
|
||||
)
|
||||
|
||||
def sync_executor(msg: StreamMessage) -> StreamMessage:
|
||||
# Run the async executor synchronously using thread pool
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, async_executor(msg))
|
||||
return future.result()
|
||||
|
||||
# Store sync executor function (stream router will add _builtin_ prefix automatically)
|
||||
setattr(self.stream_router, f"_builtin_execute_node_{node_name}", sync_executor)
|
||||
|
||||
def _analyze_graph(self) -> None:
|
||||
"""Analyze graph structure for validation and optimization."""
|
||||
# Build adjacency lists
|
||||
self.adjacency_list: dict[str, List[str]] = defaultdict(list)
|
||||
self.reverse_adjacency_list: dict[str, List[str]] = defaultdict(list)
|
||||
|
||||
for edge in self.config.edges:
|
||||
self.adjacency_list[edge.source].append(edge.target)
|
||||
self.reverse_adjacency_list[edge.target].append(edge.source)
|
||||
|
||||
# Detect cycles
|
||||
self.has_cycles = self._detect_cycles()
|
||||
|
||||
# Find parallel execution opportunities
|
||||
self.parallel_groups = self._find_parallel_groups()
|
||||
|
||||
# Validate graph
|
||||
self._validate_graph()
|
||||
|
||||
def _detect_cycles(self) -> bool:
|
||||
"""Detect if the graph has cycles."""
|
||||
visited = set()
|
||||
rec_stack = set()
|
||||
|
||||
def has_cycle(node: str) -> bool:
|
||||
visited.add(node)
|
||||
rec_stack.add(node)
|
||||
|
||||
for neighbor in self.adjacency_list[node]:
|
||||
if neighbor not in visited:
|
||||
if has_cycle(neighbor):
|
||||
return True
|
||||
elif neighbor in rec_stack:
|
||||
return True
|
||||
|
||||
rec_stack.remove(node)
|
||||
return False
|
||||
|
||||
for node in self.nodes:
|
||||
if node not in visited:
|
||||
if has_cycle(node):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def _find_parallel_groups(self) -> List[Set[str]]:
|
||||
"""Find groups of nodes that can execute in parallel."""
|
||||
if not self.config.parallel_execution:
|
||||
return []
|
||||
|
||||
# Use topological levels to find parallel groups
|
||||
levels = self._topological_levels()
|
||||
|
||||
parallel_groups = []
|
||||
for level_nodes in levels.values():
|
||||
# Filter nodes that support parallel execution
|
||||
parallel_nodes = {
|
||||
node for node in level_nodes if self.nodes[node].can_execute_parallel()
|
||||
}
|
||||
if len(parallel_nodes) > 1:
|
||||
parallel_groups.append(parallel_nodes)
|
||||
|
||||
return parallel_groups
|
||||
|
||||
def _topological_levels(self) -> dict[int, Set[str]]:
|
||||
"""Compute topological levels for the graph."""
|
||||
in_degree = {node: 0 for node in self.nodes}
|
||||
|
||||
for edges in self.adjacency_list.values():
|
||||
for target in edges:
|
||||
in_degree[target] += 1
|
||||
|
||||
queue = [node for node, degree in in_degree.items() if degree == 0]
|
||||
levels = defaultdict(set)
|
||||
level = 0
|
||||
|
||||
while queue:
|
||||
next_queue = []
|
||||
for node in queue:
|
||||
levels[level].add(node)
|
||||
|
||||
for neighbor in self.adjacency_list[node]:
|
||||
in_degree[neighbor] -= 1
|
||||
if in_degree[neighbor] == 0:
|
||||
next_queue.append(neighbor)
|
||||
|
||||
queue = next_queue
|
||||
level += 1
|
||||
|
||||
return dict(levels)
|
||||
|
||||
def _validate_graph(self) -> None:
|
||||
"""Validate graph structure."""
|
||||
# Check entry point exists
|
||||
if self.config.entry_point not in self.nodes:
|
||||
raise ValueError(
|
||||
f"Entry point '{self.config.entry_point}' not found in nodes"
|
||||
)
|
||||
|
||||
# Check all edges reference valid nodes
|
||||
for edge in self.config.edges:
|
||||
if edge.source not in self.nodes:
|
||||
raise ValueError(f"Edge source '{edge.source}' not found in nodes")
|
||||
if edge.target not in self.nodes:
|
||||
raise ValueError(f"Edge target '{edge.target}' not found in nodes")
|
||||
|
||||
# Warn about unreachable nodes
|
||||
reachable = self._find_reachable_nodes(self.config.entry_point)
|
||||
unreachable = set(self.nodes.keys()) - reachable
|
||||
if unreachable:
|
||||
self.logger.warning("Unreachable nodes in graph: %s", unreachable)
|
||||
|
||||
def _find_reachable_nodes(self, start: str) -> Set[str]:
|
||||
"""Find all nodes reachable from start node."""
|
||||
visited = set()
|
||||
queue = [start]
|
||||
|
||||
while queue:
|
||||
node = queue.pop(0)
|
||||
if node in visited:
|
||||
continue
|
||||
|
||||
visited.add(node)
|
||||
# Only add neighbors that haven't been visited or queued
|
||||
for neighbor in self.adjacency_list[node]:
|
||||
if neighbor not in visited and neighbor not in queue:
|
||||
queue.append(neighbor)
|
||||
|
||||
return visited
|
||||
|
||||
async def execute(self, input_data: Optional[dict[str, Any]] = None) -> GraphState:
|
||||
"""Execute the graph with optional input data."""
|
||||
if self.is_running:
|
||||
raise RuntimeError("Graph is already running")
|
||||
|
||||
self.is_running = True
|
||||
self.execution_history.clear()
|
||||
|
||||
try:
|
||||
# Initialize state with input data
|
||||
if input_data:
|
||||
if "messages" in input_data:
|
||||
self.state_manager.update_state(
|
||||
{"messages": input_data["messages"]},
|
||||
mode=StateUpdateMode.APPEND,
|
||||
)
|
||||
else:
|
||||
# Wrap input as a message
|
||||
self.state_manager.update_state(
|
||||
{"messages": [{"role": "user", "content": input_data}]},
|
||||
mode=StateUpdateMode.APPEND,
|
||||
)
|
||||
|
||||
# Pass through metadata (like _unsafe_mode) to state
|
||||
if "metadata" in input_data:
|
||||
self.state_manager.update_state(
|
||||
{"metadata": input_data["metadata"]},
|
||||
mode=StateUpdateMode.MERGE,
|
||||
)
|
||||
|
||||
# Execute from entry point
|
||||
await self._execute_from_node(self.config.entry_point)
|
||||
|
||||
# Return final state
|
||||
final_state = self.state_manager.get_state()
|
||||
return final_state
|
||||
|
||||
finally:
|
||||
self.is_running = False
|
||||
|
||||
async def _execute_from_node(self, node_name: str) -> None:
|
||||
"""Execute graph starting from a specific node."""
|
||||
# Create execution queue
|
||||
queue = [node_name]
|
||||
executed: set[str] = set()
|
||||
|
||||
while queue:
|
||||
# Get next node(s) to execute
|
||||
newly_executed = set()
|
||||
|
||||
if self.config.parallel_execution:
|
||||
# Find all nodes that can execute in parallel
|
||||
ready_nodes = []
|
||||
for node in queue[:]:
|
||||
if self._can_execute_node(node, executed):
|
||||
ready_nodes.append(node)
|
||||
queue.remove(node)
|
||||
|
||||
if ready_nodes:
|
||||
# Execute in parallel
|
||||
await self._execute_nodes_parallel(ready_nodes)
|
||||
newly_executed.update(ready_nodes)
|
||||
executed.update(ready_nodes)
|
||||
else:
|
||||
# Sequential execution
|
||||
node = queue.pop(0)
|
||||
if node not in executed and self._can_execute_node(node, executed):
|
||||
await self._execute_node(node)
|
||||
newly_executed.add(node)
|
||||
executed.add(node)
|
||||
|
||||
# Add next nodes to queue (only from newly executed nodes)
|
||||
for node in newly_executed:
|
||||
next_nodes = self._get_next_nodes(node)
|
||||
for next_node in next_nodes:
|
||||
if next_node not in executed and next_node not in queue:
|
||||
queue.append(next_node)
|
||||
|
||||
# Break if no progress was made
|
||||
if not newly_executed and queue:
|
||||
# No nodes could be executed, but queue isn't empty
|
||||
# This might indicate missing dependencies or graph issues
|
||||
break
|
||||
|
||||
def _can_execute_node(self, node_name: str, executed: Set[str]) -> bool:
|
||||
"""Check if a node can be executed."""
|
||||
# For conditional routing graphs, a node can execute if:
|
||||
# 1. At least one predecessor has been executed AND
|
||||
# 2. There exists at least one satisfied edge from an executed predecessor
|
||||
|
||||
predecessors = self.reverse_adjacency_list[node_name]
|
||||
|
||||
# If no predecessors, node can always execute (like 'start' node)
|
||||
if not predecessors:
|
||||
return True
|
||||
|
||||
# Check if there's at least one executed predecessor with a satisfied edge condition
|
||||
state = self.state_manager.get_state()
|
||||
|
||||
for predecessor in predecessors:
|
||||
if predecessor in executed:
|
||||
# Find the edge from this predecessor to our target node
|
||||
for edge in self.config.edges:
|
||||
if edge.source == predecessor and edge.target == node_name:
|
||||
# Check if this edge condition is satisfied
|
||||
pred_node = self.nodes[predecessor]
|
||||
if pred_node.evaluate_edge_condition(edge, state):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def _execute_node(self, node_name: str) -> None:
|
||||
"""Execute a single node."""
|
||||
stream_name = f"__{self.name}_node_{node_name}__"
|
||||
|
||||
# Send message to node stream
|
||||
self.stream_router.send_message(
|
||||
stream_name,
|
||||
content={"execute": True},
|
||||
metadata={"node": node_name},
|
||||
)
|
||||
|
||||
# Wait a bit for execution to complete
|
||||
# This ensures the executor function has time to run
|
||||
await asyncio.sleep(0.1)
|
||||
|
||||
async def _execute_nodes_parallel(self, node_names: List[str]) -> None:
|
||||
"""Execute multiple nodes in parallel."""
|
||||
tasks = []
|
||||
for node_name in node_names:
|
||||
task = asyncio.create_task(self._execute_node(node_name))
|
||||
tasks.append(task)
|
||||
|
||||
await asyncio.gather(*tasks)
|
||||
|
||||
def _get_next_nodes(self, node_name: str) -> List[str]:
|
||||
"""Get next nodes to execute based on edges and conditions."""
|
||||
node = self.nodes[node_name]
|
||||
state = self.state_manager.get_state()
|
||||
|
||||
next_nodes = []
|
||||
for edge in self.config.edges:
|
||||
if edge.source == node_name:
|
||||
# Evaluate edge condition
|
||||
if node.evaluate_edge_condition(edge, state):
|
||||
next_nodes.append(edge.target)
|
||||
|
||||
return next_nodes
|
||||
|
||||
def add_node(self, node_config: NodeConfig) -> None:
|
||||
"""Add a node to the graph."""
|
||||
if node_config.name in self.nodes:
|
||||
raise ValueError(f"Node '{node_config.name}' already exists")
|
||||
|
||||
self.config.nodes[node_config.name] = node_config
|
||||
self.nodes[node_config.name] = Node(node_config, self.agents)
|
||||
|
||||
# Create stream for new node
|
||||
self._register_node_executor(node_config.name)
|
||||
|
||||
# Re-analyze graph
|
||||
self._analyze_graph()
|
||||
|
||||
def add_edge(self, edge: Edge) -> None:
|
||||
"""Add an edge to the graph."""
|
||||
if edge.source not in self.nodes:
|
||||
raise ValueError(f"Source node '{edge.source}' not found")
|
||||
if edge.target not in self.nodes:
|
||||
raise ValueError(f"Target node '{edge.target}' not found")
|
||||
|
||||
self.config.edges.append(edge)
|
||||
|
||||
# Re-analyze graph
|
||||
self._analyze_graph()
|
||||
|
||||
def get_state(self) -> GraphState:
|
||||
"""Get current graph state."""
|
||||
return self.state_manager.get_state()
|
||||
|
||||
def get_execution_history(self) -> List[str]:
|
||||
"""Get execution history."""
|
||||
return self.execution_history.copy()
|
||||
|
||||
def visualize(self, output_format: str = "mermaid") -> str:
|
||||
"""Visualize the graph."""
|
||||
if output_format != "mermaid":
|
||||
return f"Format {output_format} not supported"
|
||||
|
||||
lines = ["graph TD"]
|
||||
|
||||
# Add nodes
|
||||
for node_name, node in self.nodes.items():
|
||||
shape = self._get_node_shape(node.type)
|
||||
lines.append(f" {node_name}{shape}")
|
||||
|
||||
# Add edges
|
||||
for edge in self.config.edges:
|
||||
if edge.condition:
|
||||
label = f"|{edge.condition.get('type', 'condition')}|"
|
||||
lines.append(f" {edge.source} --{label}--> {edge.target}")
|
||||
else:
|
||||
lines.append(f" {edge.source} --> {edge.target}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _get_node_shape(self, node_type: NodeType) -> str:
|
||||
"""Get Mermaid shape for node type."""
|
||||
shapes = {
|
||||
NodeType.START: "((Start))",
|
||||
NodeType.END: "((End))",
|
||||
NodeType.AGENT: "[Agent]",
|
||||
NodeType.FUNCTION: "[Function]",
|
||||
NodeType.TOOL: "[/Tool/]",
|
||||
NodeType.CONDITIONAL: "{Conditional}",
|
||||
NodeType.SUBGRAPH: "[[Subgraph]]",
|
||||
}
|
||||
return shapes.get(node_type, "[Node]")
|
||||
@@ -0,0 +1,411 @@
|
||||
"""
|
||||
Node definitions for LangGraph integration.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.agents.tool import ToolAgent
|
||||
from cleveragents.langgraph.state import GraphState
|
||||
|
||||
|
||||
class NodeType(Enum):
|
||||
"""Types of nodes in a LangGraph."""
|
||||
|
||||
AGENT = "agent"
|
||||
FUNCTION = "function"
|
||||
TOOL = "tool"
|
||||
CONDITIONAL = "conditional"
|
||||
SUBGRAPH = "subgraph"
|
||||
START = "start"
|
||||
END = "end"
|
||||
|
||||
|
||||
@dataclass
|
||||
class NodeConfig: # pylint: disable=too-many-instance-attributes
|
||||
"""Configuration for a graph node."""
|
||||
|
||||
name: str
|
||||
type: NodeType
|
||||
agent: Optional[str] = None
|
||||
function: Optional[str] = None
|
||||
tools: List[str] = field(default_factory=list)
|
||||
retry_policy: Optional[dict[str, Any]] = None
|
||||
timeout: Optional[float] = None
|
||||
parallel: bool = False
|
||||
condition: Optional[dict[str, Any]] = None
|
||||
subgraph: Optional[str] = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Edge:
|
||||
"""Edge between nodes in a graph."""
|
||||
|
||||
source: str
|
||||
target: str
|
||||
condition: Optional[dict[str, Any]] = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class Node: # pylint: disable=too-many-instance-attributes
|
||||
"""
|
||||
A node in a LangGraph.
|
||||
|
||||
Nodes can be agents, functions, tools, conditionals, or subgraphs.
|
||||
"""
|
||||
|
||||
def __init__(self, config: NodeConfig, agents: Optional[dict[str, Agent]] = None):
|
||||
"""Initialize node."""
|
||||
self.config = config
|
||||
self.name = config.name
|
||||
self.type = config.type
|
||||
self.agents = agents or {}
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
# Execution tracking
|
||||
self.execution_count = 0
|
||||
self.last_execution_time: Optional[float] = None
|
||||
self.last_error: Optional[Exception] = None
|
||||
|
||||
async def execute( # pylint: disable=too-many-branches,too-many-statements
|
||||
self, state: GraphState
|
||||
) -> dict[str, Any]:
|
||||
"""Execute the node and return state updates."""
|
||||
self.execution_count += 1
|
||||
loop = asyncio.get_running_loop()
|
||||
start_time = loop.time()
|
||||
|
||||
try:
|
||||
# Update current node in state
|
||||
state_updates = {"current_node": self.name}
|
||||
|
||||
# Execute based on node type
|
||||
if self.type == NodeType.AGENT:
|
||||
result = await self._execute_agent(state)
|
||||
elif self.type == NodeType.FUNCTION:
|
||||
result = await self._execute_function(state)
|
||||
elif self.type == NodeType.TOOL:
|
||||
result = await self._execute_tool()
|
||||
elif self.type == NodeType.CONDITIONAL:
|
||||
result = await self._execute_conditional(state)
|
||||
elif self.type == NodeType.SUBGRAPH:
|
||||
result = await self._execute_subgraph(state)
|
||||
elif self.type == NodeType.START:
|
||||
result = {"started": True}
|
||||
elif self.type == NodeType.END:
|
||||
result = {"completed": True}
|
||||
else:
|
||||
result = {}
|
||||
|
||||
# Merge results into state updates
|
||||
if isinstance(result, dict):
|
||||
state_updates.update(result)
|
||||
|
||||
self.last_error = None
|
||||
return state_updates
|
||||
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
# Intentionally catch all exceptions to handle node failures gracefully
|
||||
self.last_error = e
|
||||
self.logger.error("Node %s execution failed: %s", self.name, e)
|
||||
|
||||
# Handle retry policy
|
||||
if self.config.retry_policy:
|
||||
retry_count = self.config.retry_policy.get("max_retries", 3)
|
||||
retry_delay = self.config.retry_policy.get("delay", 1.0)
|
||||
|
||||
for retry_attempt in range(retry_count):
|
||||
await asyncio.sleep(retry_delay)
|
||||
self.execution_count += 1
|
||||
try:
|
||||
# Execute based on node type
|
||||
if self.type == NodeType.AGENT:
|
||||
result = await self._execute_agent(state)
|
||||
elif self.type == NodeType.FUNCTION:
|
||||
result = await self._execute_function(state)
|
||||
elif self.type == NodeType.TOOL:
|
||||
result = await self._execute_tool()
|
||||
elif self.type == NodeType.CONDITIONAL:
|
||||
result = await self._execute_conditional(state)
|
||||
elif self.type == NodeType.SUBGRAPH:
|
||||
result = await self._execute_subgraph(state)
|
||||
elif self.type == NodeType.START:
|
||||
result = {"started": True}
|
||||
elif self.type == NodeType.END:
|
||||
result = {"completed": True}
|
||||
else:
|
||||
result = {}
|
||||
|
||||
# Merge results into state updates
|
||||
state_updates = {"current_node": self.name}
|
||||
if isinstance(result, dict):
|
||||
state_updates.update(result)
|
||||
|
||||
self.last_error = None
|
||||
return state_updates
|
||||
except Exception as retry_e: # pylint: disable=broad-exception-caught
|
||||
# Retry on any exception during retry attempts
|
||||
self.last_error = retry_e
|
||||
if retry_attempt == retry_count - 1:
|
||||
# Last retry failed, fall through to return error
|
||||
e = retry_e
|
||||
continue
|
||||
|
||||
# Return error state
|
||||
return {
|
||||
"error": str(e),
|
||||
"failed_node": self.name,
|
||||
}
|
||||
finally:
|
||||
self.last_execution_time = asyncio.get_event_loop().time() - start_time
|
||||
|
||||
async def _execute_agent(self, state: GraphState) -> dict[str, Any]:
|
||||
"""Execute an agent node."""
|
||||
if not self.config.agent:
|
||||
raise ValueError(f"Agent node {self.name} has no agent specified")
|
||||
|
||||
agent = self.agents.get(self.config.agent)
|
||||
if not agent:
|
||||
raise ValueError(f"Agent {self.config.agent} not found")
|
||||
|
||||
# Prepare input for agent
|
||||
if state.messages:
|
||||
# Check if this is a tool agent that should receive the last message
|
||||
# (regardless of role) to process commands from other agents
|
||||
if isinstance(agent, ToolAgent):
|
||||
# Tool agents receive the last message (likely from another agent)
|
||||
agent_input = state.messages[-1].get("content", "")
|
||||
else:
|
||||
# LLM agents look for the last user message
|
||||
last_user_message = None
|
||||
for msg in reversed(state.messages):
|
||||
if msg.get("role") == "user":
|
||||
last_user_message = msg.get("content", "")
|
||||
break
|
||||
|
||||
agent_input = last_user_message or state.messages[-1].get("content", "")
|
||||
else:
|
||||
agent_input = ""
|
||||
|
||||
# Pass full conversation history in context so agent can see previous responses
|
||||
context = {
|
||||
"graph_state": state.to_dict(),
|
||||
"conversation_history": state.messages,
|
||||
"full_context": True
|
||||
}
|
||||
|
||||
# Pass through important metadata like unsafe mode
|
||||
if state.metadata:
|
||||
context.update(state.metadata)
|
||||
|
||||
# Execute agent with full context
|
||||
try:
|
||||
agent_response = await agent.process_message(agent_input, context)
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
# Catch all agent exceptions to prevent node failure
|
||||
self.logger.error("Agent %s execution failed: %s", agent.name, e)
|
||||
agent_response = f"Error processing message: {str(e)}"
|
||||
|
||||
# Return state updates
|
||||
return {
|
||||
"messages": [
|
||||
{
|
||||
"role": "assistant",
|
||||
"content": agent_response,
|
||||
"node": self.name,
|
||||
"agent": self.config.agent,
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
async def _execute_function(self, state: GraphState) -> dict[str, Any]:
|
||||
"""Execute a function node."""
|
||||
if not self.config.function:
|
||||
raise ValueError(f"Function node {self.name} has no function specified")
|
||||
|
||||
# Get function from registered functions
|
||||
# For now, we'll implement some built-in functions
|
||||
func_name = self.config.function
|
||||
|
||||
if func_name == "summarize":
|
||||
messages = state.messages
|
||||
summary = f"Summary of {len(messages)} messages"
|
||||
return {"metadata": {"summary": summary}}
|
||||
|
||||
if func_name == "route":
|
||||
# Simple routing based on message content
|
||||
if state.messages:
|
||||
last_msg = state.messages[-1].get("content", "")
|
||||
if "?" in last_msg:
|
||||
return {"metadata": {"route": "question"}}
|
||||
return {"metadata": {"route": "statement"}}
|
||||
return {"metadata": {"route": "default"}}
|
||||
|
||||
if func_name == "validate":
|
||||
# Validate state
|
||||
is_valid = len(state.messages) > 0 and not state.error
|
||||
return {"metadata": {"valid": is_valid}}
|
||||
|
||||
return {}
|
||||
|
||||
async def _execute_tool(self) -> dict[str, Any]:
|
||||
"""Execute a tool node."""
|
||||
if not self.config.tools:
|
||||
return {}
|
||||
|
||||
# Execute tools (simplified)
|
||||
tool_results = []
|
||||
for tool_name in self.config.tools:
|
||||
result = {
|
||||
"tool": tool_name,
|
||||
"result": f"Executed {tool_name}",
|
||||
}
|
||||
tool_results.append(result)
|
||||
|
||||
return {"metadata": {"tool_results": tool_results}}
|
||||
|
||||
async def _execute_conditional( # pylint: disable=too-many-branches,too-many-statements
|
||||
self, state: GraphState
|
||||
) -> dict[str, Any]:
|
||||
"""Execute a conditional node."""
|
||||
if not self.config.condition:
|
||||
return {"metadata": {"condition_result": True}}
|
||||
|
||||
condition = self.config.condition
|
||||
condition_type = condition.get("type", "always")
|
||||
|
||||
if condition_type == "always":
|
||||
result = True
|
||||
elif condition_type == "never":
|
||||
result = False
|
||||
elif condition_type == "has_messages":
|
||||
result = len(state.messages) > 0
|
||||
elif condition_type == "message_count":
|
||||
operator = condition.get("operator", "gt")
|
||||
value = condition.get("value", 0)
|
||||
count = len(state.messages)
|
||||
|
||||
if operator == "gt":
|
||||
result = count > value
|
||||
elif operator == "lt":
|
||||
result = count < value
|
||||
elif operator == "eq":
|
||||
result = count == value
|
||||
else:
|
||||
result = True
|
||||
elif condition_type == "metadata_check":
|
||||
key = condition.get("key", "")
|
||||
expected = condition.get("value")
|
||||
actual = state.metadata.get(key)
|
||||
result = actual == expected
|
||||
elif condition_type == "content_contains":
|
||||
text = condition.get("text", "")
|
||||
# Check the last message content
|
||||
if state.messages:
|
||||
last_message = state.messages[-1]
|
||||
content_str = str(last_message.get("content", ""))
|
||||
result = text in content_str
|
||||
else:
|
||||
result = False
|
||||
elif condition_type == "content_not_contains":
|
||||
text = condition.get("text", "")
|
||||
# Check the last message content
|
||||
if state.messages:
|
||||
last_message = state.messages[-1]
|
||||
content_str = str(last_message.get("content", ""))
|
||||
result = text not in content_str
|
||||
else:
|
||||
result = True # If no messages, then text is not contained
|
||||
elif condition_type == "content_starts_with":
|
||||
text = condition.get("text", "")
|
||||
# Check the last message content
|
||||
if state.messages:
|
||||
last_message = state.messages[-1]
|
||||
content_str = str(last_message.get("content", ""))
|
||||
result = content_str.startswith(text)
|
||||
else:
|
||||
result = False
|
||||
elif condition_type == "custom":
|
||||
# Allow custom condition functions
|
||||
func = condition.get("function")
|
||||
if func and callable(func):
|
||||
result = func(state)
|
||||
else:
|
||||
result = True
|
||||
else:
|
||||
result = True
|
||||
|
||||
return {"metadata": {"condition_result": result}}
|
||||
|
||||
async def _execute_subgraph(self, _state: GraphState) -> dict[str, Any]:
|
||||
"""Execute a subgraph node."""
|
||||
if not self.config.subgraph:
|
||||
raise ValueError(f"Subgraph node {self.name} has no subgraph specified")
|
||||
|
||||
# Subgraph execution would be handled by the main graph executor
|
||||
# For now, just mark it
|
||||
return {
|
||||
"metadata": {
|
||||
"subgraph": self.config.subgraph,
|
||||
"subgraph_pending": True,
|
||||
}
|
||||
}
|
||||
|
||||
def can_execute_parallel(self) -> bool:
|
||||
"""Check if this node can execute in parallel with others."""
|
||||
return self.config.parallel
|
||||
|
||||
def get_timeout(self) -> Optional[float]:
|
||||
"""Get execution timeout for this node."""
|
||||
return self.config.timeout
|
||||
|
||||
def get_edges(self, edges: List[Edge]) -> List[Edge]:
|
||||
"""Get outgoing edges from this node."""
|
||||
return [e for e in edges if e.source == self.name]
|
||||
|
||||
def evaluate_edge_condition(self, edge: Edge, state: GraphState) -> bool:
|
||||
"""Evaluate if an edge condition is met."""
|
||||
if not edge.condition:
|
||||
return True
|
||||
|
||||
condition = edge.condition
|
||||
_ = condition.get("type", "always") # unused but extracted for consistency
|
||||
|
||||
# Reuse conditional logic
|
||||
temp_node = Node(
|
||||
NodeConfig(
|
||||
name="temp_condition",
|
||||
type=NodeType.CONDITIONAL,
|
||||
condition=condition,
|
||||
)
|
||||
)
|
||||
|
||||
# Run condition evaluation, handling nested event loops
|
||||
try:
|
||||
# Check if we're already in an event loop
|
||||
asyncio.get_running_loop()
|
||||
# We're in an event loop, use asyncio.run in a separate thread
|
||||
import concurrent.futures # pylint: disable=import-outside-toplevel
|
||||
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(
|
||||
asyncio.run, temp_node._execute_conditional(state) # pylint: disable=protected-access
|
||||
)
|
||||
result = future.result()
|
||||
except RuntimeError:
|
||||
# No event loop running, we can use run_until_complete
|
||||
loop = asyncio.get_event_loop()
|
||||
result = loop.run_until_complete(temp_node._execute_conditional(state)) # pylint: disable=protected-access
|
||||
|
||||
condition_result = result.get("metadata", {}).get("condition_result", True)
|
||||
if not isinstance(condition_result, bool):
|
||||
return bool(condition_result)
|
||||
return condition_result
|
||||
@@ -0,0 +1,246 @@
|
||||
"""
|
||||
State management for LangGraph integration.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from datetime import datetime, timezone
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Type
|
||||
from typing import TypeVar
|
||||
|
||||
from rx.core import Observable # type: ignore[attr-defined]
|
||||
from rx.subject import BehaviorSubject # type: ignore[attr-defined]
|
||||
|
||||
T = TypeVar("T", bound="GraphState")
|
||||
|
||||
|
||||
class StateUpdateMode(Enum):
|
||||
"""How state updates are applied."""
|
||||
|
||||
REPLACE = "replace"
|
||||
MERGE = "merge"
|
||||
APPEND = "append"
|
||||
|
||||
|
||||
@dataclass
|
||||
class StateSnapshot:
|
||||
"""Snapshot of graph state at a point in time."""
|
||||
|
||||
state: dict[str, Any]
|
||||
timestamp: datetime
|
||||
node_id: Optional[str] = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class GraphState:
|
||||
"""
|
||||
Base class for graph state.
|
||||
|
||||
This represents the shared state that flows through a LangGraph.
|
||||
Users can extend this class to define custom state schemas.
|
||||
"""
|
||||
|
||||
messages: List[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
current_node: Optional[str] = None
|
||||
execution_count: int = 0
|
||||
error: Optional[str] = None
|
||||
|
||||
def update( # pylint: disable=too-many-branches
|
||||
self, updates: dict[str, Any], mode: StateUpdateMode = StateUpdateMode.MERGE
|
||||
) -> None:
|
||||
"""Update state based on mode."""
|
||||
if mode == StateUpdateMode.REPLACE:
|
||||
for key, value in updates.items():
|
||||
if hasattr(self, key):
|
||||
setattr(self, key, value)
|
||||
elif mode == StateUpdateMode.MERGE:
|
||||
for key, value in updates.items():
|
||||
if hasattr(self, key):
|
||||
current = getattr(self, key)
|
||||
if isinstance(current, dict) and isinstance(value, dict):
|
||||
current.update(value)
|
||||
elif isinstance(current, list) and isinstance(value, list):
|
||||
current.extend(value)
|
||||
else:
|
||||
setattr(self, key, value)
|
||||
elif mode == StateUpdateMode.APPEND:
|
||||
for key, value in updates.items():
|
||||
if hasattr(self, key):
|
||||
current = getattr(self, key)
|
||||
if isinstance(current, list):
|
||||
if isinstance(value, list):
|
||||
current.extend(value)
|
||||
else:
|
||||
current.append(value)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
"""Convert state to dictionary."""
|
||||
return {
|
||||
"messages": self.messages,
|
||||
"metadata": self.metadata,
|
||||
"current_node": self.current_node,
|
||||
"execution_count": self.execution_count,
|
||||
"error": self.error,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls: Type[T], data: dict[str, Any]) -> T:
|
||||
"""Create state from dictionary."""
|
||||
return cls(**data)
|
||||
|
||||
|
||||
class StateManager: # pylint: disable=too-many-instance-attributes
|
||||
"""
|
||||
Manages graph state with checkpointing and persistence.
|
||||
|
||||
Integrates with RxPy for reactive state updates.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
initial_state: Optional[GraphState] = None,
|
||||
checkpoint_dir: Optional[Path] = None,
|
||||
enable_time_travel: bool = False,
|
||||
):
|
||||
"""Initialize state manager."""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self.state = initial_state or GraphState()
|
||||
self.checkpoint_dir = checkpoint_dir
|
||||
self.enable_time_travel = enable_time_travel
|
||||
|
||||
# Reactive state stream
|
||||
self.state_stream = BehaviorSubject(self.state)
|
||||
|
||||
# History tracking for time travel
|
||||
self.history: List[StateSnapshot] = []
|
||||
self.max_history_size = 100
|
||||
|
||||
# Checkpoint management
|
||||
self.checkpoint_interval = 10 # Save every N updates
|
||||
self.update_count = 0
|
||||
|
||||
# Create checkpoint directory if needed
|
||||
if self.checkpoint_dir:
|
||||
self.checkpoint_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def get_state(self) -> GraphState:
|
||||
"""Get current state."""
|
||||
return self.state
|
||||
|
||||
def update_state(
|
||||
self,
|
||||
updates: dict[str, Any],
|
||||
mode: StateUpdateMode = StateUpdateMode.MERGE,
|
||||
node_id: Optional[str] = None,
|
||||
) -> GraphState:
|
||||
"""Update state and emit to stream."""
|
||||
# Create snapshot before update
|
||||
if self.enable_time_travel:
|
||||
snapshot = StateSnapshot(
|
||||
state=self.state.to_dict(),
|
||||
timestamp=datetime.now(timezone.utc),
|
||||
node_id=node_id,
|
||||
)
|
||||
self.history.append(snapshot)
|
||||
|
||||
# Trim history if needed
|
||||
if len(self.history) > self.max_history_size:
|
||||
self.history = self.history[-self.max_history_size :]
|
||||
|
||||
# Update state
|
||||
self.state.update(updates, mode)
|
||||
self.state.execution_count += 1
|
||||
|
||||
# Emit updated state
|
||||
self.state_stream.on_next(self.state)
|
||||
|
||||
# Checkpoint if needed
|
||||
self.update_count += 1
|
||||
if self.checkpoint_dir and self.update_count % self.checkpoint_interval == 0:
|
||||
self._save_checkpoint()
|
||||
|
||||
return self.state
|
||||
|
||||
def _save_checkpoint(self) -> None:
|
||||
"""Save state checkpoint."""
|
||||
if not self.checkpoint_dir:
|
||||
return
|
||||
timestamp = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
|
||||
checkpoint_file = self.checkpoint_dir / f"checkpoint_{timestamp}.json"
|
||||
|
||||
checkpoint_data = {
|
||||
"state": self.state.to_dict(),
|
||||
"timestamp": timestamp,
|
||||
"update_count": self.update_count,
|
||||
}
|
||||
|
||||
with open(checkpoint_file, "w", encoding="utf-8") as f:
|
||||
json.dump(checkpoint_data, f, indent=2)
|
||||
|
||||
self.logger.debug("Saved checkpoint: %s", checkpoint_file)
|
||||
|
||||
def load_checkpoint(self, checkpoint_file: Path) -> None:
|
||||
"""Load state from checkpoint."""
|
||||
with open(checkpoint_file, "r", encoding="utf-8") as f:
|
||||
checkpoint_data = json.load(f)
|
||||
|
||||
self.state = GraphState.from_dict(checkpoint_data["state"])
|
||||
self.update_count = checkpoint_data.get("update_count", 0)
|
||||
|
||||
# Emit loaded state
|
||||
self.state_stream.on_next(self.state)
|
||||
|
||||
self.logger.info("Loaded checkpoint: %s", checkpoint_file)
|
||||
|
||||
def get_latest_checkpoint(self) -> Optional[Path]:
|
||||
"""Get the most recent checkpoint file."""
|
||||
if not self.checkpoint_dir:
|
||||
return None
|
||||
|
||||
checkpoints = list(self.checkpoint_dir.glob("checkpoint_*.json"))
|
||||
if not checkpoints:
|
||||
return None
|
||||
|
||||
return max(checkpoints, key=lambda p: p.stat().st_mtime)
|
||||
|
||||
def time_travel(self, steps_back: int = 1) -> Optional[GraphState]:
|
||||
"""Go back in state history."""
|
||||
if not self.enable_time_travel or not self.history:
|
||||
return None
|
||||
|
||||
if steps_back >= len(self.history):
|
||||
steps_back = len(self.history) - 1
|
||||
|
||||
snapshot = self.history[-(steps_back + 1)]
|
||||
self.state = GraphState.from_dict(snapshot.state)
|
||||
|
||||
# Emit historical state
|
||||
self.state_stream.on_next(self.state)
|
||||
|
||||
return self.state
|
||||
|
||||
def get_state_observable(self) -> Observable:
|
||||
"""Get observable for state changes."""
|
||||
return self.state_stream
|
||||
|
||||
def clear_history(self) -> None:
|
||||
"""Clear state history."""
|
||||
self.history.clear()
|
||||
|
||||
def reset(self, initial_state: Optional[GraphState] = None) -> None:
|
||||
"""Reset to initial state."""
|
||||
self.state = initial_state or GraphState()
|
||||
self.update_count = 0
|
||||
self.history.clear()
|
||||
|
||||
# Emit reset state
|
||||
self.state_stream.on_next(self.state)
|
||||
@@ -0,0 +1,57 @@
|
||||
"""
|
||||
Module: network
|
||||
|
||||
This module implements the AgentNetwork class, which encapsulates the creation,
|
||||
management, and execution of an agent network.
|
||||
"""
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from cleveragents.agents.factory import AgentFactory
|
||||
from cleveragents.core.config import ConfigurationManager
|
||||
|
||||
|
||||
class AgentNetwork: # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
AgentNetwork represents a network of agents.
|
||||
|
||||
Attributes:
|
||||
config_manager (ConfigurationManager): Manager for configuration.
|
||||
agent_factory (AgentFactory): Factory for creating agents.
|
||||
router: Router to handle message passing between agents.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
config_files: Optional[List[Path]] = None,
|
||||
_verbose: bool = False,
|
||||
) -> None:
|
||||
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
|
||||
|
||||
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.
|
||||
"""
|
||||
# Processing logic using the new reactive system is not yet implemented
|
||||
raise NotImplementedError(
|
||||
"AgentNetwork processing not yet implemented with new reactive system"
|
||||
)
|
||||
@@ -0,0 +1,6 @@
|
||||
"""
|
||||
Reactive streams module for CleverAgents.
|
||||
|
||||
This module provides RxPy-based reactive stream processing capabilities
|
||||
for agent orchestration and message routing.
|
||||
"""
|
||||
@@ -0,0 +1,480 @@
|
||||
"""
|
||||
Configuration parser for RxPy-based CleverAgents.
|
||||
|
||||
This module handles parsing of the new YAML configuration format
|
||||
that supports full RxPy reactive stream capabilities.
|
||||
"""
|
||||
# pylint: disable=duplicate-code
|
||||
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from pathlib import Path
|
||||
from re import Match
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
from cleveragents.reactive.route import BridgeConfig
|
||||
from cleveragents.reactive.route import RouteConfig
|
||||
from cleveragents.reactive.route import RouteType
|
||||
from cleveragents.reactive.stream_router import StreamType
|
||||
from cleveragents.templates.yaml_template_engine import YAMLTemplateEngine
|
||||
|
||||
|
||||
@dataclass
|
||||
class AgentConfig:
|
||||
"""Configuration for an agent."""
|
||||
|
||||
name: str
|
||||
type: str
|
||||
config: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LangGraphConfig: # pylint: disable=too-many-instance-attributes
|
||||
"""Configuration for a LangGraph."""
|
||||
|
||||
name: str
|
||||
nodes: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
edges: List[dict[str, Any]] = field(default_factory=list)
|
||||
entry_point: str = "start"
|
||||
checkpointing: bool = False
|
||||
checkpoint_dir: Optional[str] = None
|
||||
enable_time_travel: bool = False
|
||||
parallel_execution: bool = True
|
||||
state_class: Optional[str] = None
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
template_config: Optional[dict[str, Any]] = None # Template configuration
|
||||
|
||||
|
||||
@dataclass
|
||||
class HybridPipelineConfig:
|
||||
"""Configuration for hybrid RxPy-LangGraph pipelines."""
|
||||
|
||||
name: str
|
||||
stages: List[dict[str, Any]] = field(default_factory=list)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ReactiveConfig: # pylint: disable=too-many-instance-attributes
|
||||
"""Complete reactive configuration."""
|
||||
|
||||
agents: dict[str, AgentConfig] = field(default_factory=dict)
|
||||
routes: dict[str, RouteConfig] = field(default_factory=dict) # Unified routes
|
||||
merges: List[dict[str, Any]] = field(default_factory=list)
|
||||
splits: List[dict[str, Any]] = field(default_factory=list)
|
||||
pipelines: dict[str, HybridPipelineConfig] = field(default_factory=dict)
|
||||
templates: dict[str, dict[str, Any]] = field(
|
||||
default_factory=dict
|
||||
) # New: template definitions
|
||||
instances: dict[str, dict[str, Any]] = field(
|
||||
default_factory=dict
|
||||
) # New: template instances
|
||||
global_context: dict[str, Any] = field(default_factory=dict)
|
||||
template_engine: str = "JINJA2"
|
||||
prompts: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
class ReactiveConfigParser: # pylint: disable=too-few-public-methods
|
||||
"""Parser for reactive configuration files."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def parse_files(self, config_files: List[Path]) -> ReactiveConfig:
|
||||
"""Parse configuration from multiple files."""
|
||||
combined_config: dict[str, Any] = {}
|
||||
_all_template_sections: dict[str, Any] = {} # Reserved for future use
|
||||
|
||||
for file_path in config_files:
|
||||
self.logger.info("Loading configuration from %s", file_path)
|
||||
|
||||
# Check if this is a template file by looking for Jinja2 syntax
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
if "{%" in content or "{{" in content:
|
||||
# This file contains templates, use template engine
|
||||
engine = YAMLTemplateEngine()
|
||||
|
||||
# Process with deferred rendering
|
||||
file_config = engine.load_string(content, context=None)
|
||||
else:
|
||||
# Regular YAML file
|
||||
file_config = yaml.safe_load(content)
|
||||
|
||||
self._merge_configs(combined_config, file_config)
|
||||
|
||||
# Interpolate environment variables before building reactive config
|
||||
combined_config = self._interpolate_env_vars(combined_config)
|
||||
|
||||
return self._build_reactive_config(combined_config)
|
||||
|
||||
def _merge_configs(self, base: dict[str, Any], new: Optional[dict[str, Any]]) -> None:
|
||||
"""Merge configuration dictionaries."""
|
||||
if new is None:
|
||||
return
|
||||
for key, value in new.items():
|
||||
if key in base:
|
||||
if isinstance(base[key], dict) and isinstance(value, dict):
|
||||
self._merge_configs(base[key], value)
|
||||
elif isinstance(base[key], list) and isinstance(value, list):
|
||||
base[key].extend(value)
|
||||
else:
|
||||
base[key] = value
|
||||
else:
|
||||
base[key] = value
|
||||
|
||||
def _interpolate_env_vars(self, config: Any) -> Any:
|
||||
"""Interpolate environment variables in configuration values."""
|
||||
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):
|
||||
# Pattern to match ${VAR_NAME} or ${VAR_NAME:default_value}
|
||||
env_var_pattern = r"\${([A-Za-z0-9_]+)(?::([^}]*))?\}"
|
||||
|
||||
def replace_env_var(match: Match[str]) -> str:
|
||||
env_var = match.group(1)
|
||||
default_value = match.group(2) if match.group(2) is not None else None
|
||||
|
||||
env_value = os.environ.get(env_var)
|
||||
if env_value is None:
|
||||
if default_value is not None:
|
||||
# Convert default value to appropriate type
|
||||
if default_value.lower() in ("true", "false"):
|
||||
return str(default_value.lower() == "true")
|
||||
if default_value.isdigit():
|
||||
return default_value
|
||||
return default_value
|
||||
raise ConfigurationError(
|
||||
f"Environment variable '{env_var}' is not set"
|
||||
)
|
||||
return env_value
|
||||
|
||||
config = re.sub(env_var_pattern, replace_env_var, config)
|
||||
|
||||
# Convert numeric and boolean strings to appropriate types
|
||||
if config.lower() in ("true", "false"):
|
||||
return config.lower() == "true"
|
||||
# Handle integers (positive and negative)
|
||||
if config.lstrip('-').isdigit():
|
||||
return int(config)
|
||||
# Handle floats (positive and negative)
|
||||
if config.lstrip('-').replace(".", "", 1).isdigit() and config.count(".") == 1:
|
||||
return float(config)
|
||||
return config
|
||||
|
||||
def _build_reactive_config( # pylint: disable=too-many-locals,too-many-branches
|
||||
self, config_dict: dict[str, Any]
|
||||
) -> ReactiveConfig:
|
||||
"""Build reactive configuration from dictionary."""
|
||||
reactive_config = ReactiveConfig()
|
||||
|
||||
# Parse templates first
|
||||
templates_config = config_dict.get("templates", {})
|
||||
reactive_config.templates = templates_config
|
||||
|
||||
# Also check for template_strings (raw templates with Jinja2)
|
||||
template_strings = config_dict.get("template_strings", {})
|
||||
if template_strings:
|
||||
# Merge template strings into templates
|
||||
for template_type, templates in template_strings.items():
|
||||
if template_type not in reactive_config.templates:
|
||||
reactive_config.templates[template_type] = {}
|
||||
|
||||
for name, template_str in templates.items():
|
||||
# Store as a special marker that this needs preprocessing
|
||||
reactive_config.templates[template_type][name] = {
|
||||
"_raw_template": template_str,
|
||||
"_needs_preprocessing": True,
|
||||
}
|
||||
|
||||
# Parse instances
|
||||
instances_config = config_dict.get("instances", {})
|
||||
reactive_config.instances = instances_config
|
||||
|
||||
# Parse agents (both direct and from instances)
|
||||
agents_config = config_dict.get("agents", {})
|
||||
for name, agent_data in agents_config.items():
|
||||
if "template" in agent_data or "agent_template" in agent_data:
|
||||
# This is a template instance, store as-is for later instantiation
|
||||
reactive_config.agents[name] = AgentConfig(
|
||||
name=name,
|
||||
type="template_instance",
|
||||
config=agent_data,
|
||||
)
|
||||
else:
|
||||
# Direct agent definition
|
||||
reactive_config.agents[name] = AgentConfig(
|
||||
name=name,
|
||||
type=agent_data.get("type", "llm"),
|
||||
config=agent_data.get("config", {}),
|
||||
)
|
||||
|
||||
# Parse routes (unified system)
|
||||
routes_config = config_dict.get("routes", {})
|
||||
for name, route_data in routes_config.items():
|
||||
# Ensure type field is present and valid
|
||||
if "type" not in route_data:
|
||||
raise ConfigurationError(
|
||||
f"Route '{name}' must specify a 'type' field (stream, graph, or bridge)"
|
||||
)
|
||||
|
||||
route_type_str = route_data["type"].lower()
|
||||
try:
|
||||
route_type = RouteType(route_type_str)
|
||||
except ValueError as exc:
|
||||
raise ConfigurationError(
|
||||
f"Route '{name}' has invalid type '{route_type_str}'. "
|
||||
f"Must be one of: stream, graph, bridge"
|
||||
) from exc
|
||||
|
||||
# Handle template instances
|
||||
if "template" in route_data or "route_template" in route_data:
|
||||
# This is a template instance, store raw for later instantiation
|
||||
reactive_config.routes[name] = RouteConfig(
|
||||
name=name,
|
||||
type=route_type,
|
||||
template_config=route_data,
|
||||
)
|
||||
else:
|
||||
# Parse based on route type
|
||||
if route_type == RouteType.STREAM:
|
||||
reactive_config.routes[name] = self._parse_stream_route(
|
||||
name, route_data
|
||||
)
|
||||
elif route_type == RouteType.GRAPH:
|
||||
reactive_config.routes[name] = self._parse_graph_route(
|
||||
name, route_data
|
||||
)
|
||||
elif route_type == RouteType.BRIDGE:
|
||||
reactive_config.routes[name] = self._parse_bridge_route(
|
||||
name, route_data
|
||||
)
|
||||
|
||||
# Parse stream operations
|
||||
reactive_config.merges = config_dict.get("merges", [])
|
||||
reactive_config.splits = config_dict.get("splits", [])
|
||||
|
||||
# Removed legacy graphs parsing - use routes instead
|
||||
|
||||
# Parse hybrid pipelines
|
||||
pipelines_config = config_dict.get("pipelines", {})
|
||||
for name, pipeline_data in pipelines_config.items():
|
||||
reactive_config.pipelines[name] = HybridPipelineConfig(
|
||||
name=name,
|
||||
stages=pipeline_data.get("stages", []),
|
||||
metadata=pipeline_data.get("metadata", {}),
|
||||
)
|
||||
|
||||
# Parse global settings
|
||||
reactive_config.global_context = config_dict.get("context", {}).get(
|
||||
"global", {}
|
||||
)
|
||||
reactive_config.template_engine = config_dict.get("cleveragents", {}).get(
|
||||
"template_engine", "JINJA2"
|
||||
)
|
||||
reactive_config.prompts = config_dict.get("prompts", {})
|
||||
|
||||
self._validate_config(reactive_config)
|
||||
return reactive_config
|
||||
|
||||
def _parse_stream_route(self, name: str, route_data: dict[str, Any]) -> RouteConfig:
|
||||
"""Parse a stream-type route."""
|
||||
stream_type = StreamType(route_data.get("stream_type", "cold"))
|
||||
|
||||
# Parse bridge config if present
|
||||
bridge_config = None
|
||||
if "bridge" in route_data:
|
||||
bridge_data = route_data["bridge"]
|
||||
bridge_config = BridgeConfig(
|
||||
upgrade_conditions=bridge_data.get("upgrade_conditions", {}),
|
||||
downgrade_conditions=bridge_data.get("downgrade_conditions", {}),
|
||||
state_extractor=bridge_data.get("state_extractor"),
|
||||
state_flattener=bridge_data.get("state_flattener"),
|
||||
preserve_subscriptions=bridge_data.get("preserve_subscriptions", True),
|
||||
preserve_checkpointing=bridge_data.get("preserve_checkpointing", True),
|
||||
)
|
||||
|
||||
return RouteConfig(
|
||||
name=name,
|
||||
type=RouteType.STREAM,
|
||||
stream_type=stream_type,
|
||||
operators=route_data.get("operators", []),
|
||||
subscriptions=route_data.get("subscriptions", []),
|
||||
publications=route_data.get("publications", []),
|
||||
agents=route_data.get("agents", []),
|
||||
initial_value=route_data.get("initial_value"),
|
||||
buffer_size=route_data.get("buffer_size", 1),
|
||||
bridge=bridge_config,
|
||||
metadata=route_data.get("metadata", {}),
|
||||
)
|
||||
|
||||
def _parse_graph_route(self, name: str, route_data: dict[str, Any]) -> RouteConfig:
|
||||
"""Parse a graph-type route."""
|
||||
# Parse bridge config if present
|
||||
bridge_config = None
|
||||
if "bridge" in route_data:
|
||||
bridge_data = route_data["bridge"]
|
||||
bridge_config = BridgeConfig(
|
||||
upgrade_conditions=bridge_data.get("upgrade_conditions", {}),
|
||||
downgrade_conditions=bridge_data.get("downgrade_conditions", {}),
|
||||
state_extractor=bridge_data.get("state_extractor"),
|
||||
state_flattener=bridge_data.get("state_flattener"),
|
||||
preserve_subscriptions=bridge_data.get("preserve_subscriptions", True),
|
||||
preserve_checkpointing=bridge_data.get("preserve_checkpointing", True),
|
||||
)
|
||||
|
||||
return RouteConfig(
|
||||
name=name,
|
||||
type=RouteType.GRAPH,
|
||||
nodes=route_data.get("nodes", {}),
|
||||
edges=route_data.get("edges", []),
|
||||
entry_point=route_data.get("entry_point", "start"),
|
||||
checkpointing=route_data.get("checkpointing", False),
|
||||
checkpoint_dir=route_data.get("checkpoint_dir"),
|
||||
enable_time_travel=route_data.get("enable_time_travel", False),
|
||||
parallel_execution=route_data.get("parallel_execution", True),
|
||||
state_class=route_data.get("state_class"),
|
||||
bridge=bridge_config,
|
||||
metadata=route_data.get("metadata", {}),
|
||||
)
|
||||
|
||||
def _parse_bridge_route(self, name: str, route_data: dict[str, Any]) -> RouteConfig:
|
||||
"""Parse a bridge-type route."""
|
||||
# Bridge routes are special - they define conversion logic
|
||||
bridge_config = BridgeConfig(
|
||||
upgrade_conditions=route_data.get("upgrade_conditions", {}),
|
||||
downgrade_conditions=route_data.get("downgrade_conditions", {}),
|
||||
state_extractor=route_data.get("state_extractor"),
|
||||
state_flattener=route_data.get("state_flattener"),
|
||||
preserve_subscriptions=route_data.get("preserve_subscriptions", True),
|
||||
preserve_checkpointing=route_data.get("preserve_checkpointing", True),
|
||||
)
|
||||
|
||||
return RouteConfig(
|
||||
name=name,
|
||||
type=RouteType.BRIDGE,
|
||||
bridge=bridge_config,
|
||||
metadata=route_data.get("metadata", {}),
|
||||
)
|
||||
|
||||
def _validate_config( # pylint: disable=too-many-locals,too-many-branches
|
||||
self, config: ReactiveConfig
|
||||
) -> None:
|
||||
"""Validate the reactive configuration."""
|
||||
# Collect all route names
|
||||
all_route_names = set(config.routes.keys()) | {
|
||||
"__input__",
|
||||
"__output__",
|
||||
"__error__",
|
||||
}
|
||||
|
||||
# Validate routes
|
||||
for route_name, route_config in config.routes.items():
|
||||
if route_config.type == RouteType.STREAM:
|
||||
# Validate stream-type route
|
||||
for sub in route_config.subscriptions:
|
||||
if sub not in all_route_names:
|
||||
raise ConfigurationError(
|
||||
f"Route '{route_name}' references unknown subscription '{sub}'"
|
||||
)
|
||||
|
||||
for pub in route_config.publications:
|
||||
if pub not in all_route_names:
|
||||
raise ConfigurationError(
|
||||
f"Route '{route_name}' references unknown publication '{pub}'"
|
||||
)
|
||||
|
||||
for agent_name in route_config.agents:
|
||||
if agent_name not in config.agents:
|
||||
raise ConfigurationError(
|
||||
f"Route '{route_name}' references unknown agent '{agent_name}'"
|
||||
)
|
||||
|
||||
elif route_config.type == RouteType.GRAPH:
|
||||
# Validate graph-type route
|
||||
for node_name, node_data in route_config.nodes.items():
|
||||
if "agent" in node_data and node_data["agent"] not in config.agents:
|
||||
raise ConfigurationError(
|
||||
f"Route '{route_name}' node '{node_name}' references "
|
||||
f"unknown agent '{node_data['agent']}'"
|
||||
)
|
||||
|
||||
# Removed legacy stream validation - use routes instead
|
||||
|
||||
# Validate merge operations
|
||||
for merge in config.merges:
|
||||
sources = merge.get("sources", [])
|
||||
target = merge.get("target")
|
||||
|
||||
if not sources:
|
||||
raise ConfigurationError("Merge operation must specify source streams")
|
||||
|
||||
if not target:
|
||||
raise ConfigurationError("Merge operation must specify target stream")
|
||||
|
||||
for source in sources:
|
||||
if source not in all_route_names:
|
||||
raise ConfigurationError(
|
||||
f"Merge operation references unknown source route '{source}'"
|
||||
)
|
||||
|
||||
# Validate split operations
|
||||
for split in config.splits:
|
||||
source = split.get("source")
|
||||
targets = split.get("targets", {})
|
||||
|
||||
if not source:
|
||||
raise ConfigurationError("Split operation must specify source stream")
|
||||
|
||||
if not targets:
|
||||
raise ConfigurationError("Split operation must specify target streams")
|
||||
|
||||
if source not in all_route_names:
|
||||
raise ConfigurationError(
|
||||
f"Split operation references unknown source route '{source}'"
|
||||
)
|
||||
|
||||
# Removed legacy graph validation - use routes instead
|
||||
|
||||
# Validate hybrid pipelines
|
||||
for pipeline_name, pipeline_config in config.pipelines.items():
|
||||
for stage in pipeline_config.stages:
|
||||
stage_type = stage.get("type")
|
||||
|
||||
if stage_type == "graph":
|
||||
graph_name = stage.get("config", {}).get("name")
|
||||
# Check in routes instead of graphs
|
||||
graph_found = any(
|
||||
route.type == RouteType.GRAPH and route.name == graph_name
|
||||
for route in config.routes.values()
|
||||
)
|
||||
if graph_name and not graph_found:
|
||||
# Graph might be defined inline, so only warn
|
||||
self.logger.warning(
|
||||
"Pipeline '%s' references graph '%s' not found in routes",
|
||||
pipeline_name,
|
||||
graph_name,
|
||||
)
|
||||
|
||||
elif stage_type == "stream":
|
||||
# Check routes instead of streams
|
||||
stream_name = stage.get("name", "")
|
||||
if stream_name and stream_name in config.routes:
|
||||
self.logger.warning(
|
||||
"Pipeline '%s' defines route '%s' that already exists",
|
||||
pipeline_name,
|
||||
stream_name,
|
||||
)
|
||||
|
||||
self.logger.info("Configuration validation completed successfully")
|
||||
@@ -0,0 +1,353 @@
|
||||
"""
|
||||
Unified route system for CleverAgents.
|
||||
|
||||
This module provides a unified abstraction for both reactive streams (RxPy)
|
||||
and stateful graphs (LangGraph), treating them as different types of routes
|
||||
for data flow through processing pipelines.
|
||||
"""
|
||||
# pylint: disable=duplicate-code
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from cleveragents.core.exceptions import ConfigurationError
|
||||
from cleveragents.langgraph.graph import GraphConfig
|
||||
from cleveragents.langgraph.nodes import Edge
|
||||
from cleveragents.langgraph.nodes import NodeConfig
|
||||
from cleveragents.langgraph.nodes import NodeType
|
||||
from cleveragents.reactive.stream_router import StreamConfig
|
||||
from cleveragents.reactive.stream_router import StreamType
|
||||
|
||||
|
||||
class RouteType(Enum):
|
||||
"""Types of routes available in the system."""
|
||||
|
||||
STREAM = "stream" # Reactive stream using RxPy
|
||||
GRAPH = "graph" # Stateful graph using LangGraph
|
||||
BRIDGE = "bridge" # Bridge for converting between types
|
||||
|
||||
|
||||
@dataclass
|
||||
class BridgeConfig:
|
||||
"""Configuration for type conversion/bridging between routes."""
|
||||
|
||||
# When to upgrade from stream to graph
|
||||
upgrade_conditions: dict[str, Any] = field(default_factory=dict)
|
||||
# When to downgrade from graph to stream
|
||||
downgrade_conditions: dict[str, Any] = field(default_factory=dict)
|
||||
# State extraction for stream->graph conversion
|
||||
state_extractor: Optional[str] = None
|
||||
# State flattener for graph->stream conversion
|
||||
state_flattener: Optional[str] = None
|
||||
# Preserve stream subscriptions during conversion
|
||||
preserve_subscriptions: bool = True
|
||||
# Preserve graph checkpointing during conversion
|
||||
preserve_checkpointing: bool = True
|
||||
|
||||
|
||||
@dataclass
|
||||
class RouteConfig: # pylint: disable=too-many-instance-attributes
|
||||
"""Unified configuration for all route types."""
|
||||
|
||||
name: str
|
||||
type: RouteType # REQUIRED field - must be prominent
|
||||
|
||||
# Stream-specific config (used when type=STREAM)
|
||||
stream_type: Optional[StreamType] = None
|
||||
operators: List[dict[str, Any]] = field(default_factory=list)
|
||||
subscriptions: List[str] = field(default_factory=list)
|
||||
publications: List[str] = field(default_factory=list)
|
||||
agents: List[str] = field(default_factory=list)
|
||||
initial_value: Optional[Any] = None
|
||||
buffer_size: int = 1
|
||||
|
||||
# Graph-specific config (used when type=GRAPH)
|
||||
nodes: dict[str, dict[str, Any]] = field(default_factory=dict)
|
||||
edges: List[dict[str, Any]] = field(default_factory=list)
|
||||
entry_point: str = "start"
|
||||
checkpointing: bool = False
|
||||
checkpoint_dir: Optional[str] = None
|
||||
enable_time_travel: bool = False
|
||||
parallel_execution: bool = True
|
||||
state_class: Optional[str] = None
|
||||
|
||||
# Bridge config (optional - for type conversion)
|
||||
bridge: Optional[BridgeConfig] = None
|
||||
|
||||
# Common metadata
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
template_config: Optional[dict[str, Any]] = None
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
"""Validate configuration based on route type."""
|
||||
if self.type == RouteType.STREAM:
|
||||
if not self.stream_type:
|
||||
self.stream_type = StreamType.COLD
|
||||
elif self.type == RouteType.GRAPH:
|
||||
if not self.nodes:
|
||||
raise ConfigurationError(
|
||||
f"Route '{self.name}' of type 'graph' must have nodes defined"
|
||||
)
|
||||
|
||||
def to_stream_config(self) -> StreamConfig:
|
||||
"""Convert to StreamConfig for stream routes."""
|
||||
if self.type != RouteType.STREAM:
|
||||
raise ValueError(f"Cannot convert {self.type} route to StreamConfig")
|
||||
|
||||
return StreamConfig(
|
||||
name=self.name,
|
||||
type=self.stream_type or StreamType.COLD,
|
||||
operators=self.operators,
|
||||
subscriptions=self.subscriptions,
|
||||
publications=self.publications,
|
||||
agents=self.agents,
|
||||
initial_value=self.initial_value,
|
||||
buffer_size=self.buffer_size,
|
||||
template_config=self.template_config,
|
||||
)
|
||||
|
||||
def to_graph_config(self) -> GraphConfig:
|
||||
"""Convert to GraphConfig for graph routes."""
|
||||
if self.type != RouteType.GRAPH:
|
||||
raise ValueError(f"Cannot convert {self.type} route to GraphConfig")
|
||||
|
||||
from pathlib import Path # pylint: disable=import-outside-toplevel
|
||||
|
||||
# Convert node dictionaries to NodeConfig objects
|
||||
node_configs = {}
|
||||
for node_name, node_data in self.nodes.items():
|
||||
node_type_str = node_data.get("type", "agent")
|
||||
node_type = NodeType[node_type_str.upper()]
|
||||
|
||||
node_configs[node_name] = NodeConfig(
|
||||
name=node_name,
|
||||
type=node_type,
|
||||
agent=node_data.get("agent"),
|
||||
function=node_data.get("function"),
|
||||
tools=node_data.get("tools", []),
|
||||
retry_policy=node_data.get("retry_policy"),
|
||||
timeout=node_data.get("timeout"),
|
||||
parallel=node_data.get("parallel", False),
|
||||
condition=node_data.get("condition"),
|
||||
subgraph=node_data.get("subgraph"),
|
||||
metadata=node_data.get("metadata", {}),
|
||||
)
|
||||
|
||||
# Convert edge dictionaries to Edge objects
|
||||
edge_objects = []
|
||||
for edge_data in self.edges:
|
||||
edge_objects.append(
|
||||
Edge(
|
||||
source=edge_data["source"],
|
||||
target=edge_data["target"],
|
||||
condition=edge_data.get("condition"),
|
||||
metadata=edge_data.get("metadata", {}),
|
||||
)
|
||||
)
|
||||
|
||||
return GraphConfig(
|
||||
name=self.name,
|
||||
nodes=node_configs,
|
||||
edges=edge_objects,
|
||||
entry_point=self.entry_point,
|
||||
state_class=None, # Will be resolved later from string
|
||||
checkpointing=self.checkpointing,
|
||||
checkpoint_dir=Path(self.checkpoint_dir) if self.checkpoint_dir else None,
|
||||
enable_time_travel=self.enable_time_travel,
|
||||
parallel_execution=self.parallel_execution,
|
||||
metadata=self.metadata,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_stream_config(cls, stream_config: StreamConfig) -> "RouteConfig":
|
||||
"""Create RouteConfig from existing StreamConfig."""
|
||||
return cls(
|
||||
name=stream_config.name,
|
||||
type=RouteType.STREAM,
|
||||
stream_type=stream_config.type,
|
||||
operators=stream_config.operators,
|
||||
subscriptions=stream_config.subscriptions,
|
||||
publications=stream_config.publications,
|
||||
agents=stream_config.agents,
|
||||
initial_value=stream_config.initial_value,
|
||||
buffer_size=stream_config.buffer_size,
|
||||
template_config=stream_config.template_config,
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def from_graph_config(cls, graph_config: GraphConfig) -> "RouteConfig":
|
||||
"""Create RouteConfig from existing GraphConfig."""
|
||||
# Convert nodes from NodeConfig to dict
|
||||
nodes_dict = {}
|
||||
for name, node_config in graph_config.nodes.items():
|
||||
node_dict: dict[str, Any] = {
|
||||
"type": node_config.type.value,
|
||||
"parallel": node_config.parallel,
|
||||
}
|
||||
if node_config.agent:
|
||||
node_dict["agent"] = node_config.agent
|
||||
if node_config.function:
|
||||
node_dict["function"] = node_config.function
|
||||
if node_config.tools:
|
||||
node_dict["tools"] = node_config.tools
|
||||
if node_config.retry_policy:
|
||||
node_dict["retry_policy"] = node_config.retry_policy
|
||||
if node_config.timeout:
|
||||
node_dict["timeout"] = node_config.timeout
|
||||
nodes_dict[name] = node_dict
|
||||
|
||||
# Convert edges from Edge to dict
|
||||
edges_list = []
|
||||
for edge in graph_config.edges:
|
||||
edge_dict: dict[str, Any] = {
|
||||
"source": edge.source,
|
||||
"target": edge.target,
|
||||
}
|
||||
if edge.condition:
|
||||
edge_dict["condition"] = edge.condition
|
||||
edges_list.append(edge_dict)
|
||||
|
||||
return cls(
|
||||
name=graph_config.name,
|
||||
type=RouteType.GRAPH,
|
||||
nodes=nodes_dict,
|
||||
edges=edges_list,
|
||||
entry_point=graph_config.entry_point,
|
||||
checkpointing=graph_config.checkpointing,
|
||||
checkpoint_dir=(
|
||||
str(graph_config.checkpoint_dir)
|
||||
if graph_config.checkpoint_dir
|
||||
else None
|
||||
),
|
||||
enable_time_travel=graph_config.enable_time_travel,
|
||||
parallel_execution=graph_config.parallel_execution,
|
||||
state_class=None, # String representation
|
||||
metadata=graph_config.metadata,
|
||||
)
|
||||
|
||||
|
||||
class RouteComplexityAnalyzer:
|
||||
"""
|
||||
Analyzes route complexity to help users choose the right type.
|
||||
|
||||
Complexity ladder:
|
||||
1. Simple Stream - Basic data transformation
|
||||
2. Stream with Operators - Multiple transformations
|
||||
3. Stream with Merging/Splitting - Complex routing
|
||||
4. Basic Graph - Conditional logic needed
|
||||
5. Stateful Graph - State management required
|
||||
6. Graph with Checkpointing - Persistence/recovery needed
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def analyze_route(config: RouteConfig) -> dict[str, Any]:
|
||||
"""Analyze a route and return complexity metrics."""
|
||||
if config.type == RouteType.STREAM:
|
||||
return RouteComplexityAnalyzer._analyze_stream(config)
|
||||
if config.type == RouteType.GRAPH:
|
||||
return RouteComplexityAnalyzer._analyze_graph(config)
|
||||
return {"complexity": "bridge", "score": 0}
|
||||
|
||||
@staticmethod
|
||||
def _analyze_stream(config: RouteConfig) -> dict[str, Any]:
|
||||
"""Analyze stream complexity."""
|
||||
score = 1 # Base score for streams
|
||||
features = []
|
||||
|
||||
if config.operators:
|
||||
score += len(config.operators)
|
||||
features.append(f"{len(config.operators)} operators")
|
||||
|
||||
if config.subscriptions or config.publications:
|
||||
score += 2
|
||||
features.append("routing connections")
|
||||
|
||||
if config.stream_type == StreamType.HOT:
|
||||
score += 1
|
||||
features.append("hot/stateful stream")
|
||||
|
||||
return {
|
||||
"type": "stream",
|
||||
"complexity": (
|
||||
"simple" if score <= 2 else "moderate" if score <= 5 else "complex"
|
||||
),
|
||||
"score": score,
|
||||
"features": features,
|
||||
"recommendation": RouteComplexityAnalyzer._get_stream_recommendation(score),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _analyze_graph(config: RouteConfig) -> dict[str, Any]:
|
||||
"""Analyze graph complexity."""
|
||||
score = 5 # Base score for graphs
|
||||
features = []
|
||||
|
||||
node_count = len(config.nodes)
|
||||
edge_count = len(config.edges)
|
||||
|
||||
score += node_count + (edge_count // 2)
|
||||
features.append(f"{node_count} nodes, {edge_count} edges")
|
||||
|
||||
if config.checkpointing:
|
||||
score += 3
|
||||
features.append("checkpointing enabled")
|
||||
|
||||
if config.enable_time_travel:
|
||||
score += 2
|
||||
features.append("time travel enabled")
|
||||
|
||||
# Check for conditional edges
|
||||
conditional_edges = sum(1 for edge in config.edges if "condition" in edge)
|
||||
if conditional_edges:
|
||||
score += conditional_edges * 2
|
||||
features.append(f"{conditional_edges} conditional edges")
|
||||
|
||||
return {
|
||||
"type": "graph",
|
||||
"complexity": (
|
||||
"moderate" if score <= 10 else "complex" if score <= 15 else "advanced"
|
||||
),
|
||||
"score": score,
|
||||
"features": features,
|
||||
"recommendation": RouteComplexityAnalyzer._get_graph_recommendation(score),
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
def _get_stream_recommendation(score: int) -> str:
|
||||
"""Get recommendation for stream complexity."""
|
||||
if score <= 2:
|
||||
return "Good for simple transformations and filtering"
|
||||
if score <= 5:
|
||||
return "Suitable for multi-step processing pipelines"
|
||||
return "Consider using a graph if you need conditional logic or state"
|
||||
|
||||
@staticmethod
|
||||
def _get_graph_recommendation(score: int) -> str:
|
||||
"""Get recommendation for graph complexity."""
|
||||
if score <= 10:
|
||||
return "Good for workflows with conditional logic"
|
||||
if score <= 15:
|
||||
return "Suitable for complex stateful workflows"
|
||||
return "Advanced setup - ensure you need all features"
|
||||
|
||||
@staticmethod
|
||||
def suggest_route_type(requirements: dict[str, Any]) -> RouteType:
|
||||
"""Suggest the best route type based on requirements."""
|
||||
needs_state = requirements.get("needs_state", False)
|
||||
needs_conditionals = requirements.get("needs_conditionals", False)
|
||||
needs_persistence = requirements.get("needs_persistence", False)
|
||||
is_continuous = requirements.get("is_continuous", False)
|
||||
is_stateless = requirements.get("is_stateless", True)
|
||||
|
||||
if needs_persistence or (needs_state and needs_conditionals):
|
||||
return RouteType.GRAPH
|
||||
if needs_conditionals and not is_continuous:
|
||||
return RouteType.GRAPH
|
||||
if is_stateless and is_continuous:
|
||||
return RouteType.STREAM
|
||||
# Default to stream for simpler cases
|
||||
return RouteType.STREAM
|
||||
@@ -0,0 +1,306 @@
|
||||
"""
|
||||
Route bridging and type conversion system.
|
||||
|
||||
This module provides functionality to dynamically convert between
|
||||
stream and graph routes based on runtime conditions.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
|
||||
from rx.scheduler.eventloop import AsyncIOScheduler # type: ignore[attr-defined]
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.langgraph.graph import GraphConfig
|
||||
from cleveragents.langgraph.graph import LangGraph
|
||||
from cleveragents.langgraph.nodes import Edge
|
||||
from cleveragents.langgraph.nodes import NodeConfig
|
||||
from cleveragents.langgraph.nodes import NodeType
|
||||
from cleveragents.langgraph.state import GraphState
|
||||
from cleveragents.reactive.route import RouteConfig
|
||||
from cleveragents.reactive.route import RouteType
|
||||
from cleveragents.reactive.stream_router import ReactiveStreamRouter
|
||||
from cleveragents.reactive.stream_router import StreamConfig
|
||||
from cleveragents.reactive.stream_router import StreamMessage
|
||||
from cleveragents.reactive.stream_router import StreamType
|
||||
|
||||
|
||||
class RouteBridge:
|
||||
"""
|
||||
Handles dynamic conversion between stream and graph routes.
|
||||
|
||||
This allows routes to upgrade from simple streams to stateful graphs
|
||||
when conditions require it, or downgrade from graphs to streams when
|
||||
the complexity is no longer needed.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
stream_router: ReactiveStreamRouter,
|
||||
agents: dict[str, Agent],
|
||||
scheduler: Optional[AsyncIOScheduler] = None,
|
||||
):
|
||||
"""Initialize the route bridge."""
|
||||
self.stream_router = stream_router
|
||||
self.agents = agents
|
||||
self.scheduler = scheduler
|
||||
self.logger = logging.getLogger(__name__)
|
||||
self._active_conversions: dict[str, Any] = {}
|
||||
|
||||
async def check_upgrade_conditions(
|
||||
self,
|
||||
route_config: RouteConfig,
|
||||
message: StreamMessage,
|
||||
) -> bool:
|
||||
"""Check if a stream should be upgraded to a graph."""
|
||||
if not route_config.bridge or route_config.type != RouteType.STREAM:
|
||||
return False
|
||||
|
||||
conditions = route_config.bridge.upgrade_conditions
|
||||
|
||||
# Check built-in conditions
|
||||
if "needs_state" in conditions:
|
||||
# Check if message indicates state requirement
|
||||
if message.metadata.get("requires_state", False):
|
||||
return True
|
||||
|
||||
if "message_count" in conditions:
|
||||
# Check if we've processed enough messages to warrant upgrade
|
||||
count = self._get_message_count(route_config.name)
|
||||
if count >= conditions["message_count"]:
|
||||
return True
|
||||
|
||||
if "complexity_threshold" in conditions:
|
||||
# Analyze route complexity
|
||||
from cleveragents.reactive.route import RouteComplexityAnalyzer # pylint: disable=import-outside-toplevel
|
||||
|
||||
analysis = RouteComplexityAnalyzer.analyze_route(route_config)
|
||||
if analysis["score"] >= conditions["complexity_threshold"]:
|
||||
return True
|
||||
|
||||
if "custom_predicate" in conditions:
|
||||
# Evaluate custom predicate function
|
||||
predicate = conditions["custom_predicate"]
|
||||
if callable(predicate):
|
||||
result = predicate(message, route_config)
|
||||
return bool(result)
|
||||
|
||||
return False
|
||||
|
||||
async def check_downgrade_conditions(
|
||||
self,
|
||||
route_config: RouteConfig,
|
||||
state: GraphState,
|
||||
) -> bool:
|
||||
"""Check if a graph should be downgraded to a stream."""
|
||||
if not route_config.bridge or route_config.type != RouteType.GRAPH:
|
||||
return False
|
||||
|
||||
conditions = route_config.bridge.downgrade_conditions
|
||||
|
||||
# Check built-in conditions
|
||||
if "idle_time" in conditions:
|
||||
# Check if graph has been idle
|
||||
idle_threshold = conditions["idle_time"]
|
||||
last_updated = state.metadata.get("last_updated")
|
||||
if (
|
||||
last_updated
|
||||
and (asyncio.get_event_loop().time() - last_updated) > idle_threshold
|
||||
):
|
||||
return True
|
||||
|
||||
if "state_size" in conditions:
|
||||
# Check if state is minimal (using messages as proxy for state size)
|
||||
if len(state.messages) <= conditions["state_size"]:
|
||||
return True
|
||||
|
||||
if "no_conditionals_used" in conditions:
|
||||
# Check if conditional edges were actually used
|
||||
if not state.metadata.get("conditional_edges_used", False):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
async def upgrade_stream_to_graph(
|
||||
self,
|
||||
route_config: RouteConfig,
|
||||
message: StreamMessage,
|
||||
) -> LangGraph:
|
||||
"""Convert a stream route to a graph route."""
|
||||
self.logger.info("Upgrading stream route '%s' to graph", route_config.name)
|
||||
|
||||
# Create graph config from stream
|
||||
graph_config = self._create_graph_from_stream(route_config)
|
||||
|
||||
# Extract initial state if configured
|
||||
initial_state = {}
|
||||
if route_config.bridge and route_config.bridge.state_extractor:
|
||||
extractor = route_config.bridge.state_extractor
|
||||
if callable(extractor):
|
||||
initial_state = extractor(message)
|
||||
|
||||
# Create the graph
|
||||
graph = LangGraph(
|
||||
config=graph_config,
|
||||
agents=self.agents,
|
||||
stream_router=self.stream_router,
|
||||
scheduler=self.scheduler,
|
||||
)
|
||||
|
||||
# Initialize with extracted state
|
||||
if initial_state:
|
||||
graph.state_manager.update_state(initial_state)
|
||||
|
||||
# Preserve subscriptions if configured
|
||||
if route_config.bridge and route_config.bridge.preserve_subscriptions:
|
||||
# Re-subscribe to the same sources
|
||||
for _ in route_config.subscriptions:
|
||||
# This would need implementation in the graph
|
||||
pass
|
||||
|
||||
self._active_conversions[route_config.name] = {
|
||||
"type": "graph",
|
||||
"instance": graph,
|
||||
"original_config": route_config,
|
||||
}
|
||||
|
||||
return graph
|
||||
|
||||
async def downgrade_graph_to_stream(
|
||||
self,
|
||||
route_config: RouteConfig,
|
||||
graph: LangGraph,
|
||||
) -> StreamConfig:
|
||||
"""Convert a graph route back to a stream route."""
|
||||
self.logger.info("Downgrading graph route '%s' to stream", route_config.name)
|
||||
|
||||
# Create stream config from graph
|
||||
stream_config = self._create_stream_from_graph(route_config, graph)
|
||||
|
||||
# Flatten state if configured
|
||||
if route_config.bridge and route_config.bridge.state_flattener:
|
||||
flattener = route_config.bridge.state_flattener
|
||||
if callable(flattener):
|
||||
final_state = graph.state_manager.get_state()
|
||||
_flattened_data = flattener(final_state)
|
||||
# This could be injected into the stream somehow
|
||||
|
||||
# Preserve checkpointing info if configured
|
||||
if route_config.bridge and route_config.bridge.preserve_checkpointing:
|
||||
# Save final checkpoint if checkpoint_dir is set
|
||||
if graph.state_manager.checkpoint_dir:
|
||||
graph.state_manager._save_checkpoint() # pylint: disable=protected-access
|
||||
|
||||
self._active_conversions[route_config.name] = {
|
||||
"type": "stream",
|
||||
"instance": stream_config,
|
||||
"original_config": route_config,
|
||||
}
|
||||
|
||||
return stream_config
|
||||
|
||||
def _create_graph_from_stream(self, route_config: RouteConfig) -> GraphConfig:
|
||||
"""Create a graph configuration from a stream route."""
|
||||
# Create nodes from stream operators
|
||||
nodes = {}
|
||||
edges = []
|
||||
|
||||
# Entry node
|
||||
prev_node = "start"
|
||||
|
||||
# Convert each operator to a node
|
||||
for i, operator in enumerate(route_config.operators):
|
||||
node_name = f"op_{i}_{operator.get('type', 'unknown')}"
|
||||
|
||||
# Create node based on operator type
|
||||
if operator["type"] == "map" and "agent" in operator.get("params", {}):
|
||||
# Agent-based operator becomes agent node
|
||||
nodes[node_name] = NodeConfig(
|
||||
name=node_name,
|
||||
type=NodeType.AGENT,
|
||||
agent=operator["params"]["agent"],
|
||||
)
|
||||
else:
|
||||
# Other operators become function nodes
|
||||
# For now, we'll use the operator type as the function name
|
||||
nodes[node_name] = NodeConfig(
|
||||
name=node_name,
|
||||
type=NodeType.FUNCTION,
|
||||
function=f"stream_operator_{operator.get('type', 'unknown')}",
|
||||
)
|
||||
|
||||
# Add edge from previous node
|
||||
edges.append(Edge(source=prev_node, target=node_name))
|
||||
prev_node = node_name
|
||||
|
||||
# Add final edge to end
|
||||
edges.append(Edge(source=prev_node, target="end"))
|
||||
|
||||
return GraphConfig(
|
||||
name=f"{route_config.name}_graph",
|
||||
nodes=nodes,
|
||||
edges=edges,
|
||||
entry_point="start",
|
||||
checkpointing=True, # Enable by default for upgraded routes
|
||||
parallel_execution=False, # Sequential like stream
|
||||
)
|
||||
|
||||
def _create_stream_from_graph(
|
||||
self,
|
||||
route_config: RouteConfig,
|
||||
graph: LangGraph,
|
||||
) -> StreamConfig:
|
||||
"""Create a stream configuration from a graph route."""
|
||||
# Extract linear flow from graph
|
||||
operators = []
|
||||
agents = []
|
||||
|
||||
# Traverse graph nodes in topological order
|
||||
topological_levels = graph._topological_levels() # pylint: disable=protected-access
|
||||
sorted_nodes = []
|
||||
for level in sorted(topological_levels.keys()):
|
||||
sorted_nodes.extend(sorted(topological_levels[level]))
|
||||
|
||||
for node_name in sorted_nodes:
|
||||
if node_name in ["start", "end"]:
|
||||
continue
|
||||
|
||||
node = graph.nodes.get(node_name)
|
||||
if not node:
|
||||
continue
|
||||
|
||||
# Convert node to operator
|
||||
if node.config.type == NodeType.AGENT:
|
||||
operators.append(
|
||||
{
|
||||
"type": "map",
|
||||
"params": {"agent": node.config.agent},
|
||||
}
|
||||
)
|
||||
if node.config.agent:
|
||||
agents.append(node.config.agent)
|
||||
elif node.config.type == NodeType.FUNCTION:
|
||||
# Restore original operator if stored
|
||||
if isinstance(node.config.function, dict):
|
||||
operators.append(node.config.function)
|
||||
|
||||
return StreamConfig(
|
||||
name=f"{route_config.name}_stream",
|
||||
type=route_config.stream_type or StreamType.COLD,
|
||||
operators=operators,
|
||||
agents=agents,
|
||||
subscriptions=route_config.subscriptions,
|
||||
publications=route_config.publications,
|
||||
)
|
||||
|
||||
def _get_message_count(self, _route_name: str) -> int:
|
||||
"""Get the number of messages processed by a route."""
|
||||
# This would need to be tracked somewhere
|
||||
# For now, return a placeholder
|
||||
return 0
|
||||
|
||||
def get_active_conversion(self, route_name: str) -> Optional[dict[str, Any]]:
|
||||
"""Get active conversion info for a route."""
|
||||
return self._active_conversions.get(route_name)
|
||||
@@ -0,0 +1,610 @@
|
||||
"""
|
||||
RxPy-based stream router for CleverAgents.
|
||||
|
||||
This module provides reactive stream routing capabilities using RxPy,
|
||||
allowing for complex message flows with splitting, merging, filtering,
|
||||
and transformation operations.
|
||||
"""
|
||||
# pylint: disable=duplicate-code
|
||||
import asyncio
|
||||
import threading
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from enum import Enum
|
||||
from typing import Any
|
||||
from typing import Callable
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import rx
|
||||
from rx import operators as ops
|
||||
from rx.core import Observable as ObservableType # type: ignore[attr-defined]
|
||||
from rx.core import Observer as ObserverType # type: ignore[attr-defined]
|
||||
from rx.scheduler.eventloop import AsyncIOScheduler # type: ignore[attr-defined]
|
||||
from rx.subject import BehaviorSubject # type: ignore[attr-defined]
|
||||
from rx.subject import Subject # type: ignore[attr-defined]
|
||||
|
||||
from cleveragents.agents.base import Agent
|
||||
from cleveragents.core.exceptions import StreamRoutingError
|
||||
|
||||
|
||||
class StreamType(Enum):
|
||||
"""Types of streams in the reactive system."""
|
||||
|
||||
HOT = "hot" # BehaviorSubject - replays last value
|
||||
COLD = "cold" # Subject - no replay
|
||||
REPLAY = "replay" # ReplaySubject - replays all values
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamMessage:
|
||||
"""Message container for reactive streams."""
|
||||
|
||||
content: Any
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
source_stream: Optional[str] = None
|
||||
timestamp: Optional[float] = None
|
||||
|
||||
def copy_with(self, **kwargs: Any) -> "StreamMessage":
|
||||
"""Create a copy with modified fields."""
|
||||
data = {
|
||||
"content": self.content,
|
||||
"metadata": self.metadata.copy(),
|
||||
"source_stream": self.source_stream,
|
||||
"timestamp": self.timestamp,
|
||||
}
|
||||
data.update(kwargs)
|
||||
return StreamMessage(**data)
|
||||
|
||||
|
||||
@dataclass
|
||||
class StreamConfig: # pylint: disable=too-many-instance-attributes
|
||||
"""Configuration for a reactive stream."""
|
||||
|
||||
name: str
|
||||
type: StreamType = StreamType.COLD
|
||||
operators: List[dict[str, Any]] = field(default_factory=list)
|
||||
subscriptions: List[str] = field(default_factory=list)
|
||||
publications: List[str] = field(default_factory=list)
|
||||
agents: List[str] = field(default_factory=list)
|
||||
initial_value: Optional[Any] = None # For hot streams
|
||||
buffer_size: int = 1 # For replay streams
|
||||
template_config: Optional[dict[str, Any]] = None # Template configuration
|
||||
|
||||
|
||||
class ReactiveStreamRouter: # pylint: disable=too-many-instance-attributes
|
||||
"""
|
||||
RxPy-based stream router for agent orchestration.
|
||||
|
||||
This router manages reactive streams and provides full RxPy operator
|
||||
support for complex message routing patterns.
|
||||
"""
|
||||
|
||||
def __init__(self, scheduler: Optional[AsyncIOScheduler] = None):
|
||||
"""Initialize the stream router."""
|
||||
self.logger = logging.getLogger(__name__)
|
||||
# Create event loop if needed
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop, create a new one
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
self.scheduler = scheduler or AsyncIOScheduler(loop)
|
||||
|
||||
# Stream storage
|
||||
self.streams: dict[str, Any] = (
|
||||
{}
|
||||
) # Can be Subject, BehaviorSubject, or ReplaySubject
|
||||
self.stream_configs: dict[str, StreamConfig] = {}
|
||||
self.observables: dict[str, ObservableType] = {}
|
||||
|
||||
# Agent registry
|
||||
self.agents: dict[str, Agent] = {}
|
||||
|
||||
# Subscription tracking
|
||||
self.subscriptions: List[Any] = []
|
||||
|
||||
# LangGraph bridge (set externally)
|
||||
self._langgraph_bridge: Optional[Any] = None
|
||||
|
||||
# Built-in streams
|
||||
self._create_builtin_streams()
|
||||
|
||||
def _create_builtin_streams(self) -> None:
|
||||
"""Create built-in system streams."""
|
||||
# Input stream for external messages
|
||||
input_stream = Subject()
|
||||
self.streams["__input__"] = input_stream
|
||||
self.observables["__input__"] = input_stream
|
||||
self.stream_configs["__input__"] = StreamConfig(
|
||||
name="__input__", type=StreamType.COLD
|
||||
)
|
||||
|
||||
# Output stream for final results
|
||||
output_stream = Subject()
|
||||
self.streams["__output__"] = output_stream
|
||||
self.observables["__output__"] = output_stream
|
||||
self.stream_configs["__output__"] = StreamConfig(
|
||||
name="__output__", type=StreamType.COLD
|
||||
)
|
||||
|
||||
# Error stream for error handling
|
||||
error_stream = Subject()
|
||||
self.streams["__error__"] = error_stream
|
||||
self.observables["__error__"] = error_stream
|
||||
self.stream_configs["__error__"] = StreamConfig(
|
||||
name="__error__", type=StreamType.COLD
|
||||
)
|
||||
|
||||
def register_agent(self, name: str, agent: Agent) -> None:
|
||||
"""Register an agent for use in streams."""
|
||||
self.agents[name] = agent
|
||||
self.logger.debug("Registered agent: %s", name)
|
||||
|
||||
def create_stream(
|
||||
self, config: StreamConfig
|
||||
) -> Any: # Returns Subject, BehaviorSubject, or ReplaySubject
|
||||
"""Create a new reactive stream."""
|
||||
if config.name in self.streams:
|
||||
raise StreamRoutingError(f"Stream '{config.name}' already exists")
|
||||
|
||||
# Create appropriate subject type
|
||||
stream: Any # Can be Subject, BehaviorSubject, or ReplaySubject
|
||||
if config.type == StreamType.HOT:
|
||||
stream = BehaviorSubject(config.initial_value)
|
||||
elif config.type == StreamType.REPLAY:
|
||||
from rx.subject import ReplaySubject # type: ignore[attr-defined] # pylint: disable=import-outside-toplevel
|
||||
|
||||
stream = ReplaySubject(buffer_size=config.buffer_size)
|
||||
else: # COLD
|
||||
stream = Subject()
|
||||
|
||||
self.streams[config.name] = stream
|
||||
self.stream_configs[config.name] = config
|
||||
|
||||
# Build observable with operators
|
||||
observable = self._build_observable(config.name, config.operators)
|
||||
self.observables[config.name] = observable
|
||||
|
||||
# Set up subscriptions
|
||||
self._setup_subscriptions(config)
|
||||
|
||||
self.logger.info("Created stream: %s (%s)", config.name, config.type.value)
|
||||
return stream
|
||||
|
||||
def _build_observable(
|
||||
self, stream_name: str, operator_configs: List[dict[str, Any]]
|
||||
) -> ObservableType:
|
||||
"""Build an observable with configured operators."""
|
||||
base_stream = self.streams[stream_name]
|
||||
observable: ObservableType = base_stream
|
||||
|
||||
for op_config in operator_configs:
|
||||
operator = self._create_operator(op_config)
|
||||
observable = observable.pipe(operator)
|
||||
|
||||
return observable
|
||||
|
||||
def _create_operator(self, config: dict[str, Any]) -> Any: # pylint: disable=too-many-return-statements,too-many-branches,too-many-statements,too-many-locals
|
||||
"""Create an RxPy operator from configuration."""
|
||||
op_type = config["type"]
|
||||
params = config.get("params", {})
|
||||
|
||||
# Check if this is a LangGraph operator
|
||||
if op_type in [
|
||||
"graph_execute",
|
||||
"state_update",
|
||||
"state_checkpoint",
|
||||
"langgraph_node",
|
||||
"conditional_route",
|
||||
]:
|
||||
# Delegate to LangGraph bridge if available
|
||||
if self._langgraph_bridge is not None:
|
||||
factory_name = f"_operator_{op_type}"
|
||||
if hasattr(self._langgraph_bridge, factory_name):
|
||||
factory = getattr(self._langgraph_bridge, factory_name)
|
||||
return factory(params)
|
||||
else:
|
||||
raise StreamRoutingError(
|
||||
f"LangGraph operator '{op_type}' requires LangGraph bridge"
|
||||
)
|
||||
|
||||
# Map operations
|
||||
if op_type == "map":
|
||||
if "agent" in params:
|
||||
agent_name = params["agent"]
|
||||
agent = self.agents.get(agent_name)
|
||||
if not agent:
|
||||
raise StreamRoutingError(f"Agent '{agent_name}' not found")
|
||||
return ops.map(self._create_agent_mapper(agent))
|
||||
if "function" in params:
|
||||
func_name = params["function"]
|
||||
return ops.map(getattr(self, f"_builtin_{func_name}", lambda x: x))
|
||||
if "transform" in params:
|
||||
transform = params["transform"]
|
||||
return ops.map(lambda x: self._apply_transform(x, transform))
|
||||
raise StreamRoutingError(
|
||||
"Map operator requires 'agent', 'function', or 'transform' parameter"
|
||||
)
|
||||
|
||||
# Filter operations
|
||||
if op_type == "filter":
|
||||
if "condition" in params:
|
||||
condition = params["condition"]
|
||||
return ops.filter(lambda x: self._evaluate_condition(x, condition))
|
||||
raise StreamRoutingError("Filter operator requires 'condition' parameter")
|
||||
|
||||
# Timing operations
|
||||
if op_type == "debounce":
|
||||
duration = params.get("duration", 1.0)
|
||||
# For single-shot mode, reduce debounce time
|
||||
debounce_time = min(duration, 0.05) # Max 50ms debounce for single-shot
|
||||
return ops.debounce(debounce_time)
|
||||
|
||||
if op_type == "throttle":
|
||||
duration = params.get("duration", 0.2)
|
||||
return ops.throttle_first(duration)
|
||||
|
||||
if op_type == "delay":
|
||||
duration = params.get("duration", 0.1)
|
||||
return ops.delay(duration)
|
||||
|
||||
# Buffering operations - modified for single-shot compatibility
|
||||
if op_type == "buffer":
|
||||
if "count" in params:
|
||||
count = params["count"]
|
||||
timeout = params.get("timeout", 0.1)
|
||||
|
||||
# For single-shot mode, use timeout to avoid hanging on insufficient messages
|
||||
# Create a custom operator that handles the buffer output properly
|
||||
def buffer_and_flatten(source: ObservableType) -> ObservableType:
|
||||
return source.pipe(
|
||||
ops.buffer_with_time_or_count(timespan=timeout, count=count),
|
||||
ops.flat_map(
|
||||
lambda msgs: rx.from_iterable(msgs) if msgs else rx.empty()
|
||||
),
|
||||
)
|
||||
|
||||
return buffer_and_flatten
|
||||
if "time" in params:
|
||||
timeout = params["time"]
|
||||
|
||||
def buffer_time_and_flatten(source: ObservableType) -> ObservableType:
|
||||
return source.pipe(
|
||||
ops.buffer_with_time(timeout),
|
||||
ops.flat_map(
|
||||
lambda msgs: rx.from_iterable(msgs) if msgs else rx.empty()
|
||||
),
|
||||
)
|
||||
|
||||
return buffer_time_and_flatten
|
||||
raise StreamRoutingError("Buffer operator requires 'count' or 'time' parameter")
|
||||
|
||||
# Aggregation operations
|
||||
if op_type == "scan":
|
||||
if "accumulator" in params:
|
||||
return ops.scan(
|
||||
lambda acc, x: self._apply_accumulator(
|
||||
acc, x, params["accumulator"]
|
||||
)
|
||||
)
|
||||
raise StreamRoutingError("Scan operator requires 'accumulator' parameter")
|
||||
|
||||
if op_type == "reduce":
|
||||
if "accumulator" in params:
|
||||
return ops.reduce(
|
||||
lambda acc, x: self._apply_accumulator(
|
||||
acc, x, params["accumulator"]
|
||||
)
|
||||
)
|
||||
raise StreamRoutingError("Reduce operator requires 'accumulator' parameter")
|
||||
|
||||
# Error handling
|
||||
if op_type == "catch":
|
||||
return ops.catch(self._handle_stream_error)
|
||||
|
||||
if op_type == "retry":
|
||||
count = params.get("count", 3)
|
||||
return ops.retry(count)
|
||||
|
||||
# Utility operations
|
||||
if op_type == "distinct":
|
||||
return ops.distinct()
|
||||
|
||||
if op_type == "take":
|
||||
count = params.get("count", 1)
|
||||
return ops.take(count)
|
||||
|
||||
if op_type == "skip":
|
||||
count = params.get("count", 1)
|
||||
return ops.skip(count)
|
||||
|
||||
if op_type == "sample":
|
||||
interval = params.get("interval", 1.0)
|
||||
return ops.sample(interval)
|
||||
|
||||
raise StreamRoutingError(f"Unknown operator type: {op_type}")
|
||||
|
||||
def _create_agent_mapper(self, agent: Agent) -> Callable[[StreamMessage], StreamMessage]:
|
||||
"""Create a mapper function for an agent."""
|
||||
|
||||
# For RxPY compatibility, we need to handle async operations
|
||||
def mapper(msg: StreamMessage) -> StreamMessage:
|
||||
# Handle None message
|
||||
if msg is None:
|
||||
return StreamMessage(content="", metadata={"processed_by": agent.name})
|
||||
|
||||
# Get message content
|
||||
content = msg.content if msg.content is not None else ""
|
||||
|
||||
try:
|
||||
# Create a new event loop in a separate thread for the async operation
|
||||
result_holder = []
|
||||
error_holder = []
|
||||
|
||||
def run_async() -> None:
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
asyncio.set_event_loop(loop)
|
||||
try:
|
||||
result = loop.run_until_complete(agent.process_message(content))
|
||||
result_holder.append(result)
|
||||
finally:
|
||||
loop.close()
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
error_holder.append(e)
|
||||
|
||||
# Run in thread with timeout
|
||||
thread = threading.Thread(target=run_async, daemon=True)
|
||||
thread.start()
|
||||
thread.join(timeout=30.0) # 30 second timeout
|
||||
|
||||
if thread.is_alive():
|
||||
self.logger.error("Agent %s processing timed out", agent.name)
|
||||
return msg.copy_with(
|
||||
content="Processing timed out",
|
||||
metadata={
|
||||
**msg.metadata,
|
||||
"processed_by": agent.name,
|
||||
"error": True,
|
||||
"timeout": True,
|
||||
}
|
||||
)
|
||||
|
||||
if error_holder:
|
||||
raise error_holder[0]
|
||||
|
||||
if result_holder:
|
||||
result = result_holder[0]
|
||||
else:
|
||||
result = "No result returned"
|
||||
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
self.logger.error(
|
||||
"Failed to process message with %s: %s", agent.name, e
|
||||
)
|
||||
return msg.copy_with(
|
||||
content=f"Error: {str(e)}",
|
||||
metadata={**msg.metadata, "processed_by": agent.name, "error": True}
|
||||
)
|
||||
|
||||
# Return processed message
|
||||
return msg.copy_with(
|
||||
content=result, metadata={**msg.metadata, "processed_by": agent.name}
|
||||
)
|
||||
|
||||
return mapper
|
||||
|
||||
def _apply_transform(
|
||||
self, msg: StreamMessage, transform: dict[str, Any]
|
||||
) -> StreamMessage: # pylint: disable=too-many-return-statements
|
||||
"""Apply a transformation to a message."""
|
||||
transform_type = transform.get("type", "identity")
|
||||
|
||||
if transform_type == "extract":
|
||||
field_name = transform.get("field")
|
||||
if field_name and msg.content is not None and isinstance(msg.content, dict):
|
||||
return msg.copy_with(content=msg.content.get(field_name))
|
||||
|
||||
if transform_type == "wrap":
|
||||
wrapper = transform.get("wrapper", {})
|
||||
content_data = msg.content if msg.content is not None else ""
|
||||
return msg.copy_with(content={**wrapper, "data": content_data})
|
||||
|
||||
if transform_type == "format":
|
||||
template = transform.get("template", "{content}")
|
||||
content_data = msg.content if msg.content is not None else ""
|
||||
formatted = template.format(content=content_data)
|
||||
return msg.copy_with(content=formatted)
|
||||
|
||||
return msg
|
||||
|
||||
def _evaluate_condition( # pylint: disable=too-many-return-statements
|
||||
self, msg: StreamMessage, condition: dict[str, Any]
|
||||
) -> bool:
|
||||
"""Evaluate a filter condition."""
|
||||
condition_type = condition.get("type", "always")
|
||||
|
||||
if condition_type == "always":
|
||||
return True
|
||||
if condition_type == "never":
|
||||
return False
|
||||
if condition_type == "content_contains":
|
||||
text = condition.get("text", "")
|
||||
content_str = str(msg.content) if msg.content is not None else ""
|
||||
return text in content_str
|
||||
if condition_type == "content_not_contains":
|
||||
text = condition.get("text", "")
|
||||
content_str = str(msg.content) if msg.content is not None else ""
|
||||
return text not in content_str
|
||||
if condition_type == "metadata_has":
|
||||
key = condition.get("key", "")
|
||||
return key in msg.metadata
|
||||
if condition_type == "source_is":
|
||||
source: str = condition.get("source", "")
|
||||
return msg.source_stream == source
|
||||
|
||||
return True
|
||||
|
||||
def _apply_accumulator(
|
||||
self, acc: Any, msg: StreamMessage, accumulator: dict[str, Any]
|
||||
) -> Any:
|
||||
"""Apply an accumulator function."""
|
||||
acc_type = accumulator.get("type", "collect")
|
||||
|
||||
if acc_type == "collect":
|
||||
if acc is None:
|
||||
acc = []
|
||||
acc.append(msg.content if msg.content is not None else "")
|
||||
return acc
|
||||
|
||||
if acc_type == "concat":
|
||||
if acc is None:
|
||||
acc = ""
|
||||
content_str = str(msg.content) if msg.content is not None else ""
|
||||
return acc + content_str
|
||||
|
||||
if acc_type == "sum":
|
||||
if acc is None:
|
||||
acc = 0
|
||||
if msg.content is not None and isinstance(msg.content, (int, float)):
|
||||
return acc + msg.content
|
||||
return acc
|
||||
|
||||
return acc
|
||||
|
||||
def _handle_stream_error(self, error: Exception, source: ObservableType) -> ObservableType:
|
||||
"""Handle stream errors."""
|
||||
error_msg = StreamMessage(
|
||||
content=f"Stream error: {str(error)}",
|
||||
metadata={"error_type": type(error).__name__},
|
||||
)
|
||||
self.streams["__error__"].on_next(error_msg)
|
||||
return source
|
||||
|
||||
def _setup_subscriptions(self, config: StreamConfig) -> None:
|
||||
"""Set up stream subscriptions."""
|
||||
observable = self.observables[config.name]
|
||||
|
||||
# Subscribe to publication streams
|
||||
for pub_stream_name in config.publications:
|
||||
# Ensure built-in streams exist
|
||||
if pub_stream_name == "__output__" and pub_stream_name not in self.streams:
|
||||
self._create_builtin_streams()
|
||||
|
||||
if pub_stream_name in self.streams:
|
||||
pub_stream = self.streams[pub_stream_name]
|
||||
subscription = observable.subscribe(
|
||||
on_next=pub_stream.on_next,
|
||||
on_error=pub_stream.on_error,
|
||||
on_completed=pub_stream.on_completed,
|
||||
)
|
||||
self.subscriptions.append(subscription)
|
||||
self.logger.debug(
|
||||
"Stream '%s' publishing to '%s'", config.name, pub_stream_name
|
||||
)
|
||||
|
||||
def merge_streams(self, stream_names: List[str], output_stream_name: str) -> None:
|
||||
"""Merge multiple streams into one."""
|
||||
# Check if output stream already exists
|
||||
if output_stream_name in self.streams:
|
||||
# If it exists, we'll merge into the existing stream
|
||||
output_stream = self.streams[output_stream_name]
|
||||
self.logger.debug("Merging into existing stream: %s", output_stream_name)
|
||||
else:
|
||||
# Create new output stream if it doesn't exist
|
||||
output_stream = Subject()
|
||||
self.streams[output_stream_name] = output_stream
|
||||
self.observables[output_stream_name] = output_stream
|
||||
self.logger.debug("Created new stream for merge: %s", output_stream_name)
|
||||
|
||||
source_observables = []
|
||||
for name in stream_names:
|
||||
if name == "__input__":
|
||||
# Special handling for __input__ stream
|
||||
if "__input__" not in self.streams:
|
||||
self._create_builtin_streams()
|
||||
source_observables.append(self.observables["__input__"])
|
||||
elif name not in self.observables:
|
||||
raise StreamRoutingError(f"Stream '{name}' not found")
|
||||
else:
|
||||
source_observables.append(self.observables[name])
|
||||
|
||||
# Create merged observable and subscribe to output stream
|
||||
merged_observable = rx.merge(*source_observables)
|
||||
subscription = merged_observable.subscribe(output_stream)
|
||||
self.subscriptions.append(subscription)
|
||||
|
||||
self.logger.info("Merged streams %s into %s", stream_names, output_stream_name)
|
||||
|
||||
def split_stream(
|
||||
self, source_stream_name: str, conditions: dict[str, dict[str, Any]]
|
||||
) -> None:
|
||||
"""Split a stream into multiple streams based on conditions."""
|
||||
if source_stream_name not in self.observables:
|
||||
raise StreamRoutingError(f"Source stream '{source_stream_name}' not found")
|
||||
|
||||
source_observable = self.observables[source_stream_name]
|
||||
|
||||
for output_name, condition in conditions.items():
|
||||
if output_name in self.streams:
|
||||
raise StreamRoutingError(
|
||||
f"Output stream '{output_name}' already exists"
|
||||
)
|
||||
|
||||
# Create output stream
|
||||
output_stream = Subject()
|
||||
self.streams[output_name] = output_stream
|
||||
|
||||
# Create filtered observable with closure to avoid cell-var-from-loop
|
||||
def make_filter(cond: dict[str, Any]) -> Any:
|
||||
return ops.filter(lambda x: self._evaluate_condition(x, cond))
|
||||
|
||||
filter_op = make_filter(condition)
|
||||
filtered_observable = source_observable.pipe(filter_op)
|
||||
self.observables[output_name] = filtered_observable
|
||||
|
||||
# Subscribe to output stream
|
||||
subscription = filtered_observable.subscribe(output_stream)
|
||||
self.subscriptions.append(subscription)
|
||||
|
||||
self.logger.info(
|
||||
"Split stream %s into %s", source_stream_name, list(conditions.keys())
|
||||
)
|
||||
|
||||
def send_message(
|
||||
self, stream_name: str, content: Any, metadata: Optional[dict[str, Any]] = None
|
||||
) -> None:
|
||||
"""Send a message to a stream."""
|
||||
if stream_name not in self.streams:
|
||||
raise StreamRoutingError(f"Stream '{stream_name}' not found")
|
||||
|
||||
message = StreamMessage(
|
||||
content=content,
|
||||
metadata=metadata or {},
|
||||
source_stream=stream_name,
|
||||
timestamp=asyncio.get_event_loop().time(),
|
||||
)
|
||||
|
||||
self.streams[stream_name].on_next(message)
|
||||
|
||||
def subscribe_to_output(self, observer: ObserverType) -> None:
|
||||
"""Subscribe to the output stream."""
|
||||
self.observables["__output__"].subscribe(observer)
|
||||
|
||||
def dispose(self) -> None:
|
||||
"""Dispose of all subscriptions and streams."""
|
||||
for subscription in self.subscriptions:
|
||||
subscription.dispose()
|
||||
|
||||
for stream in self.streams.values():
|
||||
if hasattr(stream, "dispose"):
|
||||
stream.dispose()
|
||||
|
||||
self.subscriptions.clear()
|
||||
self.streams.clear()
|
||||
self.observables.clear()
|
||||
|
||||
self.logger.info("Stream router disposed")
|
||||
@@ -0,0 +1 @@
|
||||
"""Session module placeholder."""
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Template system for CleverAgents.
|
||||
|
||||
This module provides a unified template system for creating reusable,
|
||||
parameterizable components including agents, graphs, and streams.
|
||||
"""
|
||||
|
||||
# Import only base types to avoid circular imports
|
||||
from cleveragents.templates.base import BaseTemplate
|
||||
from cleveragents.templates.base import ComponentReference
|
||||
from cleveragents.templates.base import InstantiationContext
|
||||
from cleveragents.templates.base import TemplateParameter
|
||||
from cleveragents.templates.base import TemplateType
|
||||
|
||||
__all__ = [
|
||||
"BaseTemplate",
|
||||
"TemplateParameter",
|
||||
"TemplateType",
|
||||
"ComponentReference",
|
||||
"InstantiationContext",
|
||||
]
|
||||
@@ -0,0 +1,203 @@
|
||||
"""
|
||||
Agent template implementations.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
|
||||
|
||||
from cleveragents.templates.base import BaseTemplate
|
||||
from cleveragents.templates.base import ComponentReference
|
||||
from cleveragents.templates.base import InstantiationContext
|
||||
from cleveragents.templates.base import TemplateType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import TemplateRegistryProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AgentTemplate(BaseTemplate):
|
||||
"""Template for simple agents (LLM, Tool, etc.)."""
|
||||
|
||||
def instantiate(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
registry: "TemplateRegistryProtocol",
|
||||
context: InstantiationContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Create agent configuration from template."""
|
||||
# Validate and fill defaults
|
||||
filled_params = self.validate_params(params)
|
||||
|
||||
# Deep copy the definition
|
||||
config = copy.deepcopy(self.definition)
|
||||
|
||||
# Remove parameters section from config
|
||||
if "parameters" in config:
|
||||
del config["parameters"]
|
||||
|
||||
# Apply template variables throughout the config
|
||||
config = self._apply_template_vars(config, filled_params)
|
||||
|
||||
# Return agent configuration (not instance)
|
||||
# The actual instantiation will be done by AgentFactory
|
||||
return {
|
||||
"name": self.name,
|
||||
"type": config.get("type", "llm"),
|
||||
"config": config.get("config", config),
|
||||
}
|
||||
|
||||
|
||||
class CompositeAgentTemplate(BaseTemplate):
|
||||
"""Template for composite agents with nested components."""
|
||||
|
||||
def instantiate( # pylint: disable=too-many-locals,too-many-branches
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
registry: "TemplateRegistryProtocol",
|
||||
context: InstantiationContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Create composite agent configuration with instantiated sub-components."""
|
||||
filled_params = self.validate_params(params)
|
||||
|
||||
# Create new context for this composite's internals
|
||||
local_context = InstantiationContext(parent=context)
|
||||
|
||||
# Deep copy the definition
|
||||
definition = copy.deepcopy(self.definition)
|
||||
|
||||
# Remove parameters section
|
||||
if "parameters" in definition:
|
||||
del definition["parameters"]
|
||||
|
||||
# Process components section
|
||||
components_config = definition.get("components", {})
|
||||
instantiated_components: dict[str, dict[str, Any]] = {
|
||||
"agents": {},
|
||||
"graphs": {},
|
||||
"streams": {},
|
||||
}
|
||||
|
||||
# Process the entire components section with template vars first
|
||||
components_config = self._apply_template_vars(components_config, filled_params)
|
||||
|
||||
# Instantiate agents
|
||||
for name, agent_def in components_config.get("agents", {}).items():
|
||||
if agent_def is None: # Might be None if conditionally excluded
|
||||
continue
|
||||
|
||||
if "template" in agent_def:
|
||||
# Instantiate from template
|
||||
template = registry.get_template(
|
||||
TemplateType.AGENT, agent_def["template"]
|
||||
)
|
||||
agent_params = self._merge_params(
|
||||
filled_params, agent_def.get("params", {})
|
||||
)
|
||||
agent_config = template.instantiate(
|
||||
agent_params, registry, local_context
|
||||
)
|
||||
instantiated_components["agents"][name] = agent_config
|
||||
local_context.add_component("agent", name, agent_config)
|
||||
else:
|
||||
# Direct definition
|
||||
agent_config = self._apply_template_vars(agent_def, filled_params)
|
||||
instantiated_components["agents"][name] = agent_config
|
||||
local_context.add_component("agent", name, agent_config)
|
||||
|
||||
# Instantiate graphs
|
||||
for name, graph_def in components_config.get("graphs", {}).items():
|
||||
if graph_def is None:
|
||||
continue
|
||||
|
||||
if "template" in graph_def:
|
||||
template = registry.get_template(
|
||||
TemplateType.GRAPH, graph_def["template"]
|
||||
)
|
||||
graph_params = self._merge_params(
|
||||
filled_params, graph_def.get("params", {})
|
||||
)
|
||||
graph_config = template.instantiate(
|
||||
graph_params, registry, local_context
|
||||
)
|
||||
instantiated_components["graphs"][name] = graph_config
|
||||
local_context.add_component("graph", name, graph_config)
|
||||
else:
|
||||
# Direct definition with reference resolution
|
||||
graph_config = self._process_graph_definition(
|
||||
graph_def, filled_params, local_context
|
||||
)
|
||||
instantiated_components["graphs"][name] = graph_config
|
||||
local_context.add_component("graph", name, graph_config)
|
||||
|
||||
# Instantiate streams
|
||||
for name, stream_def in components_config.get("streams", {}).items():
|
||||
if stream_def is None:
|
||||
continue
|
||||
|
||||
if "template" in stream_def:
|
||||
template = registry.get_template(
|
||||
TemplateType.STREAM, stream_def["template"]
|
||||
)
|
||||
stream_params = self._merge_params(
|
||||
filled_params, stream_def.get("params", {})
|
||||
)
|
||||
stream_config = template.instantiate(
|
||||
stream_params, registry, local_context
|
||||
)
|
||||
instantiated_components["streams"][name] = stream_config
|
||||
local_context.add_component("stream", name, stream_config)
|
||||
else:
|
||||
# Direct definition
|
||||
stream_config = self._apply_template_vars(stream_def, filled_params)
|
||||
instantiated_components["streams"][name] = stream_config
|
||||
local_context.add_component("stream", name, stream_config)
|
||||
|
||||
# Process routing configuration
|
||||
routing = definition.get("routing", {})
|
||||
routing = self._apply_template_vars(routing, filled_params)
|
||||
|
||||
# Resolve any pending references
|
||||
local_context.resolve_pending()
|
||||
|
||||
# Build composite agent configuration
|
||||
composite_config = {
|
||||
"name": self.name,
|
||||
"type": "composite",
|
||||
"config": {
|
||||
"components": instantiated_components,
|
||||
"routing": routing,
|
||||
"expose_params": filled_params, # Store the parameters used
|
||||
},
|
||||
}
|
||||
|
||||
return composite_config
|
||||
|
||||
def _process_graph_definition(
|
||||
self,
|
||||
graph_def: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
context: InstantiationContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Process a graph definition, resolving agent references."""
|
||||
# Apply template vars
|
||||
graph_def = self._apply_template_vars(graph_def, params)
|
||||
|
||||
# Process nodes to resolve agent references
|
||||
if "nodes" in graph_def:
|
||||
for node_config in graph_def["nodes"].values():
|
||||
if node_config and node_config.get("type") == "agent":
|
||||
agent_ref = node_config.get("agent")
|
||||
if isinstance(agent_ref, str) and not agent_ref.startswith("{{"):
|
||||
# This is a reference to an internal agent
|
||||
resolved = context.resolve_reference(
|
||||
ComponentReference("agent", agent_ref)
|
||||
)
|
||||
if resolved:
|
||||
# Replace with the resolved agent configuration
|
||||
node_config["agent_config"] = resolved
|
||||
|
||||
return graph_def
|
||||
@@ -0,0 +1,323 @@
|
||||
"""
|
||||
Base classes for the template system.
|
||||
"""
|
||||
# pylint: disable=duplicate-code
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from abc import ABC
|
||||
from abc import abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
from enum import Enum
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from jinja2 import Template
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from typing import Protocol
|
||||
|
||||
class TemplateRegistryProtocol(Protocol):
|
||||
"""Protocol for template registries."""
|
||||
|
||||
def get_template(
|
||||
self, template_type: "TemplateType", name: str
|
||||
) -> "BaseTemplate":
|
||||
"""Get a template by type and name."""
|
||||
|
||||
def instantiate(
|
||||
self,
|
||||
template_type: "TemplateType",
|
||||
name: str,
|
||||
params: dict[str, Any],
|
||||
context: Optional["InstantiationContext"] = None,
|
||||
) -> Any:
|
||||
"""Instantiate a template with parameters."""
|
||||
|
||||
|
||||
class TemplateType(Enum):
|
||||
"""Types of templates in the system."""
|
||||
|
||||
AGENT = "agent"
|
||||
GRAPH = "graph"
|
||||
STREAM = "stream"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TemplateParameter:
|
||||
"""Definition of a template parameter."""
|
||||
|
||||
name: str
|
||||
default: Any = None
|
||||
type: str = (
|
||||
"string" # string, int, float, boolean, enum, list, dict, agent_ref, component_ref
|
||||
)
|
||||
values: Optional[List[Any]] = None # For enum types
|
||||
description: Optional[str] = None
|
||||
required: bool = False
|
||||
|
||||
def validate( # pylint: disable=too-many-return-statements,too-many-branches
|
||||
self, value: Any
|
||||
) -> Any:
|
||||
"""Validate and convert parameter value."""
|
||||
if value is None:
|
||||
if self.required:
|
||||
raise ValueError(f"Required parameter '{self.name}' not provided")
|
||||
return self.default
|
||||
|
||||
# Type validation and conversion
|
||||
if self.type == "string":
|
||||
return str(value)
|
||||
if self.type == "int":
|
||||
return int(value)
|
||||
if self.type == "float":
|
||||
return float(value)
|
||||
if self.type == "boolean":
|
||||
if isinstance(value, str):
|
||||
return value.lower() in ("true", "yes", "1", "on")
|
||||
return bool(value)
|
||||
if self.type == "enum":
|
||||
if self.values and value not in self.values:
|
||||
raise ValueError(
|
||||
f"Parameter '{self.name}' must be one of {self.values}, got '{value}'"
|
||||
)
|
||||
return value
|
||||
if self.type == "list":
|
||||
if isinstance(value, list):
|
||||
return value
|
||||
return [value] # Convert single value to list
|
||||
if self.type in ("dict", "agent_ref", "component_ref"):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
@dataclass
|
||||
class ComponentReference:
|
||||
"""Reference to a component that can be resolved during instantiation."""
|
||||
|
||||
ref_type: str # 'agent', 'graph', 'stream'
|
||||
ref_name: str
|
||||
ref_params: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
def resolve(self, context: "InstantiationContext") -> Any:
|
||||
"""Resolve this reference in the given context."""
|
||||
return context.resolve_reference(self)
|
||||
|
||||
|
||||
class InstantiationContext:
|
||||
"""Context for resolving references during template instantiation."""
|
||||
|
||||
def __init__(self, parent: Optional["InstantiationContext"] = None):
|
||||
self.parent = parent
|
||||
self.components: dict[str, dict[str, Any]] = {
|
||||
"agents": {},
|
||||
"graphs": {},
|
||||
"streams": {},
|
||||
}
|
||||
self._pending_resolutions: List[tuple[ComponentReference, str]] = []
|
||||
|
||||
def add_component(self, comp_type: str, name: str, instance: Any) -> None:
|
||||
"""Add a component to this context."""
|
||||
self.components[f"{comp_type}s"][name] = instance
|
||||
logger.debug("Added %s '%s' to context", comp_type, name)
|
||||
|
||||
def resolve_reference(self, ref: ComponentReference) -> Any:
|
||||
"""Resolve a component reference."""
|
||||
comp_type_plural = f"{ref.ref_type}s"
|
||||
|
||||
# First check local context
|
||||
if ref.ref_name in self.components.get(comp_type_plural, {}):
|
||||
return self.components[comp_type_plural][ref.ref_name]
|
||||
|
||||
# Then check parent context
|
||||
if self.parent:
|
||||
return self.parent.resolve_reference(ref)
|
||||
|
||||
# If not found, it might be pending - add to pending list
|
||||
self._pending_resolutions.append((ref, comp_type_plural))
|
||||
return None
|
||||
|
||||
def resolve_pending(self) -> None:
|
||||
"""Attempt to resolve any pending references."""
|
||||
unresolved = []
|
||||
for ref, comp_type_plural in self._pending_resolutions:
|
||||
if ref.ref_name in self.components.get(comp_type_plural, {}):
|
||||
# Now it's available
|
||||
continue
|
||||
unresolved.append((ref, comp_type_plural))
|
||||
|
||||
if unresolved:
|
||||
refs = [f"{ref.ref_type}/{ref.ref_name}" for ref, _ in unresolved]
|
||||
raise ValueError(f"Cannot resolve references: {refs}")
|
||||
|
||||
def get_all_components(self) -> dict[str, dict[str, Any]]:
|
||||
"""Get all components in this context."""
|
||||
return copy.deepcopy(self.components)
|
||||
|
||||
|
||||
class BaseTemplate(ABC):
|
||||
"""Base class for all templates."""
|
||||
|
||||
def __init__(
|
||||
self, name: str, template_type: TemplateType, definition: dict[str, Any]
|
||||
):
|
||||
self.name = name
|
||||
self.template_type = template_type
|
||||
self.definition = definition
|
||||
self.parameters = self._parse_parameters()
|
||||
logger.debug("Created %s template '%s'", template_type.value, name)
|
||||
|
||||
def _parse_parameters(self) -> dict[str, TemplateParameter]:
|
||||
"""Parse parameter definitions from template."""
|
||||
params = {}
|
||||
params_def = self.definition.get("parameters", {})
|
||||
|
||||
# Handle both dict and list formats
|
||||
if isinstance(params_def, dict):
|
||||
# Dictionary format: {param_name: {description: ..., type: ..., ...}}
|
||||
for name, param_config in params_def.items():
|
||||
if isinstance(param_config, dict):
|
||||
param = TemplateParameter(name=name, **param_config)
|
||||
else:
|
||||
# Simple value, treat as default
|
||||
param = TemplateParameter(name=name, default=param_config)
|
||||
params[name] = param
|
||||
elif isinstance(params_def, list):
|
||||
# List format: [{name: ..., description: ..., ...}, ...]
|
||||
for param_def in params_def:
|
||||
if isinstance(param_def, dict):
|
||||
param = TemplateParameter(**param_def)
|
||||
params[param.name] = param
|
||||
else:
|
||||
# Simple parameter name
|
||||
params[param_def] = TemplateParameter(name=param_def)
|
||||
|
||||
return params
|
||||
|
||||
def validate_params(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Validate and fill in default parameters."""
|
||||
result = {}
|
||||
|
||||
# Validate all defined parameters
|
||||
for param_name, param_def in self.parameters.items():
|
||||
value = params.get(param_name)
|
||||
result[param_name] = param_def.validate(value)
|
||||
|
||||
# Include any extra parameters not in definition (for flexibility)
|
||||
for key, value in params.items():
|
||||
if key not in result:
|
||||
result[key] = value
|
||||
|
||||
return result
|
||||
|
||||
@abstractmethod
|
||||
def instantiate(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
registry: "TemplateRegistryProtocol",
|
||||
context: InstantiationContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Create concrete instance from template."""
|
||||
raise NotImplementedError("Subclasses must implement instantiate()")
|
||||
|
||||
def _apply_template_vars( # pylint: disable=too-many-locals,too-many-return-statements,too-many-branches
|
||||
self, config: Any, params: dict[str, Any]
|
||||
) -> Any:
|
||||
"""Recursively apply Jinja2 template variables."""
|
||||
if isinstance(config, str):
|
||||
# Check if it's a template string
|
||||
if "{{" in config or "{%" in config:
|
||||
try:
|
||||
template = Template(config)
|
||||
rendered = template.render(**params)
|
||||
# Try to evaluate the result if it looks like a value
|
||||
if rendered.lower() in ("true", "false"):
|
||||
return rendered.lower() == "true"
|
||||
|
||||
# Try to parse as JSON-like structure (list or dict)
|
||||
if (rendered.startswith("[") and rendered.endswith("]")) or (
|
||||
rendered.startswith("{") and rendered.endswith("}")
|
||||
):
|
||||
try:
|
||||
import json # pylint: disable=import-outside-toplevel
|
||||
|
||||
# Replace single quotes with double quotes for JSON parsing
|
||||
json_str = rendered.replace("'", '"')
|
||||
return json.loads(json_str)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
pass
|
||||
|
||||
try:
|
||||
# Try to parse as number
|
||||
if "." in rendered:
|
||||
return float(rendered)
|
||||
return int(rendered)
|
||||
except ValueError:
|
||||
return rendered
|
||||
except Exception as e: # pylint: disable=broad-exception-caught
|
||||
logger.warning(
|
||||
"Template rendering failed: %s, using original: %s", e, config
|
||||
)
|
||||
return config
|
||||
return config
|
||||
if isinstance(config, dict):
|
||||
# Handle conditional sections
|
||||
if len(config) == 1 and list(config.keys())[0].startswith("{% if"):
|
||||
# This is a conditional block
|
||||
condition_key = list(config.keys())[0]
|
||||
condition_content = config[condition_key]
|
||||
|
||||
# Render the condition
|
||||
template = Template(
|
||||
condition_key + "\n" + str(condition_content) + "\n{% endif %}"
|
||||
)
|
||||
rendered = template.render(**params).strip()
|
||||
|
||||
if rendered:
|
||||
# Condition was true, parse the content
|
||||
import yaml # pylint: disable=import-outside-toplevel
|
||||
|
||||
try:
|
||||
return yaml.safe_load(rendered)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
return rendered
|
||||
# Condition was false, exclude this section
|
||||
return None
|
||||
# Normal dictionary
|
||||
result: dict[Any, Any] = {}
|
||||
for key, value in config.items():
|
||||
processed_value = self._apply_template_vars(value, params)
|
||||
if (
|
||||
processed_value is not None
|
||||
): # Skip None values (excluded sections)
|
||||
# Also process the key in case it has templates
|
||||
processed_key = self._apply_template_vars(key, params)
|
||||
result[processed_key] = processed_value
|
||||
return result
|
||||
if isinstance(config, list):
|
||||
# Process list items and filter out None values
|
||||
result_list: List[Any] = []
|
||||
for item in config:
|
||||
processed = self._apply_template_vars(item, params)
|
||||
if processed is not None:
|
||||
result_list.append(processed)
|
||||
return result_list
|
||||
return config
|
||||
|
||||
def _merge_params(
|
||||
self, base_params: dict[str, Any], override_params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Merge parameter dictionaries with override semantics."""
|
||||
result = copy.deepcopy(base_params)
|
||||
|
||||
for key, value in override_params.items():
|
||||
# Apply template vars to the override value using base params
|
||||
processed_value = self._apply_template_vars(value, base_params)
|
||||
result[key] = processed_value
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,133 @@
|
||||
"""
|
||||
Deferred template processing for complex Jinja2 templates.
|
||||
|
||||
This module provides a way to store template definitions with Jinja2 syntax
|
||||
and process them only during instantiation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from cleveragents.templates.yaml_preprocessor import YAMLTemplateProcessor
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class DeferredTemplate:
|
||||
"""
|
||||
A template that stores its definition as a string and processes it on demand.
|
||||
|
||||
This allows complex Jinja2 syntax to be stored in template definitions
|
||||
without breaking YAML parsing.
|
||||
"""
|
||||
|
||||
def __init__(self, template_str: str):
|
||||
self.template_str = template_str
|
||||
self.processor = YAMLTemplateProcessor()
|
||||
|
||||
def render(self, context: dict[str, Any]) -> Any:
|
||||
"""
|
||||
Render the template with the given context.
|
||||
|
||||
Args:
|
||||
context: Template variables
|
||||
|
||||
Returns:
|
||||
Processed template result (usually a dict)
|
||||
"""
|
||||
# Process the template string
|
||||
rendered = self.processor.process_string(self.template_str, context)
|
||||
return rendered
|
||||
|
||||
@classmethod
|
||||
def from_yaml_section(
|
||||
cls, yaml_dict: dict[str, Any], key: str
|
||||
) -> Optional["DeferredTemplate"]:
|
||||
"""
|
||||
Create a deferred template from a YAML section if it contains template syntax.
|
||||
|
||||
Args:
|
||||
yaml_dict: The YAML dictionary
|
||||
key: The key to check
|
||||
|
||||
Returns:
|
||||
DeferredTemplate if the section contains templates, None otherwise
|
||||
"""
|
||||
if key not in yaml_dict:
|
||||
return None
|
||||
|
||||
# Convert the section back to YAML string
|
||||
section_yaml = yaml.dump({key: yaml_dict[key]}, default_flow_style=False)
|
||||
|
||||
# Check if it contains template syntax
|
||||
if "{%" in section_yaml or "{{" in section_yaml:
|
||||
return cls(section_yaml)
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def process_template_definition(definition: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Process a template definition to handle deferred template sections.
|
||||
|
||||
This function identifies sections that need deferred processing and
|
||||
converts them to DeferredTemplate objects.
|
||||
|
||||
Args:
|
||||
definition: Template definition dictionary
|
||||
|
||||
Returns:
|
||||
Processed definition with deferred templates
|
||||
"""
|
||||
processed = definition.copy()
|
||||
|
||||
# Check for sections that commonly contain complex templates
|
||||
template_sections = ["components", "nodes", "edges", "operators", "routing"]
|
||||
|
||||
for section in template_sections:
|
||||
if section in processed:
|
||||
# Check if this section contains template syntax
|
||||
section_yaml = yaml.dump(processed[section], default_flow_style=False)
|
||||
if "{%" in section_yaml or "{{" in section_yaml:
|
||||
# Store as deferred template
|
||||
processed[f"_deferred_{section}"] = DeferredTemplate(section_yaml)
|
||||
# Keep a placeholder
|
||||
processed[section] = {"_deferred": True}
|
||||
|
||||
return processed
|
||||
|
||||
|
||||
def apply_deferred_templates(
|
||||
config: dict[str, Any], context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Apply any deferred templates in a configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration that may contain deferred templates
|
||||
context: Template context
|
||||
|
||||
Returns:
|
||||
Configuration with deferred templates processed
|
||||
"""
|
||||
result = config.copy()
|
||||
|
||||
# Look for deferred template markers
|
||||
for key, value in list(result.items()):
|
||||
if key.startswith("_deferred_"):
|
||||
actual_key = key[len("_deferred_") :]
|
||||
if isinstance(value, DeferredTemplate):
|
||||
# Render the deferred template
|
||||
rendered = value.render(context)
|
||||
# Extract the actual section from the rendered result
|
||||
if isinstance(rendered, dict) and actual_key in rendered:
|
||||
result[actual_key] = rendered[actual_key]
|
||||
else:
|
||||
result[actual_key] = rendered
|
||||
# Remove the deferred marker
|
||||
del result[key]
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,268 @@
|
||||
"""
|
||||
Enhanced template registry that supports complex Jinja2 templates.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
|
||||
from cleveragents.templates.base import BaseTemplate
|
||||
from cleveragents.templates.base import InstantiationContext
|
||||
from cleveragents.templates.base import TemplateType
|
||||
from cleveragents.templates.template_store import TemplateStore
|
||||
from cleveragents.templates.yaml_preprocessor import YAMLTemplateProcessor
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.templates.base import TemplateRegistryProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnhancedTemplateRegistry:
|
||||
"""
|
||||
Enhanced registry that supports complex Jinja2 templates in YAML.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.store = TemplateStore()
|
||||
self.processor = YAMLTemplateProcessor()
|
||||
|
||||
# Cache for instantiated template classes
|
||||
self._template_cache: dict[TemplateType, dict[str, BaseTemplate]] = {
|
||||
TemplateType.AGENT: {},
|
||||
TemplateType.GRAPH: {},
|
||||
TemplateType.STREAM: {},
|
||||
}
|
||||
|
||||
logger.debug("Initialized enhanced template registry")
|
||||
|
||||
def register_template_string(
|
||||
self, template_type: TemplateType, name: str, template_yaml: str
|
||||
) -> None:
|
||||
"""
|
||||
Register a template from a YAML string.
|
||||
|
||||
Args:
|
||||
template_type: Type of template
|
||||
name: Template name
|
||||
template_yaml: YAML string containing the template
|
||||
"""
|
||||
# Store the raw template
|
||||
type_str = template_type.value + "s" # Convert to plural
|
||||
self.store.add_template(type_str, name, template_yaml)
|
||||
logger.info(
|
||||
"Registered %s template '%s' from string", template_type.value, name
|
||||
)
|
||||
|
||||
def register_template_file(
|
||||
self, template_type: TemplateType, name: str, file_path: Path
|
||||
) -> None:
|
||||
"""
|
||||
Register a template from a file.
|
||||
|
||||
Args:
|
||||
template_type: Type of template
|
||||
name: Template name
|
||||
file_path: Path to template file
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
template_yaml = f.read()
|
||||
|
||||
self.register_template_string(template_type, name, template_yaml)
|
||||
|
||||
def register_template_dict(
|
||||
self, template_type: TemplateType, name: str, definition: dict[str, Any]
|
||||
) -> None:
|
||||
"""
|
||||
Register a template from a dictionary.
|
||||
|
||||
This method checks if the definition contains Jinja2 syntax and
|
||||
handles it appropriately.
|
||||
|
||||
Args:
|
||||
template_type: Type of template
|
||||
name: Template name
|
||||
definition: Template definition dictionary
|
||||
"""
|
||||
# Check if the definition contains Jinja2 syntax
|
||||
yaml_str = yaml.dump(definition, default_flow_style=False)
|
||||
|
||||
if "{%" in yaml_str or "{{" in yaml_str:
|
||||
# Contains templates, store as string
|
||||
self.register_template_string(template_type, name, yaml_str)
|
||||
else:
|
||||
# No templates, can use regular registration
|
||||
self._register_simple_template(template_type, name, definition)
|
||||
|
||||
def _register_simple_template(
|
||||
self, template_type: TemplateType, name: str, definition: dict[str, Any]
|
||||
) -> None:
|
||||
"""Register a simple template without Jinja2 syntax."""
|
||||
# Import template classes here to avoid circular imports
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from cleveragents.templates.agent_templates import AgentTemplate
|
||||
from cleveragents.templates.agent_templates import CompositeAgentTemplate
|
||||
from cleveragents.templates.graph_templates import GraphTemplate
|
||||
from cleveragents.templates.stream_templates import StreamTemplate
|
||||
|
||||
# Create appropriate template instance
|
||||
template: BaseTemplate
|
||||
if template_type == TemplateType.AGENT:
|
||||
if definition.get("type") == "composite":
|
||||
template = CompositeAgentTemplate(name, template_type, definition)
|
||||
else:
|
||||
template = AgentTemplate(name, template_type, definition)
|
||||
elif template_type == TemplateType.GRAPH:
|
||||
template = GraphTemplate(name, template_type, definition)
|
||||
elif template_type == TemplateType.STREAM:
|
||||
template = StreamTemplate(name, template_type, definition)
|
||||
else:
|
||||
raise ValueError(f"Unknown template type: {template_type}")
|
||||
|
||||
self._template_cache[template_type][name] = template
|
||||
logger.info("Registered simple %s template '%s'", template_type.value, name)
|
||||
|
||||
def get_template(self, template_type: TemplateType, name: str) -> BaseTemplate:
|
||||
"""Get a template by type and name."""
|
||||
if name in self._template_cache.get(template_type, {}):
|
||||
return self._template_cache[template_type][name]
|
||||
|
||||
# Check if it exists in the store
|
||||
type_str = template_type.value + "s"
|
||||
if self.store.get_template(type_str, name):
|
||||
# For complex templates in the store, we need to create a BaseTemplate wrapper
|
||||
# This is a limitation - complex templates don't have a BaseTemplate representation
|
||||
raise ValueError(
|
||||
f"Template '{name}' is a complex template and cannot be retrieved as BaseTemplate. "
|
||||
"Use instantiate() method instead."
|
||||
)
|
||||
|
||||
raise ValueError(
|
||||
f"{template_type.value.capitalize()} template '{name}' not found"
|
||||
)
|
||||
|
||||
def instantiate(
|
||||
self,
|
||||
template_type: TemplateType,
|
||||
name: str,
|
||||
params: dict[str, Any],
|
||||
context: Optional[InstantiationContext] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Instantiate a template with parameters.
|
||||
|
||||
Args:
|
||||
template_type: Type of template
|
||||
name: Template name
|
||||
params: Template parameters
|
||||
context: Optional instantiation context
|
||||
|
||||
Returns:
|
||||
Instantiated configuration
|
||||
"""
|
||||
# Check cache first
|
||||
if name in self._template_cache.get(template_type, {}):
|
||||
template = self._template_cache[template_type][name]
|
||||
return template.instantiate(params, self, context or InstantiationContext())
|
||||
|
||||
# Check store for complex templates
|
||||
type_str = template_type.value + "s"
|
||||
raw_template = self.store.get_template(type_str, name)
|
||||
|
||||
if raw_template:
|
||||
# Process the template with parameters
|
||||
instantiated = self.store.instantiate_template(type_str, name, params)
|
||||
|
||||
# If we have a context, we might need to do additional processing
|
||||
if context:
|
||||
# Component references in instantiated templates are handled by the template class
|
||||
pass
|
||||
|
||||
return instantiated
|
||||
|
||||
raise ValueError(
|
||||
f"{template_type.value.capitalize()} template '{name}' not found"
|
||||
)
|
||||
|
||||
def register_all_templates(self, templates_config: dict[str, dict[str, dict[str, Any]]]) -> None: # pylint: disable=line-too-long
|
||||
"""
|
||||
Register all templates from a configuration dictionary.
|
||||
|
||||
Args:
|
||||
templates_config: Dictionary with template definitions by type
|
||||
"""
|
||||
# Process each template type
|
||||
for type_name, templates in templates_config.items():
|
||||
if type_name == "agents":
|
||||
template_type = TemplateType.AGENT
|
||||
elif type_name == "graphs":
|
||||
template_type = TemplateType.GRAPH
|
||||
elif type_name == "streams":
|
||||
template_type = TemplateType.STREAM
|
||||
else:
|
||||
logger.warning("Unknown template type: %s", type_name)
|
||||
continue
|
||||
|
||||
# Register each template
|
||||
for name, definition in templates.items():
|
||||
self.register_template_dict(template_type, name, definition)
|
||||
|
||||
def has_template(self, template_type: TemplateType, name: str) -> bool:
|
||||
"""Check if a template exists."""
|
||||
# Check cache
|
||||
if name in self._template_cache.get(template_type, {}):
|
||||
return True
|
||||
|
||||
# Check store
|
||||
type_str = template_type.value + "s"
|
||||
return self.store.get_template(type_str, name) is not None
|
||||
|
||||
def get_template_metadata(
|
||||
self, template_type: TemplateType, name: str
|
||||
) -> dict[str, Any]:
|
||||
"""Get template metadata (parameters, type, etc.)."""
|
||||
# Check cache
|
||||
if name in self._template_cache.get(template_type, {}):
|
||||
template = self._template_cache[template_type][name]
|
||||
return {
|
||||
"type": template.definition.get("type", "unknown"),
|
||||
"parameters": {
|
||||
name: param.__dict__ for name, param in template.parameters.items()
|
||||
},
|
||||
}
|
||||
|
||||
# Check store
|
||||
type_str = template_type.value + "s"
|
||||
metadata = self.store.get_metadata(type_str, name)
|
||||
if metadata:
|
||||
return metadata
|
||||
|
||||
raise ValueError(
|
||||
f"{template_type.value.capitalize()} template '{name}' not found"
|
||||
)
|
||||
|
||||
def list_templates(
|
||||
self, template_type: Optional[TemplateType] = None
|
||||
) -> dict[str, list[str]]:
|
||||
"""List all registered templates."""
|
||||
result = {}
|
||||
|
||||
if template_type:
|
||||
# Single type
|
||||
type_str = template_type.value + "s"
|
||||
names = list(self._template_cache.get(template_type, {}).keys())
|
||||
names.extend(self.store.raw_templates.get(type_str, {}).keys())
|
||||
result[type_str] = list(set(names)) # Remove duplicates
|
||||
else:
|
||||
# All types
|
||||
for t_type in TemplateType:
|
||||
type_str = t_type.value + "s"
|
||||
names = list(self._template_cache.get(t_type, {}).keys())
|
||||
names.extend(self.store.raw_templates.get(type_str, {}).keys())
|
||||
result[type_str] = list(set(names))
|
||||
|
||||
return result
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Graph template implementations for LangGraph.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import List
|
||||
|
||||
from cleveragents.templates.base import BaseTemplate
|
||||
from cleveragents.templates.base import ComponentReference
|
||||
from cleveragents.templates.base import InstantiationContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import TemplateRegistryProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GraphTemplate(BaseTemplate):
|
||||
"""Template for LangGraphs."""
|
||||
|
||||
def instantiate(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
registry: "TemplateRegistryProtocol",
|
||||
context: InstantiationContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Create graph configuration from template."""
|
||||
filled_params = self.validate_params(params)
|
||||
|
||||
# Deep copy the definition
|
||||
graph_def: dict[str, Any] = copy.deepcopy(self.definition)
|
||||
|
||||
# Remove parameters section
|
||||
if "parameters" in graph_def:
|
||||
del graph_def["parameters"]
|
||||
|
||||
# Apply template variables to graph definition
|
||||
graph_def = self._apply_template_vars(graph_def, filled_params)
|
||||
|
||||
# Process nodes to resolve agent references
|
||||
if "nodes" in graph_def:
|
||||
graph_def["nodes"] = self._process_nodes(
|
||||
graph_def["nodes"], filled_params, context
|
||||
)
|
||||
|
||||
# Process edges with conditional rendering
|
||||
if "edges" in graph_def:
|
||||
graph_def["edges"] = self._process_edges(graph_def["edges"], filled_params)
|
||||
|
||||
# Add name if not present
|
||||
if "name" not in graph_def:
|
||||
graph_def["name"] = self.name
|
||||
|
||||
return graph_def
|
||||
|
||||
def _process_nodes(
|
||||
self,
|
||||
nodes: dict[str, Any],
|
||||
params: dict[str, Any],
|
||||
context: InstantiationContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Process graph nodes, resolving agent references."""
|
||||
processed_nodes = {}
|
||||
|
||||
for node_name, node_config in nodes.items():
|
||||
if node_config is None: # Conditionally excluded
|
||||
continue
|
||||
|
||||
# Check if this is an agent node that needs resolution
|
||||
if node_config.get("type") == "agent":
|
||||
agent_ref = node_config.get("agent")
|
||||
|
||||
# Check if it's a parameter reference
|
||||
if isinstance(agent_ref, str) and agent_ref in params:
|
||||
# Replace with parameter value
|
||||
node_config["agent"] = params[agent_ref]
|
||||
elif isinstance(agent_ref, str) and not agent_ref.startswith("{{"):
|
||||
# Try to resolve as a component reference
|
||||
try:
|
||||
resolved = context.resolve_reference(
|
||||
ComponentReference("agent", agent_ref)
|
||||
)
|
||||
if resolved:
|
||||
# Store the resolved configuration
|
||||
node_config["agent_config"] = resolved
|
||||
logger.debug(
|
||||
"Resolved agent reference '%s' in node '%s'",
|
||||
agent_ref,
|
||||
node_name,
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
# Not a local reference, probably a global agent name
|
||||
pass
|
||||
|
||||
processed_nodes[node_name] = node_config
|
||||
|
||||
return processed_nodes
|
||||
|
||||
def _process_edges(self, edges: List[Any], params: dict[str, Any]) -> List[Any]:
|
||||
"""Process edges, handling conditional inclusions."""
|
||||
processed_edges = []
|
||||
|
||||
for edge in edges:
|
||||
if edge is None: # Conditionally excluded
|
||||
continue
|
||||
|
||||
# Process edge conditions if they contain template variables
|
||||
if "condition" in edge and edge["condition"]:
|
||||
edge["condition"] = self._apply_template_vars(edge["condition"], params)
|
||||
|
||||
processed_edges.append(edge)
|
||||
|
||||
return processed_edges
|
||||
@@ -0,0 +1,384 @@
|
||||
"""
|
||||
Handler for inline Jinja2 templates in YAML files.
|
||||
|
||||
This module provides a clean solution for handling Jinja2 templates
|
||||
directly embedded in YAML without requiring multiline strings.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InlineJinjaHandler:
|
||||
"""
|
||||
Handles inline Jinja2 templates in YAML configuration files.
|
||||
|
||||
The approach:
|
||||
1. Parse YAML into lines while tracking structure
|
||||
2. Identify Jinja2 template blocks and inline templates
|
||||
3. Replace them with unique placeholders
|
||||
4. Parse the clean YAML
|
||||
5. Store template content for later rendering
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.env = Environment(
|
||||
block_start_string="{%",
|
||||
block_end_string="%}",
|
||||
variable_start_string="{{",
|
||||
variable_end_string="}}",
|
||||
comment_start_string="{#",
|
||||
comment_end_string="#}",
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
def process_yaml_file(
|
||||
self, file_path: Path, defer_rendering: bool = True
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process a YAML file containing inline Jinja2 templates.
|
||||
|
||||
Args:
|
||||
file_path: Path to YAML file
|
||||
defer_rendering: If True, store templates for later. If False, render immediately.
|
||||
|
||||
Returns:
|
||||
Processed configuration
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
return self.process_yaml_string(content, defer_rendering)
|
||||
|
||||
def process_yaml_string(
|
||||
self,
|
||||
yaml_content: str,
|
||||
defer_rendering: bool = True,
|
||||
context: Optional[dict[str, Any]] = None,
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process YAML content containing inline Jinja2 templates.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML content with Jinja2
|
||||
defer_rendering: If True, store templates. If False, render now.
|
||||
context: Context for immediate rendering (if not deferring)
|
||||
|
||||
Returns:
|
||||
Processed configuration
|
||||
"""
|
||||
# If no templates, just parse
|
||||
if "{%" not in yaml_content and "{{" not in yaml_content:
|
||||
result = yaml.safe_load(yaml_content)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected YAML to parse as dict, got {type(result).__name__}"
|
||||
)
|
||||
return result
|
||||
|
||||
if defer_rendering:
|
||||
# Extract and store templates
|
||||
return self._extract_templates(yaml_content)
|
||||
# Render immediately
|
||||
if context is None:
|
||||
context = {}
|
||||
return self._render_templates(yaml_content, context)
|
||||
|
||||
def _extract_templates( # pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-nested-blocks
|
||||
self, yaml_content: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Extract templates and replace with placeholders.
|
||||
|
||||
This preserves the YAML structure while storing templates separately.
|
||||
"""
|
||||
lines = yaml_content.split("\n")
|
||||
result_lines: List[str] = []
|
||||
templates = {}
|
||||
in_template_block = False
|
||||
template_block_lines: List[str] = []
|
||||
template_block_key = None
|
||||
template_block_indent = 0
|
||||
block_start_line = -1
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# Check for start of template block
|
||||
if not in_template_block and "{%" in line:
|
||||
if any(keyword in line for keyword in ["for", "if", "macro", "block"]):
|
||||
# Start of template block
|
||||
in_template_block = True
|
||||
template_block_indent = indent
|
||||
template_block_lines = []
|
||||
|
||||
# Find the parent key
|
||||
for j in range(len(result_lines) - 1, -1, -1):
|
||||
prev_line = result_lines[j]
|
||||
prev_indent = len(prev_line) - len(prev_line.lstrip())
|
||||
|
||||
if (
|
||||
prev_indent < indent
|
||||
and ":" in prev_line
|
||||
and prev_line.strip().endswith(":")
|
||||
):
|
||||
# This is the parent key
|
||||
key_match = re.match(r"^(\s*)(\S+):\s*$", prev_line)
|
||||
if key_match:
|
||||
template_block_key = key_match.group(2)
|
||||
block_start_line = j
|
||||
break
|
||||
|
||||
template_block_lines.append(line)
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# If in template block, collect lines
|
||||
if in_template_block:
|
||||
template_block_lines.append(line)
|
||||
|
||||
# Check for end of block
|
||||
if any(
|
||||
keyword in line
|
||||
for keyword in ["endfor", "endif", "endmacro", "endblock"]
|
||||
):
|
||||
line_indent = len(line) - len(line.lstrip())
|
||||
if line_indent <= template_block_indent:
|
||||
# End of block
|
||||
in_template_block = False
|
||||
|
||||
# Store the template
|
||||
template_id = f"__template_{uuid.uuid4().hex[:8]}__"
|
||||
templates[template_id] = {
|
||||
"type": "block",
|
||||
"key": template_block_key,
|
||||
"content": "\n".join(template_block_lines),
|
||||
"indent": template_block_indent,
|
||||
}
|
||||
|
||||
# Replace the parent key line
|
||||
if 0 <= block_start_line < len(result_lines):
|
||||
parent_line = result_lines[block_start_line]
|
||||
parent_indent = len(parent_line) - len(parent_line.lstrip())
|
||||
result_lines[block_start_line] = (
|
||||
f"{' ' * parent_indent}{template_block_key}: {template_id}"
|
||||
)
|
||||
|
||||
i += 1
|
||||
continue
|
||||
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for inline template in value
|
||||
if ":" in line and ("{{" in line or "{%" in line):
|
||||
key_value_match = re.match(r"^(\s*)(\S+):\s*(.+)$", line)
|
||||
if key_value_match:
|
||||
indent_str = key_value_match.group(1)
|
||||
key = key_value_match.group(2)
|
||||
value = key_value_match.group(3).strip()
|
||||
|
||||
# Check if the value contains templates
|
||||
if "{{" in value or "{%" in value:
|
||||
template_id = f"__template_{uuid.uuid4().hex[:8]}__"
|
||||
templates[template_id] = {
|
||||
"type": "inline",
|
||||
"key": key,
|
||||
"content": value,
|
||||
"indent": len(indent_str),
|
||||
}
|
||||
|
||||
# Replace with placeholder
|
||||
result_lines.append(f"{indent_str}{key}: {template_id}")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Regular line
|
||||
result_lines.append(line)
|
||||
i += 1
|
||||
|
||||
# Parse the cleaned YAML
|
||||
clean_yaml = "\n".join(result_lines)
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(clean_yaml)
|
||||
except yaml.YAMLError as e:
|
||||
logger.error("Failed to parse cleaned YAML: %s", e)
|
||||
logger.debug("Cleaned YAML:\n%s", clean_yaml)
|
||||
raise
|
||||
|
||||
# Ensure result is a dict (wrap non-dicts)
|
||||
if not isinstance(parsed, dict):
|
||||
parsed = {"_content": parsed}
|
||||
|
||||
# Attach templates for later use
|
||||
if templates:
|
||||
parsed["__templates__"] = templates
|
||||
|
||||
return parsed
|
||||
|
||||
def _render_templates(
|
||||
self, yaml_content: str, context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Render all templates immediately with the given context.
|
||||
"""
|
||||
# Add utility functions to context
|
||||
full_context = {
|
||||
"range": range,
|
||||
"len": len,
|
||||
"str": str,
|
||||
"int": int,
|
||||
"float": float,
|
||||
"bool": bool,
|
||||
"list": list,
|
||||
"dict": dict,
|
||||
"enumerate": enumerate,
|
||||
"zip": zip,
|
||||
"min": min,
|
||||
"max": max,
|
||||
}
|
||||
full_context.update(context)
|
||||
|
||||
# Render as one big template
|
||||
template = self.env.from_string(yaml_content)
|
||||
rendered = template.render(**full_context)
|
||||
|
||||
# Parse the result
|
||||
result = yaml.safe_load(rendered)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected rendered YAML to parse as dict, got {type(result).__name__}"
|
||||
)
|
||||
return result
|
||||
|
||||
def apply_templates(
|
||||
self, config: dict[str, Any], context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Apply stored templates to a configuration.
|
||||
|
||||
Args:
|
||||
config: Configuration with template placeholders
|
||||
context: Context for rendering
|
||||
|
||||
Returns:
|
||||
Configuration with templates applied
|
||||
"""
|
||||
if "__templates__" not in config:
|
||||
return config
|
||||
|
||||
templates = config["__templates__"]
|
||||
|
||||
# Remove templates from result
|
||||
result = {k: v for k, v in config.items() if k != "__templates__"}
|
||||
|
||||
# Apply templates recursively
|
||||
result_with_templates: dict[str, Any] = self._apply_templates_recursive(
|
||||
result, templates, context
|
||||
)
|
||||
|
||||
return result_with_templates
|
||||
|
||||
def _apply_templates_recursive( # pylint: disable=too-many-nested-blocks
|
||||
self, data: Any, templates: dict[str, Any], context: dict[str, Any]
|
||||
) -> Any:
|
||||
"""
|
||||
Recursively apply templates in a data structure.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
result = {}
|
||||
for key, value in data.items():
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and value.startswith("__template_")
|
||||
and value.endswith("__")
|
||||
):
|
||||
# This is a template placeholder
|
||||
template_info = templates.get(value, {})
|
||||
|
||||
if template_info.get("type") == "block":
|
||||
# Render block template
|
||||
template_content = template_info["content"]
|
||||
rendered = self._render_template_string(
|
||||
template_content, context
|
||||
)
|
||||
|
||||
# Parse the rendered content
|
||||
try:
|
||||
parsed = yaml.safe_load(rendered)
|
||||
if isinstance(parsed, dict):
|
||||
result.update(parsed)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.warning(
|
||||
"Could not parse rendered template for key '%s'", key
|
||||
)
|
||||
result[key] = rendered
|
||||
|
||||
elif template_info.get("type") == "inline":
|
||||
# Render inline template
|
||||
template_content = template_info["content"]
|
||||
rendered = self._render_template_string(
|
||||
template_content, context
|
||||
)
|
||||
result[key] = self._parse_rendered_value(rendered)
|
||||
|
||||
else:
|
||||
result[key] = self._apply_templates_recursive(
|
||||
value, templates, context
|
||||
)
|
||||
|
||||
return result
|
||||
|
||||
if isinstance(data, list):
|
||||
return [
|
||||
self._apply_templates_recursive(item, templates, context)
|
||||
for item in data
|
||||
]
|
||||
|
||||
return data
|
||||
|
||||
def _render_template_string(
|
||||
self, template_str: str, context: dict[str, Any]
|
||||
) -> str:
|
||||
"""Render a single template string."""
|
||||
full_context = {
|
||||
"range": range,
|
||||
"len": len,
|
||||
"str": str,
|
||||
"int": int,
|
||||
"float": float,
|
||||
"bool": bool,
|
||||
"list": list,
|
||||
"dict": dict,
|
||||
"enumerate": enumerate,
|
||||
"zip": zip,
|
||||
"min": min,
|
||||
"max": max,
|
||||
}
|
||||
full_context.update(context)
|
||||
|
||||
template = self.env.from_string(template_str)
|
||||
return template.render(**full_context)
|
||||
|
||||
def _parse_rendered_value(self, value: str) -> Any:
|
||||
"""Parse a rendered value to appropriate Python type."""
|
||||
# Try to parse as YAML first (handles lists, dicts, etc.)
|
||||
try:
|
||||
return yaml.safe_load(value)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
# Return as string
|
||||
return value
|
||||
@@ -0,0 +1,445 @@
|
||||
"""
|
||||
Complete solution for inline Jinja2 in YAML files.
|
||||
|
||||
This module provides the definitive solution for using Jinja2 templates
|
||||
directly in YAML configuration files.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InlineYAMLJinja:
|
||||
"""
|
||||
Handles inline Jinja2 templates in YAML files.
|
||||
|
||||
Key features:
|
||||
1. Users write natural YAML with Jinja2
|
||||
2. Automatic handling of structure and indentation
|
||||
3. Support for both immediate and deferred rendering
|
||||
4. No need for multiline strings
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Configure Jinja2 to work well with YAML
|
||||
self.env = Environment(
|
||||
block_start_string="{%",
|
||||
block_end_string="%}",
|
||||
variable_start_string="{{",
|
||||
variable_end_string="}}",
|
||||
comment_start_string="{#",
|
||||
comment_end_string="#}",
|
||||
)
|
||||
|
||||
# Add useful filters
|
||||
self.env.filters.update(
|
||||
{
|
||||
"yaml": lambda x: yaml.dump(x, default_flow_style=True).strip(),
|
||||
"json": json.dumps,
|
||||
"indent": lambda x, n: "\n".join(
|
||||
" " * n + line for line in str(x).split("\n")
|
||||
),
|
||||
}
|
||||
)
|
||||
|
||||
# Add tests
|
||||
self.env.tests.update(
|
||||
{
|
||||
"list": lambda x: isinstance(x, list),
|
||||
"dict": lambda x: isinstance(x, dict),
|
||||
"none": lambda x: x is None,
|
||||
}
|
||||
)
|
||||
|
||||
def process_file(
|
||||
self, file_path: Path, context: Optional[dict[str, Any]] = None
|
||||
) -> dict[str, Any]:
|
||||
"""Process a YAML file with inline Jinja2."""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return self.process_string(content, context)
|
||||
|
||||
def process_string(
|
||||
self, yaml_content: str, context: Optional[dict[str, Any]] = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process YAML content with inline Jinja2 templates.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML with Jinja2
|
||||
context: Template context (None for deferred)
|
||||
|
||||
Returns:
|
||||
Parsed configuration
|
||||
"""
|
||||
# No templates? Just parse
|
||||
if not self._has_templates(yaml_content):
|
||||
result = yaml.safe_load(yaml_content)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected YAML to parse as dict, got {type(result).__name__}"
|
||||
)
|
||||
return result
|
||||
|
||||
if context is not None:
|
||||
# Render immediately
|
||||
return self._render_and_parse(yaml_content, context)
|
||||
# Store for deferred rendering
|
||||
return self._store_for_deferred(yaml_content)
|
||||
|
||||
def _has_templates(self, content: str) -> bool:
|
||||
"""Check if content has Jinja2 templates."""
|
||||
return "{%" in content or "{{" in content
|
||||
|
||||
def _render_and_parse(
|
||||
self, content: str, context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Render templates and parse YAML."""
|
||||
# First, analyze the content to understand its structure
|
||||
structure = self._analyze_structure(content)
|
||||
|
||||
# Add context helpers
|
||||
full_context = self._prepare_context(context)
|
||||
|
||||
# Smart rendering based on structure
|
||||
if structure["has_block_templates"]:
|
||||
# Use line-by-line rendering for better control
|
||||
rendered = self._render_structured(content, full_context)
|
||||
else:
|
||||
# Simple rendering for inline templates only
|
||||
template = self.env.from_string(content)
|
||||
rendered = template.render(**full_context)
|
||||
|
||||
# Parse the rendered YAML
|
||||
try:
|
||||
result = yaml.safe_load(rendered)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected rendered YAML to parse as dict, got {type(result).__name__}"
|
||||
)
|
||||
return result
|
||||
except yaml.YAMLError as exc:
|
||||
logger.debug("Rendered YAML:\n%s", rendered)
|
||||
# Try to fix common issues
|
||||
fixed = self._fix_yaml_issues(rendered)
|
||||
result = yaml.safe_load(fixed)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected fixed YAML to parse as dict, got {type(result).__name__}"
|
||||
) from exc
|
||||
return result
|
||||
|
||||
def _analyze_structure(self, content: str) -> dict[str, Any]:
|
||||
"""Analyze content structure for smarter rendering."""
|
||||
lines = content.split("\n")
|
||||
structure: dict[str, Any] = {
|
||||
"has_block_templates": False,
|
||||
"has_inline_templates": False,
|
||||
"template_blocks": [],
|
||||
"max_indent": 0,
|
||||
}
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
if "{%" in line:
|
||||
if any(kw in line for kw in ["for", "if", "elif", "else"]):
|
||||
structure["has_block_templates"] = True
|
||||
structure["template_blocks"].append(i)
|
||||
|
||||
if "{{" in line:
|
||||
structure["has_inline_templates"] = True
|
||||
|
||||
# Track indentation
|
||||
if line.strip():
|
||||
indent = len(line) - len(line.lstrip())
|
||||
structure["max_indent"] = max(structure["max_indent"], indent)
|
||||
|
||||
return structure
|
||||
|
||||
def _render_structured( # pylint: disable=too-many-locals,too-many-nested-blocks
|
||||
self, content: str, context: dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Render content with awareness of YAML structure.
|
||||
|
||||
This method ensures proper line breaks and indentation.
|
||||
"""
|
||||
# First pass: render the entire template
|
||||
template = self.env.from_string(content)
|
||||
rendered = template.render(**context)
|
||||
|
||||
# Second pass: fix any structural issues
|
||||
lines = rendered.split("\n")
|
||||
fixed_lines = []
|
||||
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
# Keep empty lines
|
||||
fixed_lines.append(line)
|
||||
continue
|
||||
|
||||
# Check for problematic patterns
|
||||
if ":" in line:
|
||||
# Count mappings
|
||||
# Pattern: word: value word2: value2
|
||||
mapping_pattern = r"(\s*)(\w+):\s*([^:]+?)\s+(\w+):\s*(.*)$"
|
||||
match = re.match(mapping_pattern, line)
|
||||
|
||||
if match:
|
||||
# Multiple mappings on one line - split them
|
||||
indent = match.group(1)
|
||||
key1 = match.group(2)
|
||||
val1 = match.group(3).strip()
|
||||
key2 = match.group(4)
|
||||
val2 = match.group(5).strip()
|
||||
|
||||
fixed_lines.append(
|
||||
f"{indent}{key1}: {val1}" if val1 else f"{indent}{key1}:"
|
||||
)
|
||||
fixed_lines.append(
|
||||
f"{indent}{key2}: {val2}" if val2 else f"{indent}{key2}:"
|
||||
)
|
||||
else:
|
||||
fixed_lines.append(line)
|
||||
else:
|
||||
fixed_lines.append(line)
|
||||
|
||||
return "\n".join(fixed_lines)
|
||||
|
||||
def _split_into_sections( # pylint: disable=too-many-nested-blocks
|
||||
self, content: str
|
||||
) -> List[dict[str, Any]]:
|
||||
"""Split content into template blocks and regular sections."""
|
||||
lines = content.split("\n")
|
||||
sections = []
|
||||
current_section: List[str] = []
|
||||
in_template_block = False
|
||||
block_indent = 0
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# Check for template block start
|
||||
if "{%" in line and not in_template_block:
|
||||
if any(kw in line for kw in ["for", "if", "macro"]):
|
||||
# Save current section
|
||||
if current_section:
|
||||
sections.append(
|
||||
{
|
||||
"type": "regular",
|
||||
"content": "\n".join(current_section),
|
||||
"indent": 0,
|
||||
}
|
||||
)
|
||||
current_section = []
|
||||
|
||||
# Start template block
|
||||
in_template_block = True
|
||||
block_indent = len(line) - len(line.lstrip())
|
||||
block_lines = [line]
|
||||
|
||||
# Find block end
|
||||
i += 1
|
||||
while i < len(lines):
|
||||
block_line = lines[i]
|
||||
block_lines.append(block_line)
|
||||
|
||||
if any(
|
||||
kw in block_line for kw in ["endfor", "endif", "endmacro"]
|
||||
):
|
||||
line_indent = len(block_line) - len(block_line.lstrip())
|
||||
if line_indent <= block_indent:
|
||||
# End of block
|
||||
sections.append(
|
||||
{
|
||||
"type": "template_block",
|
||||
"content": "\n".join(block_lines),
|
||||
"indent": block_indent,
|
||||
}
|
||||
)
|
||||
in_template_block = False
|
||||
break
|
||||
i += 1
|
||||
else:
|
||||
# Inline template
|
||||
current_section.append(line)
|
||||
else:
|
||||
if not in_template_block:
|
||||
current_section.append(line)
|
||||
|
||||
i += 1
|
||||
|
||||
# Add final section
|
||||
if current_section:
|
||||
sections.append(
|
||||
{"type": "regular", "content": "\n".join(current_section), "indent": 0}
|
||||
)
|
||||
|
||||
return sections
|
||||
|
||||
def _render_block(
|
||||
self, block_content: str, _base_indent: int, context: dict[str, Any]
|
||||
) -> str:
|
||||
"""
|
||||
Render a template block ensuring proper YAML structure.
|
||||
"""
|
||||
# Create template
|
||||
template = self.env.from_string(block_content)
|
||||
|
||||
# Render
|
||||
rendered = template.render(**context)
|
||||
|
||||
# Fix structure line by line
|
||||
lines = rendered.split("\n")
|
||||
fixed_lines = []
|
||||
|
||||
for line in lines:
|
||||
if not line.strip():
|
||||
fixed_lines.append(line)
|
||||
continue
|
||||
|
||||
# Check for multiple YAML keys on same line
|
||||
if ":" in line and line.count(":") > 1:
|
||||
# Extract indent
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# Parse multiple key:value pairs
|
||||
# Look for pattern: key: value key2: value2
|
||||
matches = list(re.finditer(r"(\w+):\s*([^:]*?)(?=\s+\w+:|$)", line))
|
||||
|
||||
if len(matches) > 1:
|
||||
# Multiple key-value pairs found
|
||||
for i, match in enumerate(matches):
|
||||
key = match.group(1)
|
||||
value = match.group(2).strip()
|
||||
|
||||
if i == 0:
|
||||
# First key keeps original indent
|
||||
fixed_lines.append(
|
||||
f"{' ' * indent}{key}: {value}"
|
||||
if value
|
||||
else f"{' ' * indent}{key}:"
|
||||
)
|
||||
else:
|
||||
# Subsequent keys at same level
|
||||
fixed_lines.append(
|
||||
f"{' ' * indent}{key}: {value}"
|
||||
if value
|
||||
else f"{' ' * indent}{key}:"
|
||||
)
|
||||
else:
|
||||
fixed_lines.append(line)
|
||||
else:
|
||||
fixed_lines.append(line)
|
||||
|
||||
return "\n".join(fixed_lines)
|
||||
|
||||
def _fix_yaml_issues( # pylint: disable=too-many-nested-blocks
|
||||
self, content: str
|
||||
) -> str:
|
||||
"""Fix common YAML parsing issues."""
|
||||
lines = content.split("\n")
|
||||
fixed_lines = []
|
||||
|
||||
i = 0
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# Fix "mapping values are not allowed here" error
|
||||
if ":" in line and line.count(":") > 1:
|
||||
# Multiple mappings on same line
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# Find all key:value pairs
|
||||
matches = list(re.finditer(r"(\w+):\s*([^:]*?)(?=\s+\w+:|$)", line))
|
||||
|
||||
if len(matches) > 1:
|
||||
for match in matches:
|
||||
key = match.group(1)
|
||||
value = match.group(2).strip()
|
||||
|
||||
if value:
|
||||
fixed_lines.append(f"{' ' * indent}{key}: {value}")
|
||||
else:
|
||||
# Check if next line might be the value
|
||||
if i + 1 < len(lines):
|
||||
next_line = lines[i + 1]
|
||||
next_indent = len(next_line) - len(next_line.lstrip())
|
||||
if next_indent > indent and ":" not in next_line:
|
||||
fixed_lines.append(f"{' ' * indent}{key}:")
|
||||
else:
|
||||
fixed_lines.append(f"{' ' * indent}{key}:")
|
||||
else:
|
||||
fixed_lines.append(f"{' ' * indent}{key}:")
|
||||
else:
|
||||
fixed_lines.append(line)
|
||||
else:
|
||||
fixed_lines.append(line)
|
||||
|
||||
i += 1
|
||||
|
||||
return "\n".join(fixed_lines)
|
||||
|
||||
def _prepare_context(self, context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare rendering context with utilities."""
|
||||
full_context = {
|
||||
# Built-ins
|
||||
"range": range,
|
||||
"len": len,
|
||||
"enumerate": enumerate,
|
||||
"zip": zip,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"sorted": sorted,
|
||||
"reversed": reversed,
|
||||
# Type constructors
|
||||
"int": int,
|
||||
"float": float,
|
||||
"str": str,
|
||||
"list": list,
|
||||
"dict": dict,
|
||||
"set": set,
|
||||
"tuple": tuple,
|
||||
# Utilities
|
||||
"join": lambda x, sep=",": sep.join(str(i) for i in x),
|
||||
"keys": lambda x: list(x.keys()) if isinstance(x, dict) else [],
|
||||
"values": lambda x: list(x.values()) if isinstance(x, dict) else [],
|
||||
"items": lambda x: list(x.items()) if isinstance(x, dict) else [],
|
||||
}
|
||||
full_context.update(context or {})
|
||||
return full_context
|
||||
|
||||
def _store_for_deferred(self, content: str) -> dict[str, Any]:
|
||||
"""Store content for deferred rendering."""
|
||||
# For deferred rendering, we store the template as is
|
||||
# This is the simplest approach that preserves everything
|
||||
return {
|
||||
"__yaml_template__": {
|
||||
"content": content,
|
||||
"type": "full",
|
||||
"has_jinja2": True,
|
||||
}
|
||||
}
|
||||
|
||||
def render_deferred(
|
||||
self, config: dict[str, Any], context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Render a deferred template configuration."""
|
||||
if "__yaml_template__" in config:
|
||||
template_info = config["__yaml_template__"]
|
||||
content = template_info["content"]
|
||||
return self._render_and_parse(content, context)
|
||||
|
||||
# Not a deferred template
|
||||
return config
|
||||
@@ -0,0 +1,303 @@
|
||||
"""
|
||||
Preprocessor that enables inline Jinja2 in YAML files.
|
||||
|
||||
This preprocessor automatically converts inline Jinja2 templates into
|
||||
a format that can be safely parsed by YAML, then restores them for rendering.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class JinjaYAMLPreprocessor:
|
||||
"""
|
||||
Preprocesses YAML files to handle inline Jinja2 templates.
|
||||
|
||||
The approach:
|
||||
1. Detect sections with Jinja2 syntax
|
||||
2. Automatically wrap them in YAML-safe format
|
||||
3. Parse the YAML
|
||||
4. Mark wrapped sections for later processing
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.env = Environment(
|
||||
block_start_string="{%",
|
||||
block_end_string="%}",
|
||||
variable_start_string="{{",
|
||||
variable_end_string="}}",
|
||||
comment_start_string="{#",
|
||||
comment_end_string="#}",
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
def load_file(
|
||||
self, file_path: Path, context: Optional[dict[str, Any]] = None
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Load a YAML file with inline Jinja2 templates.
|
||||
|
||||
Returns:
|
||||
Parsed configuration, or None for empty/comment-only content
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return self.load_string(content, context)
|
||||
|
||||
def load_string(
|
||||
self, yaml_content: str, context: Optional[dict[str, Any]] = None
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Load YAML content with inline Jinja2 templates.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML content with Jinja2
|
||||
context: Optional context for rendering
|
||||
|
||||
Returns:
|
||||
Parsed configuration, or None for empty/comment-only content
|
||||
"""
|
||||
# If no templates, just parse
|
||||
if "{%" not in yaml_content and "{{" not in yaml_content:
|
||||
result = yaml.safe_load(yaml_content)
|
||||
# Allow None for empty/comment-only content
|
||||
if result is None:
|
||||
return None
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected YAML to parse as dict, got {type(result).__name__}"
|
||||
)
|
||||
return result
|
||||
|
||||
if context:
|
||||
# Render immediately
|
||||
return self._render_and_parse(yaml_content, context)
|
||||
# Preprocess for deferred rendering
|
||||
return self._preprocess_for_storage(yaml_content)
|
||||
|
||||
def _render_and_parse(
|
||||
self, yaml_content: str, context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Render templates and parse YAML."""
|
||||
# Add utilities to context
|
||||
full_context = {
|
||||
"range": range,
|
||||
"len": len,
|
||||
"enumerate": enumerate,
|
||||
"zip": zip,
|
||||
"min": min,
|
||||
"max": max,
|
||||
}
|
||||
full_context.update(context)
|
||||
|
||||
# Render as Jinja2 template
|
||||
template = self.env.from_string(yaml_content)
|
||||
rendered = template.render(**full_context)
|
||||
|
||||
# Parse result
|
||||
result = yaml.safe_load(rendered)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected rendered YAML to parse as dict, got {type(result).__name__}"
|
||||
)
|
||||
return result
|
||||
|
||||
def _preprocess_for_storage( # pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-nested-blocks
|
||||
self, yaml_content: str
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Preprocess YAML for storage with templates preserved.
|
||||
|
||||
The trick: We identify template sections and convert them to
|
||||
multiline strings automatically, so the user doesn't have to.
|
||||
"""
|
||||
lines = yaml_content.split("\n")
|
||||
processed_lines: List[str] = []
|
||||
i = 0
|
||||
|
||||
while i < len(lines):
|
||||
line = lines[i]
|
||||
|
||||
# Check if this line starts a template block
|
||||
if "{%" in line and any(
|
||||
kw in line for kw in ["for", "if", "macro", "block"]
|
||||
):
|
||||
# This is a template block
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# Find the parent key
|
||||
parent_key = None
|
||||
parent_indent = -1
|
||||
for j in range(len(processed_lines) - 1, -1, -1):
|
||||
prev = processed_lines[j]
|
||||
prev_indent = len(prev) - len(prev.lstrip())
|
||||
if prev_indent < indent and prev.strip().endswith(":"):
|
||||
parent_key = prev.strip()[:-1].strip()
|
||||
parent_indent = prev_indent
|
||||
break
|
||||
|
||||
# Collect the template block
|
||||
block_lines = [line]
|
||||
i += 1
|
||||
|
||||
# Find the end of the block
|
||||
while i < len(lines):
|
||||
block_line = lines[i]
|
||||
block_lines.append(block_line)
|
||||
|
||||
if any(
|
||||
kw in block_line
|
||||
for kw in ["endfor", "endif", "endmacro", "endblock"]
|
||||
):
|
||||
line_indent = len(block_line) - len(block_line.lstrip())
|
||||
if line_indent <= indent:
|
||||
# End of block
|
||||
break
|
||||
i += 1
|
||||
|
||||
# Convert to multiline string format
|
||||
if parent_key and parent_indent >= 0:
|
||||
# Replace the parent key line
|
||||
for j in range(len(processed_lines) - 1, -1, -1):
|
||||
if processed_lines[j].strip() == f"{parent_key}:":
|
||||
# Convert to multiline string
|
||||
processed_lines[j] = f"{' ' * parent_indent}{parent_key}: |"
|
||||
# Add marker
|
||||
processed_lines.append(
|
||||
f"{' ' * (parent_indent + 2)}__jinja_template__: true"
|
||||
)
|
||||
# Add the template content
|
||||
for block_line in block_lines:
|
||||
processed_lines.append(
|
||||
" " * (parent_indent + 2) + block_line.strip()
|
||||
)
|
||||
break
|
||||
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Check for inline templates
|
||||
if ":" in line and ("{{" in line or "{%" in line):
|
||||
# Inline template in value
|
||||
match = re.match(r"^(\s*)(\S+):\s*(.+)$", line)
|
||||
if match:
|
||||
indent_str = match.group(1)
|
||||
key = match.group(2)
|
||||
value = match.group(3).strip()
|
||||
|
||||
if "{{" in value or "{%" in value:
|
||||
# Wrap in quotes to make it YAML-safe
|
||||
# Mark it as a template
|
||||
processed_lines.append(f"{indent_str}{key}:")
|
||||
processed_lines.append(f'{indent_str} __value__: "{value}"')
|
||||
processed_lines.append(f"{indent_str} __is_template__: true")
|
||||
i += 1
|
||||
continue
|
||||
|
||||
# Regular line
|
||||
processed_lines.append(line)
|
||||
i += 1
|
||||
|
||||
# Parse the preprocessed YAML
|
||||
preprocessed = "\n".join(processed_lines)
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(preprocessed)
|
||||
except yaml.YAMLError as e:
|
||||
logger.error("Failed to parse preprocessed YAML: %s", e)
|
||||
logger.debug("Preprocessed:\n%s", preprocessed)
|
||||
raise
|
||||
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(
|
||||
f"Expected preprocessed YAML to parse as dict, got {type(parsed).__name__}"
|
||||
)
|
||||
return parsed
|
||||
|
||||
def render_deferred(
|
||||
self, config: dict[str, Any], context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Render a configuration that contains deferred templates.
|
||||
|
||||
Args:
|
||||
config: Configuration with template markers
|
||||
context: Rendering context
|
||||
|
||||
Returns:
|
||||
Rendered configuration
|
||||
"""
|
||||
# Convert back to YAML with templates
|
||||
yaml_with_templates = self._restore_templates(config)
|
||||
|
||||
# Render and parse
|
||||
return self._render_and_parse(yaml_with_templates, context)
|
||||
|
||||
def _restore_templates( # pylint: disable=too-many-branches
|
||||
self, data: Any, indent: int = 0
|
||||
) -> str:
|
||||
"""
|
||||
Restore template syntax from marked configuration.
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
# Check if this is a template value
|
||||
if "__is_template__" in data and "__value__" in data:
|
||||
# Inline template
|
||||
value = data["__value__"]
|
||||
if not isinstance(value, str):
|
||||
raise ValueError(
|
||||
f"Expected template value to be str, got {type(value).__name__}"
|
||||
)
|
||||
return value
|
||||
|
||||
# Check if this is a template block
|
||||
if "__jinja_template__" in data:
|
||||
# Extract the template content
|
||||
lines = []
|
||||
for key, value in data.items():
|
||||
if key not in ["__jinja_template__"]:
|
||||
if isinstance(value, str):
|
||||
lines.append(value)
|
||||
return "\n".join(lines)
|
||||
|
||||
# Regular dict
|
||||
lines = []
|
||||
for key, value in data.items():
|
||||
if isinstance(value, dict) and "__is_template__" in value:
|
||||
# Inline template
|
||||
template_value = value.get("__value__", "")
|
||||
lines.append(f"{' ' * indent}{key}: {template_value}")
|
||||
elif isinstance(value, str) and value.startswith("__jinja_template__:"):
|
||||
# Skip marker
|
||||
continue
|
||||
elif value is None:
|
||||
lines.append(f"{' ' * indent}{key}:")
|
||||
elif isinstance(value, (dict, list)):
|
||||
lines.append(f"{' ' * indent}{key}:")
|
||||
lines.append(self._restore_templates(value, indent + 2))
|
||||
else:
|
||||
lines.append(f"{' ' * indent}{key}: {value}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
if isinstance(data, list):
|
||||
lines = []
|
||||
for item in data:
|
||||
if isinstance(item, (dict, list)):
|
||||
lines.append(f"{' ' * indent}-")
|
||||
lines.append(self._restore_templates(item, indent + 2))
|
||||
else:
|
||||
lines.append(f"{' ' * indent}- {item}")
|
||||
return "\n".join(lines)
|
||||
|
||||
return str(data)
|
||||
@@ -0,0 +1,276 @@
|
||||
"""
|
||||
Template loaders module for CleverAgents.
|
||||
|
||||
This module provides functionality to load templates from different sources, such as files,
|
||||
directories, or direct strings.
|
||||
"""
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from cleveragents.core.exceptions import TemplateError
|
||||
from cleveragents.templates.renderer import TemplateRenderer
|
||||
|
||||
|
||||
class TemplateLoader: # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
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): # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
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", encoding="utf-8") 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)}"
|
||||
) from e
|
||||
|
||||
|
||||
class DirectoryTemplateLoader(TemplateLoader): # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
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", encoding="utf-8") 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)}"
|
||||
) from e
|
||||
except Exception as e:
|
||||
raise TemplateError(
|
||||
f"Failed to load templates from directory {self.directory_path}: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
class ConfigTemplateLoader(TemplateLoader): # pylint: disable=too-few-public-methods
|
||||
"""
|
||||
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)}"
|
||||
) from 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", encoding="utf-8") as file:
|
||||
return file.read()
|
||||
except Exception as e:
|
||||
raise TemplateError(
|
||||
f"Failed to load template from file {file_path}: {str(e)}"
|
||||
) from e
|
||||
|
||||
|
||||
def load_from_string(template_str: str) -> str:
|
||||
"""
|
||||
Load a template from a provided string.
|
||||
|
||||
Args:
|
||||
template_str: The template string.
|
||||
|
||||
Returns:
|
||||
The template string.
|
||||
"""
|
||||
return template_str
|
||||
@@ -0,0 +1,163 @@
|
||||
"""
|
||||
Template registry for managing all template types.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
|
||||
from cleveragents.templates.base import BaseTemplate
|
||||
from cleveragents.templates.base import InstantiationContext
|
||||
from cleveragents.templates.base import TemplateType
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from cleveragents.templates.base import TemplateRegistryProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateRegistry:
|
||||
"""Registry for all template types."""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.templates: dict[TemplateType, dict[str, BaseTemplate]] = {
|
||||
TemplateType.AGENT: {},
|
||||
TemplateType.GRAPH: {},
|
||||
TemplateType.STREAM: {},
|
||||
}
|
||||
logger.debug("Initialized template registry")
|
||||
|
||||
def register_template(
|
||||
self, template_type: TemplateType, name: str, definition: dict[str, Any]
|
||||
) -> None:
|
||||
"""Register a new template."""
|
||||
# Import template classes here to avoid circular imports
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from cleveragents.templates.agent_templates import AgentTemplate
|
||||
from cleveragents.templates.agent_templates import CompositeAgentTemplate
|
||||
from cleveragents.templates.graph_templates import GraphTemplate
|
||||
from cleveragents.templates.stream_templates import StreamTemplate
|
||||
|
||||
# Create appropriate template instance
|
||||
template: BaseTemplate
|
||||
if template_type == TemplateType.AGENT:
|
||||
if definition.get("type") == "composite":
|
||||
template = CompositeAgentTemplate(name, template_type, definition)
|
||||
else:
|
||||
template = AgentTemplate(name, template_type, definition)
|
||||
elif template_type == TemplateType.GRAPH:
|
||||
template = GraphTemplate(name, template_type, definition)
|
||||
elif template_type == TemplateType.STREAM:
|
||||
template = StreamTemplate(name, template_type, definition)
|
||||
else:
|
||||
raise ValueError(f"Unknown template type: {template_type}")
|
||||
|
||||
self.templates[template_type][name] = template
|
||||
logger.info("Registered %s template '%s'", template_type.value, name)
|
||||
|
||||
def get_template(self, template_type: TemplateType, name: str) -> BaseTemplate:
|
||||
"""Get a template by type and name."""
|
||||
if name not in self.templates[template_type]:
|
||||
raise ValueError(
|
||||
f"{template_type.value.capitalize()} template '{name}' not found"
|
||||
)
|
||||
return self.templates[template_type][name]
|
||||
|
||||
def has_template(self, template_type: TemplateType, name: str) -> bool:
|
||||
"""Check if a template exists."""
|
||||
return name in self.templates[template_type]
|
||||
|
||||
def instantiate(
|
||||
self,
|
||||
template_type: TemplateType,
|
||||
name: str,
|
||||
params: dict[str, Any],
|
||||
context: Optional[InstantiationContext] = None,
|
||||
) -> Any:
|
||||
"""Instantiate a template by type and name (protocol compliance)."""
|
||||
if context is None:
|
||||
context = InstantiationContext()
|
||||
|
||||
template = self.get_template(template_type, name)
|
||||
return template.instantiate(params, self, context)
|
||||
|
||||
def instantiate_from_config( # pylint: disable=too-many-return-statements
|
||||
self, config: dict[str, Any], context: Optional[InstantiationContext] = None
|
||||
) -> Any:
|
||||
"""Create an instance from configuration."""
|
||||
# Handle None config
|
||||
if config is None:
|
||||
raise ValueError("Cannot instantiate from None configuration")
|
||||
|
||||
# Create context if not provided
|
||||
if context is None:
|
||||
context = InstantiationContext()
|
||||
|
||||
# Determine what we're instantiating
|
||||
if "template" in config:
|
||||
# Agent with template
|
||||
template_name = config["template"]
|
||||
params = config.get("params", {})
|
||||
|
||||
# Try to find template in order: agent, graph, stream
|
||||
for template_type in TemplateType:
|
||||
if self.has_template(template_type, template_name):
|
||||
template = self.get_template(template_type, template_name)
|
||||
return template.instantiate(params, self, context)
|
||||
|
||||
raise ValueError(f"Template '{template_name}' not found in any category")
|
||||
|
||||
if "agent_template" in config:
|
||||
template = self.get_template(TemplateType.AGENT, config["agent_template"])
|
||||
return template.instantiate(config.get("params", {}), self, context)
|
||||
|
||||
if "graph_template" in config:
|
||||
template = self.get_template(TemplateType.GRAPH, config["graph_template"])
|
||||
return template.instantiate(config.get("params", {}), self, context)
|
||||
|
||||
if "stream_template" in config:
|
||||
template = self.get_template(TemplateType.STREAM, config["stream_template"])
|
||||
return template.instantiate(config.get("params", {}), self, context)
|
||||
|
||||
# Direct definition (backward compatibility)
|
||||
# pylint: disable=import-outside-toplevel
|
||||
if "type" in config:
|
||||
# Direct agent definition - will be handled by agent factory
|
||||
return None
|
||||
if "nodes" in config and "edges" in config:
|
||||
# Direct graph definition
|
||||
from cleveragents.langgraph.graph import GraphConfig
|
||||
|
||||
return GraphConfig(**config)
|
||||
if "operators" in config:
|
||||
# Direct stream definition
|
||||
from cleveragents.reactive.stream_router import StreamConfig
|
||||
|
||||
return StreamConfig(**config)
|
||||
raise ValueError("Cannot determine instance type from configuration")
|
||||
|
||||
def register_all_templates(
|
||||
self, templates_config: dict[str, dict[str, dict[str, Any]]]
|
||||
) -> None:
|
||||
"""Register all templates from configuration."""
|
||||
# Register agents
|
||||
for name, definition in templates_config.get("agents", {}).items():
|
||||
self.register_template(TemplateType.AGENT, name, definition)
|
||||
|
||||
# Register graphs
|
||||
for name, definition in templates_config.get("graphs", {}).items():
|
||||
self.register_template(TemplateType.GRAPH, name, definition)
|
||||
|
||||
# Register streams
|
||||
for name, definition in templates_config.get("streams", {}).items():
|
||||
self.register_template(TemplateType.STREAM, name, definition)
|
||||
|
||||
def list_templates(
|
||||
self, template_type: Optional[TemplateType] = None
|
||||
) -> dict[str, List[str]]:
|
||||
"""List all registered templates."""
|
||||
if template_type:
|
||||
return {template_type.value: list(self.templates[template_type].keys())}
|
||||
return {t.value: list(self.templates[t].keys()) for t in TemplateType}
|
||||
@@ -0,0 +1,288 @@
|
||||
"""
|
||||
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 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): # pylint: disable=too-few-public-methods
|
||||
"""Protocol for Jinja2 template objects."""
|
||||
|
||||
def render(self, **kwargs: Any) -> str:
|
||||
"""Render the template with the given context."""
|
||||
|
||||
|
||||
class Jinja2Environment(Protocol): # pylint: disable=too-few-public-methods
|
||||
"""Protocol for Jinja2 environment objects."""
|
||||
|
||||
def from_string(self, source: str) -> Jinja2Template:
|
||||
"""Create a template from a string."""
|
||||
|
||||
|
||||
class PystacheRenderer(Protocol): # pylint: disable=too-few-public-methods
|
||||
"""Protocol for Pystache renderer objects."""
|
||||
|
||||
def render(self, template: str, context: dict[str, Any]) -> str:
|
||||
"""Render the template with the given context."""
|
||||
|
||||
|
||||
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[..., Any], 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 # pylint: disable=import-outside-toplevel
|
||||
|
||||
self.engine = jinja2.Environment(
|
||||
autoescape=False, trim_blocks=True, lstrip_blocks=True
|
||||
)
|
||||
except ImportError as exc:
|
||||
raise TemplateError(
|
||||
"Jinja2 is not installed. Install it with 'pip install jinja2'."
|
||||
) from exc
|
||||
elif self.engine_type == TemplateEngine.MUSTACHE:
|
||||
try:
|
||||
import pystache # type: ignore # pylint: disable=import-outside-toplevel
|
||||
|
||||
self.engine = pystache.Renderer()
|
||||
except ImportError as exc:
|
||||
raise TemplateError(
|
||||
"Pystache is not installed. Install it with 'pip install pystache'."
|
||||
) from exc
|
||||
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)`
|
||||
# pylint: disable=eval-used
|
||||
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)}"
|
||||
) from 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
|
||||
if self.engine_type == TemplateEngine.JINJA2:
|
||||
# For Jinja2, template is already a compiled template
|
||||
if hasattr(template, "render"):
|
||||
return template.render(**context) # type: ignore
|
||||
raise TemplateError("Jinja2 template has no render method")
|
||||
if self.engine_type == TemplateEngine.MUSTACHE:
|
||||
if hasattr(self.engine, "render"):
|
||||
return self.engine.render(template, context) # type: ignore
|
||||
raise TemplateError("Mustache renderer has no render method")
|
||||
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
|
||||
if self.engine_type == TemplateEngine.JINJA2:
|
||||
if hasattr(self.engine, "from_string"):
|
||||
template = self.engine.from_string(template_str) # type: ignore
|
||||
return template.render(**context)
|
||||
raise TemplateError("Jinja2 environment has no from_string method")
|
||||
if self.engine_type == TemplateEngine.MUSTACHE:
|
||||
if hasattr(self.engine, "render"):
|
||||
return self.engine.render(template_str, context) # type: ignore
|
||||
raise TemplateError("Mustache renderer has no render method")
|
||||
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) -> Any:
|
||||
"""
|
||||
Get a template from the registry.
|
||||
|
||||
Args:
|
||||
name: The name of the template to get.
|
||||
|
||||
Returns:
|
||||
The template string or object.
|
||||
|
||||
Raises:
|
||||
TemplateError: If the template does not exist.
|
||||
"""
|
||||
if name not in self.templates:
|
||||
raise TemplateError(f"Template '{name}' not found")
|
||||
return self.templates[name]
|
||||
|
||||
def list_templates(self) -> List[str]:
|
||||
"""
|
||||
List all registered templates.
|
||||
|
||||
Returns:
|
||||
A list of template names.
|
||||
"""
|
||||
return list(self.templates.keys())
|
||||
@@ -0,0 +1,341 @@
|
||||
"""
|
||||
Smart YAML loader that preserves Jinja2 templates for deferred processing.
|
||||
|
||||
This loader allows Jinja2 templates to be written inline in YAML files
|
||||
without requiring multiline strings, while still being valid YAML.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class SmartYAMLLoader:
|
||||
"""
|
||||
A YAML loader that intelligently handles inline Jinja2 templates.
|
||||
|
||||
The key insight: We parse the YAML file line by line, identifying
|
||||
template sections and storing them separately from the regular YAML.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.template_pattern = re.compile(r"{[{%].*?[%}]}")
|
||||
|
||||
def load_file(self, file_path: Path) -> Tuple[dict[str, Any], dict[str, str]]:
|
||||
"""
|
||||
Load a YAML file, separating regular content from template sections.
|
||||
|
||||
Args:
|
||||
file_path: Path to YAML file
|
||||
|
||||
Returns:
|
||||
Tuple of (parsed_yaml, template_sections)
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
return self.load_string(content)
|
||||
|
||||
def load_string( # pylint: disable=too-many-locals,too-many-branches,too-many-statements,too-many-nested-blocks
|
||||
self, yaml_content: str
|
||||
) -> Tuple[dict[str, Any], dict[str, str]]:
|
||||
"""
|
||||
Load YAML content, separating regular content from template sections.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML content with possible Jinja2 templates
|
||||
|
||||
Returns:
|
||||
Tuple of (parsed_yaml, template_sections)
|
||||
"""
|
||||
# If no templates, just parse normally
|
||||
if "{%" not in yaml_content and "{{" not in yaml_content:
|
||||
return yaml.safe_load(yaml_content), {}
|
||||
|
||||
# Process the content to extract template sections
|
||||
lines = yaml_content.split("\n")
|
||||
template_sections: dict[str, Any] = {}
|
||||
processed_lines: List[str] = []
|
||||
|
||||
current_template_block = []
|
||||
in_template_block = False
|
||||
template_block_indent = 0
|
||||
template_block_key = None
|
||||
|
||||
for line in lines:
|
||||
# Check if this line starts a template block
|
||||
if "{%" in line and not in_template_block:
|
||||
# This might be the start of a template block
|
||||
if "for" in line or "if" in line or "macro" in line:
|
||||
in_template_block = True
|
||||
template_block_indent = len(line) - len(line.lstrip())
|
||||
|
||||
# Find the key this block belongs to
|
||||
# Look backwards for the nearest key with same or less indentation
|
||||
for i in range(len(processed_lines) - 1, -1, -1):
|
||||
prev_line = processed_lines[i]
|
||||
prev_indent = len(prev_line) - len(prev_line.lstrip())
|
||||
|
||||
# Look for a key at the right indentation level
|
||||
if prev_indent < template_block_indent and ":" in prev_line:
|
||||
key_match = re.match(r"^(\s*)(\S+):\s*$", prev_line)
|
||||
if key_match:
|
||||
template_block_key = key_match.group(2)
|
||||
# Store the template ID
|
||||
template_id = f"_template_{len(template_sections)}"
|
||||
break
|
||||
|
||||
current_template_block = [line]
|
||||
continue
|
||||
|
||||
# Check if we're in a template block
|
||||
if in_template_block:
|
||||
current_template_block.append(line)
|
||||
|
||||
# Check if this ends the template block
|
||||
if "endfor" in line or "endif" in line or "endmacro" in line:
|
||||
# Check if this is at the same or lower indent level
|
||||
line_indent = len(line) - len(line.lstrip())
|
||||
if line_indent <= template_block_indent:
|
||||
# End of template block
|
||||
in_template_block = False
|
||||
|
||||
# Store the template block with proper structure
|
||||
if template_id and template_block_key:
|
||||
# Store the complete block
|
||||
template_sections[template_id] = {
|
||||
"key": template_block_key,
|
||||
"indent": template_block_indent,
|
||||
"content": "\n".join(current_template_block),
|
||||
}
|
||||
|
||||
# Add placeholder in processed lines
|
||||
# Find where to insert it
|
||||
for i in range(len(processed_lines) - 1, -1, -1):
|
||||
if template_block_key in processed_lines[i]:
|
||||
key_indent = len(processed_lines[i]) - len(
|
||||
processed_lines[i].lstrip()
|
||||
)
|
||||
template_marker = f"__TEMPLATE:{template_id}__"
|
||||
processed_lines[i] = (
|
||||
f"{' ' * key_indent}{template_block_key}: {template_marker}"
|
||||
)
|
||||
break
|
||||
|
||||
current_template_block = []
|
||||
template_block_key = None
|
||||
template_id = ""
|
||||
continue
|
||||
|
||||
# Check for inline templates in values
|
||||
elif ":" in line and ("{{" in line or "{%" in line):
|
||||
# This is a key-value pair with a template
|
||||
key_value_match = re.match(r"^(\s*)(\S+):\s*(.+)$", line)
|
||||
if key_value_match:
|
||||
indent = key_value_match.group(1)
|
||||
key = key_value_match.group(2)
|
||||
value = key_value_match.group(3)
|
||||
|
||||
# Store the template value
|
||||
template_id = f"_template_{len(template_sections)}"
|
||||
template_sections[template_id] = value.strip()
|
||||
|
||||
# Replace with marker
|
||||
processed_lines.append(f"{indent}{key}: __TEMPLATE:{template_id}__")
|
||||
continue
|
||||
|
||||
# Regular line
|
||||
if not in_template_block:
|
||||
processed_lines.append(line)
|
||||
|
||||
# Parse the processed YAML
|
||||
processed_yaml = "\n".join(processed_lines)
|
||||
|
||||
try:
|
||||
parsed = yaml.safe_load(processed_yaml)
|
||||
except yaml.YAMLError as e:
|
||||
logger.error("Failed to parse processed YAML: %s", e)
|
||||
logger.debug("Processed YAML:\n%s", processed_yaml)
|
||||
raise
|
||||
|
||||
return parsed, template_sections
|
||||
|
||||
|
||||
class TemplateDefinitionStore:
|
||||
"""
|
||||
Stores template definitions with their original Jinja2 syntax preserved.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.loader = SmartYAMLLoader()
|
||||
self.definitions: dict[str, Any] = {}
|
||||
self.template_sections: dict[str, str] = {}
|
||||
|
||||
def load_config(self, config: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Process a configuration to extract and store template definitions.
|
||||
|
||||
Args:
|
||||
config: Configuration dictionary
|
||||
|
||||
Returns:
|
||||
Configuration with template definitions marked for deferred processing
|
||||
"""
|
||||
result = config.copy()
|
||||
|
||||
# Check if we have templates section
|
||||
if "templates" in config:
|
||||
result["templates"] = self._process_templates_section(config["templates"])
|
||||
|
||||
return result
|
||||
|
||||
def _process_templates_section(self, templates: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Process the templates section to identify definitions with Jinja2.
|
||||
|
||||
Args:
|
||||
templates: Templates section of config
|
||||
|
||||
Returns:
|
||||
Processed templates section
|
||||
"""
|
||||
result: dict[str, dict[str, Any]] = {}
|
||||
|
||||
for template_type, type_templates in templates.items():
|
||||
result[template_type] = {}
|
||||
|
||||
for name, definition in type_templates.items():
|
||||
# Check if definition contains template markers
|
||||
if self._contains_template_markers(definition):
|
||||
# Store the raw definition
|
||||
def_id = f"{template_type}:{name}"
|
||||
self.definitions[def_id] = definition
|
||||
|
||||
# Mark for deferred processing
|
||||
result[template_type][name] = {
|
||||
"_template_definition_id": def_id,
|
||||
"_requires_processing": True,
|
||||
"type": definition.get("type", "unknown"),
|
||||
"parameters": definition.get("parameters", {}),
|
||||
}
|
||||
else:
|
||||
# Regular definition
|
||||
result[template_type][name] = definition
|
||||
|
||||
return result
|
||||
|
||||
def _contains_template_markers(self, data: Any) -> bool:
|
||||
"""Check if data contains template markers."""
|
||||
if isinstance(data, str):
|
||||
return data.startswith("__TEMPLATE:") and data.endswith("__")
|
||||
if isinstance(data, dict):
|
||||
return any(self._contains_template_markers(v) for v in data.values())
|
||||
if isinstance(data, list):
|
||||
return any(self._contains_template_markers(item) for item in data)
|
||||
return False
|
||||
|
||||
def get_definition(self, def_id: str) -> Optional[dict[str, Any]]:
|
||||
"""Get a stored template definition."""
|
||||
return self.definitions.get(def_id)
|
||||
|
||||
def render_definition(
|
||||
self, def_id: str, template_sections: dict[str, str], context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Render a template definition with context.
|
||||
|
||||
Args:
|
||||
def_id: Definition ID
|
||||
template_sections: Template sections from parsing
|
||||
context: Rendering context
|
||||
|
||||
Returns:
|
||||
Rendered definition
|
||||
"""
|
||||
# pylint: disable=import-outside-toplevel
|
||||
from cleveragents.templates.yaml_preprocessor import YAMLTemplateProcessor
|
||||
|
||||
definition = self.get_definition(def_id)
|
||||
if not definition:
|
||||
raise ValueError(f"Definition {def_id} not found")
|
||||
|
||||
# Reconstruct the full YAML with templates
|
||||
yaml_str = self._reconstruct_yaml(definition, template_sections)
|
||||
|
||||
# Process with context
|
||||
processor = YAMLTemplateProcessor()
|
||||
return processor.process_string(yaml_str, context)
|
||||
|
||||
def _reconstruct_yaml( # pylint: disable=too-many-branches,too-many-nested-blocks
|
||||
self, data: Any, template_sections: dict[str, str], indent: int = 0
|
||||
) -> str:
|
||||
"""
|
||||
Reconstruct YAML with original template syntax.
|
||||
|
||||
Args:
|
||||
data: Data structure with template markers
|
||||
template_sections: Original template sections
|
||||
indent: Current indentation level
|
||||
|
||||
Returns:
|
||||
Reconstructed YAML string
|
||||
"""
|
||||
indent_str = " " * indent
|
||||
|
||||
if isinstance(data, dict):
|
||||
lines = []
|
||||
for key, value in data.items():
|
||||
if (
|
||||
isinstance(value, str)
|
||||
and value.startswith("__TEMPLATE:")
|
||||
and value.endswith("__")
|
||||
):
|
||||
# Extract template ID
|
||||
template_id = value[11:-2] # Remove __TEMPLATE: and __
|
||||
if template_id in template_sections:
|
||||
# Restore original template
|
||||
template_content = template_sections[template_id]
|
||||
|
||||
# Check if it's a block template
|
||||
if "\n" in template_content:
|
||||
lines.append(f"{indent_str}{key}:")
|
||||
# Add the template block with proper indentation
|
||||
for template_line in template_content.split("\n"):
|
||||
lines.append(f"{indent_str}{template_line}")
|
||||
else:
|
||||
# Inline template
|
||||
lines.append(f"{indent_str}{key}: {template_content}")
|
||||
else:
|
||||
lines.append(f"{indent_str}{key}: {value}")
|
||||
elif isinstance(value, (dict, list)) and value:
|
||||
lines.append(f"{indent_str}{key}:")
|
||||
lines.append(
|
||||
self._reconstruct_yaml(value, template_sections, indent + 2)
|
||||
)
|
||||
elif value is None:
|
||||
lines.append(f"{indent_str}{key}:")
|
||||
else:
|
||||
lines.append(f"{indent_str}{key}: {value}")
|
||||
return "\n".join(lines)
|
||||
|
||||
if isinstance(data, list):
|
||||
lines = []
|
||||
for item in data:
|
||||
if isinstance(item, (dict, list)):
|
||||
lines.append(f"{indent_str}-")
|
||||
lines.append(
|
||||
self._reconstruct_yaml(item, template_sections, indent + 2)
|
||||
)
|
||||
else:
|
||||
lines.append(f"{indent_str}- {item}")
|
||||
return "\n".join(lines)
|
||||
|
||||
return f"{indent_str}{data}"
|
||||
@@ -0,0 +1,136 @@
|
||||
"""
|
||||
Stream template implementations for RxPy streams.
|
||||
"""
|
||||
|
||||
import copy
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import Any
|
||||
from typing import List
|
||||
|
||||
from cleveragents.templates.base import BaseTemplate
|
||||
from cleveragents.templates.base import ComponentReference
|
||||
from cleveragents.templates.base import InstantiationContext
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .base import TemplateRegistryProtocol
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StreamTemplate(BaseTemplate):
|
||||
"""Template for RxPy streams."""
|
||||
|
||||
def instantiate(
|
||||
self,
|
||||
params: dict[str, Any],
|
||||
registry: "TemplateRegistryProtocol",
|
||||
context: InstantiationContext,
|
||||
) -> dict[str, Any]:
|
||||
"""Create stream configuration from template."""
|
||||
filled_params = self.validate_params(params)
|
||||
|
||||
# Deep copy the definition
|
||||
stream_def: dict[str, Any] = copy.deepcopy(self.definition)
|
||||
|
||||
# Remove parameters section
|
||||
if "parameters" in stream_def:
|
||||
del stream_def["parameters"]
|
||||
|
||||
# Apply template variables
|
||||
stream_def = self._apply_template_vars(stream_def, filled_params)
|
||||
|
||||
# Process operators to resolve component references
|
||||
if "operators" in stream_def:
|
||||
stream_def["operators"] = self._process_operators(
|
||||
stream_def["operators"], filled_params, context
|
||||
)
|
||||
|
||||
# Add name if not present
|
||||
if "name" not in stream_def:
|
||||
stream_def["name"] = self.name
|
||||
|
||||
return stream_def
|
||||
|
||||
def _process_operators( # pylint: disable=too-many-branches
|
||||
self, operators: List[Any], params: dict[str, Any], context: InstantiationContext
|
||||
) -> List[Any]:
|
||||
"""Process stream operators, resolving component references."""
|
||||
processed_operators = []
|
||||
|
||||
for operator in operators:
|
||||
if operator is None: # Conditionally excluded
|
||||
continue
|
||||
|
||||
# Check if this operator references components
|
||||
op_type = operator.get("type")
|
||||
op_params = operator.get("params", {})
|
||||
|
||||
# Handle dynamic operator types
|
||||
if op_type in params:
|
||||
# Operator type is parameterized
|
||||
operator["type"] = params[op_type]
|
||||
|
||||
# Handle component references in parameters
|
||||
if "processor" in op_params:
|
||||
processor = op_params["processor"]
|
||||
if isinstance(processor, dict) and "type" in processor:
|
||||
# This is a component reference specification
|
||||
ref_type = processor.get("type")
|
||||
ref_params = processor.get("params", {})
|
||||
|
||||
# Update operator based on reference type
|
||||
if ref_type == "graph_execute":
|
||||
operator["type"] = "graph_execute"
|
||||
operator["params"] = ref_params
|
||||
elif ref_type == "agent":
|
||||
operator["type"] = "map"
|
||||
operator["params"] = {"agent": ref_params.get("name")}
|
||||
|
||||
# Handle agent references in map operators
|
||||
if op_type == "map" and "agent" in op_params:
|
||||
agent_ref = op_params["agent"]
|
||||
if isinstance(agent_ref, str) and agent_ref in params:
|
||||
# Replace with parameter value
|
||||
op_params["agent"] = params[agent_ref]
|
||||
elif isinstance(agent_ref, str):
|
||||
# Try to resolve as a component reference
|
||||
try:
|
||||
resolved = context.resolve_reference(
|
||||
ComponentReference("agent", agent_ref)
|
||||
)
|
||||
if resolved:
|
||||
# Store reference for later resolution
|
||||
op_params["agent_config"] = resolved
|
||||
logger.debug(
|
||||
"Resolved agent reference '%s' in stream operator", agent_ref
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
# Not a local reference, probably a global agent name
|
||||
pass
|
||||
|
||||
# Handle graph references in graph_execute operators
|
||||
if op_type == "graph_execute" and "graph" in op_params:
|
||||
graph_ref = op_params["graph"]
|
||||
if isinstance(graph_ref, str) and graph_ref in params:
|
||||
# Replace with parameter value
|
||||
op_params["graph"] = params[graph_ref]
|
||||
elif isinstance(graph_ref, str):
|
||||
# Try to resolve as a component reference
|
||||
try:
|
||||
resolved = context.resolve_reference(
|
||||
ComponentReference("graph", graph_ref)
|
||||
)
|
||||
if resolved:
|
||||
# Store reference for later resolution
|
||||
op_params["graph_config"] = resolved
|
||||
logger.debug(
|
||||
"Resolved graph reference '%s' in stream operator", graph_ref
|
||||
)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
# Not a local reference, probably a global graph name
|
||||
pass
|
||||
|
||||
processed_operators.append(operator)
|
||||
|
||||
return processed_operators
|
||||
@@ -0,0 +1,217 @@
|
||||
"""
|
||||
Template storage system that preserves Jinja2 syntax.
|
||||
|
||||
This module provides a way to store template definitions with their original
|
||||
Jinja2 syntax intact, processing them only during instantiation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
from typing import Union
|
||||
|
||||
import yaml
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class TemplateStore:
|
||||
"""
|
||||
Stores template definitions preserving Jinja2 syntax.
|
||||
|
||||
This allows templates to be stored in their original form with full
|
||||
Jinja2 syntax and processed only when instantiated.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Store raw template strings by type and name
|
||||
self.raw_templates: dict[str, dict[str, str]] = {
|
||||
"agents": {},
|
||||
"graphs": {},
|
||||
"streams": {},
|
||||
}
|
||||
|
||||
# Store parsed metadata (parameters, type, etc.)
|
||||
self.metadata: dict[str, dict[str, dict[str, Any]]] = {
|
||||
"agents": {},
|
||||
"graphs": {},
|
||||
"streams": {},
|
||||
}
|
||||
|
||||
def add_template(
|
||||
self, template_type: str, name: str, definition: Union[str, dict[str, Any]]
|
||||
) -> None:
|
||||
"""
|
||||
Add a template definition.
|
||||
|
||||
Args:
|
||||
template_type: Type of template ('agents', 'graphs', 'streams')
|
||||
name: Template name
|
||||
definition: Template definition as string or dict
|
||||
"""
|
||||
if isinstance(definition, str):
|
||||
# Already a string, store as-is
|
||||
self.raw_templates[template_type][name] = definition
|
||||
# Try to extract metadata
|
||||
try:
|
||||
parsed = yaml.safe_load(definition)
|
||||
self._extract_metadata(template_type, name, parsed)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
logger.warning("Could not parse metadata for %s/%s", template_type, name)
|
||||
else:
|
||||
# Convert to YAML string for storage
|
||||
yaml_str = yaml.dump(definition, default_flow_style=False)
|
||||
self.raw_templates[template_type][name] = yaml_str
|
||||
self._extract_metadata(template_type, name, definition)
|
||||
|
||||
def _extract_metadata(
|
||||
self, template_type: str, name: str, definition: dict[str, Any]
|
||||
) -> None:
|
||||
"""Extract metadata from template definition."""
|
||||
metadata = {
|
||||
"type": definition.get("type", "unknown"),
|
||||
"parameters": definition.get("parameters", {}),
|
||||
}
|
||||
self.metadata[template_type][name] = metadata
|
||||
|
||||
def get_template(self, template_type: str, name: str) -> Optional[str]:
|
||||
"""Get raw template string."""
|
||||
return self.raw_templates.get(template_type, {}).get(name)
|
||||
|
||||
def get_metadata(self, template_type: str, name: str) -> Optional[dict[str, Any]]:
|
||||
"""Get template metadata."""
|
||||
return self.metadata.get(template_type, {}).get(name)
|
||||
|
||||
def instantiate_template(
|
||||
self, template_type: str, name: str, params: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Instantiate a template with parameters.
|
||||
|
||||
Args:
|
||||
template_type: Type of template
|
||||
name: Template name
|
||||
params: Template parameters
|
||||
|
||||
Returns:
|
||||
Instantiated template as dictionary
|
||||
"""
|
||||
from cleveragents.templates.yaml_preprocessor import ( # pylint: disable=import-outside-toplevel
|
||||
YAMLTemplateProcessor,
|
||||
)
|
||||
|
||||
raw_template = self.get_template(template_type, name)
|
||||
if not raw_template:
|
||||
raise ValueError(f"Template {template_type}/{name} not found")
|
||||
|
||||
# Process the template with parameters
|
||||
processor = YAMLTemplateProcessor()
|
||||
|
||||
# Add utility functions to context
|
||||
context = params.copy()
|
||||
context.update(
|
||||
{
|
||||
"range": range,
|
||||
"len": len,
|
||||
"enumerate": enumerate,
|
||||
"zip": zip,
|
||||
"min": min,
|
||||
"max": max,
|
||||
}
|
||||
)
|
||||
|
||||
# Process the template
|
||||
result = processor.process_string(raw_template, context)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
class TemplateDefinition:
|
||||
"""
|
||||
Wrapper for template definitions that may contain Jinja2 syntax.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self, definition: Union[str, dict[str, Any]], contains_templates: bool = False
|
||||
):
|
||||
"""
|
||||
Initialize template definition.
|
||||
|
||||
Args:
|
||||
definition: Template definition (string or dict)
|
||||
contains_templates: Whether the definition contains Jinja2 templates
|
||||
"""
|
||||
self.contains_templates = contains_templates
|
||||
|
||||
if isinstance(definition, str):
|
||||
self.raw_yaml = definition
|
||||
try:
|
||||
self.parsed = yaml.safe_load(definition)
|
||||
except Exception: # pylint: disable=broad-exception-caught
|
||||
self.parsed = {}
|
||||
else:
|
||||
self.parsed = definition
|
||||
self.raw_yaml = yaml.dump(definition, default_flow_style=False)
|
||||
|
||||
# Check for template syntax if not explicitly set
|
||||
if not contains_templates:
|
||||
self.contains_templates = self._check_for_templates()
|
||||
|
||||
def _check_for_templates(self) -> bool:
|
||||
"""Check if the definition contains Jinja2 syntax."""
|
||||
return "{%" in self.raw_yaml or "{{" in self.raw_yaml
|
||||
|
||||
def get_parameters(self) -> dict[str, Any]:
|
||||
"""Get template parameters."""
|
||||
params = self.parsed.get("parameters", {})
|
||||
if not isinstance(params, dict):
|
||||
raise ValueError(
|
||||
f"Expected parameters to be dict, got {type(params).__name__}"
|
||||
)
|
||||
return params
|
||||
|
||||
def get_type(self) -> str:
|
||||
"""Get template type."""
|
||||
type_val = self.parsed.get("type", "unknown")
|
||||
if not isinstance(type_val, str):
|
||||
return "unknown"
|
||||
return type_val
|
||||
|
||||
def instantiate(self, params: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Instantiate the template with parameters.
|
||||
|
||||
Args:
|
||||
params: Template parameters
|
||||
|
||||
Returns:
|
||||
Instantiated template
|
||||
"""
|
||||
if not self.contains_templates:
|
||||
# No templates, return parsed version
|
||||
if not isinstance(self.parsed, dict):
|
||||
raise ValueError(
|
||||
f"Expected parsed to be dict, got {type(self.parsed).__name__}"
|
||||
)
|
||||
return self.parsed
|
||||
|
||||
from cleveragents.templates.yaml_preprocessor import ( # pylint: disable=import-outside-toplevel
|
||||
YAMLTemplateProcessor,
|
||||
)
|
||||
|
||||
processor = YAMLTemplateProcessor()
|
||||
|
||||
# Add utility functions
|
||||
context = params.copy()
|
||||
context.update(
|
||||
{
|
||||
"range": range,
|
||||
"len": len,
|
||||
"enumerate": enumerate,
|
||||
"zip": zip,
|
||||
"min": min,
|
||||
"max": max,
|
||||
}
|
||||
)
|
||||
|
||||
return processor.process_string(self.raw_yaml, context)
|
||||
@@ -0,0 +1,453 @@
|
||||
"""
|
||||
YAML loader that supports inline Jinja2 templates.
|
||||
|
||||
This module provides a custom YAML loader that preprocesses Jinja2 templates
|
||||
before parsing, allowing users to write Jinja2 directly in their YAML files.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YAMLJinjaLoader:
|
||||
"""
|
||||
Custom YAML loader that preprocesses Jinja2 templates.
|
||||
|
||||
This loader allows users to write Jinja2 templates directly in YAML
|
||||
without wrapping them in multiline strings.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.env = Environment(
|
||||
block_start_string="{%",
|
||||
block_end_string="%}",
|
||||
variable_start_string="{{",
|
||||
variable_end_string="}}",
|
||||
comment_start_string="{#",
|
||||
comment_end_string="#}",
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
def load_file(
|
||||
self, file_path: Path, context: Optional[dict[str, Any]] = None
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Load a YAML file that may contain Jinja2 templates.
|
||||
|
||||
Args:
|
||||
file_path: Path to YAML file
|
||||
context: Optional context for template rendering
|
||||
|
||||
Returns:
|
||||
Parsed YAML as dictionary, or None for empty/comment-only content
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
return self.load_string(content, context)
|
||||
|
||||
def load_string(
|
||||
self, yaml_content: str, context: Optional[dict[str, Any]] = None
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Load YAML content that may contain Jinja2 templates.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML content as string
|
||||
context: Optional context for template rendering
|
||||
|
||||
Returns:
|
||||
Parsed YAML as dictionary, or None for empty/comment-only content
|
||||
"""
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
# Add utility functions to context (but don't override existing values)
|
||||
utility_functions = {
|
||||
"range": range,
|
||||
"len": len,
|
||||
"str": str,
|
||||
"int": int,
|
||||
"float": float,
|
||||
"bool": bool,
|
||||
"list": list,
|
||||
"dict": dict,
|
||||
"enumerate": enumerate,
|
||||
"zip": zip,
|
||||
"min": min,
|
||||
"max": max,
|
||||
}
|
||||
|
||||
# Only add utility functions that aren't already in the context
|
||||
for name, func in utility_functions.items():
|
||||
if name not in context:
|
||||
context[name] = func
|
||||
|
||||
# Check if content has Jinja2 templates
|
||||
if "{%" in yaml_content or "{{" in yaml_content:
|
||||
if context:
|
||||
# We have context, render the templates
|
||||
return self._render_and_parse(yaml_content, context)
|
||||
# No context, need to defer rendering
|
||||
return self._defer_template_rendering(yaml_content)
|
||||
# No templates, parse normally
|
||||
result = yaml.safe_load(yaml_content)
|
||||
# Allow None for empty/comment-only content
|
||||
if result is None:
|
||||
return None
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected YAML to parse as dict, got {type(result).__name__}"
|
||||
)
|
||||
return result
|
||||
|
||||
def _render_and_parse(
|
||||
self, yaml_content: str, context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Render Jinja2 templates and parse the result as YAML.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML with Jinja2 templates
|
||||
context: Context for rendering
|
||||
|
||||
Returns:
|
||||
Parsed YAML
|
||||
"""
|
||||
try:
|
||||
# Render as a Jinja2 template
|
||||
template = self.env.from_string(yaml_content)
|
||||
rendered = template.render(**context)
|
||||
|
||||
# Parse the rendered YAML
|
||||
result = yaml.safe_load(rendered)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected rendered YAML to parse as dict, got {type(result).__name__}"
|
||||
)
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error("Failed to render template: %s", e)
|
||||
raise
|
||||
|
||||
def _defer_template_rendering(self, yaml_content: str) -> dict[str, Any]:
|
||||
"""
|
||||
Parse YAML while preserving template sections for later rendering.
|
||||
|
||||
This is the key innovation: we detect template sections and convert them
|
||||
to placeholder strings that can be stored in regular YAML.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML with Jinja2 templates
|
||||
|
||||
Returns:
|
||||
Parsed YAML with template markers
|
||||
"""
|
||||
# First, protect template sections by converting them to special markers
|
||||
protected_content, template_sections = self._protect_template_sections(
|
||||
yaml_content
|
||||
)
|
||||
|
||||
# Parse the protected YAML
|
||||
try:
|
||||
parsed = yaml.safe_load(protected_content)
|
||||
except yaml.YAMLError as e:
|
||||
logger.error("Failed to parse protected YAML: %s", e)
|
||||
logger.debug("Protected content:\n%s", protected_content)
|
||||
raise
|
||||
|
||||
# Restore template sections with markers
|
||||
result = self._restore_template_sections(parsed, template_sections)
|
||||
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected restored result to be dict, got {type(result).__name__}"
|
||||
)
|
||||
return result
|
||||
|
||||
def _protect_template_sections( # pylint: disable=too-many-locals,too-many-branches,too-many-statements
|
||||
self, content: str
|
||||
) -> Tuple[str, dict[str, str]]:
|
||||
"""
|
||||
Protect template sections by replacing them with placeholders.
|
||||
|
||||
Args:
|
||||
content: YAML content with templates
|
||||
|
||||
Returns:
|
||||
Protected content and mapping of placeholders to template sections
|
||||
"""
|
||||
template_sections = {}
|
||||
section_counter = [0] # Use list to allow modification in nested functions
|
||||
|
||||
# Pattern for block templates with their content
|
||||
# This handles nested blocks by matching the complete structure
|
||||
def find_block_templates(content: str) -> Tuple[str, dict[str, str]]:
|
||||
"""Find and replace block templates with proper nesting support."""
|
||||
result = content
|
||||
local_template_sections = {}
|
||||
|
||||
# Pattern to find the start of block templates
|
||||
start_pattern = re.compile(
|
||||
r"^(\s*)(.*?)({%\s*(?:for|if|elif|macro|block|with)\s+.*?%})",
|
||||
re.MULTILINE,
|
||||
)
|
||||
|
||||
while True:
|
||||
match = start_pattern.search(result)
|
||||
if not match:
|
||||
break
|
||||
|
||||
indent = match.group(1)
|
||||
_prefix = match.group(2) # Unused but part of the pattern
|
||||
start_tag = match.group(3)
|
||||
start_pos = match.start()
|
||||
|
||||
# Determine the end tag we're looking for
|
||||
tag_type = None
|
||||
for tag in ["for", "if", "macro", "block", "with"]:
|
||||
if f"{{% {tag}" in start_tag or f"{{%{tag}" in start_tag:
|
||||
tag_type = tag
|
||||
break
|
||||
|
||||
if not tag_type:
|
||||
# Skip this match if we can't determine the tag type
|
||||
result = result[: match.end()] + "SKIP" + result[match.end() :]
|
||||
continue
|
||||
|
||||
# Find the matching end tag, accounting for nesting
|
||||
end_pattern = f"end{tag_type}"
|
||||
pos = match.end()
|
||||
nesting_level = 1
|
||||
|
||||
while pos < len(result) and nesting_level > 0:
|
||||
# Look for start tags of the same type
|
||||
next_start = result.find(f"{{% {tag_type}", pos)
|
||||
next_start_alt = result.find(f"{{%{tag_type}", pos)
|
||||
if next_start_alt != -1 and (
|
||||
next_start == -1 or next_start_alt < next_start
|
||||
):
|
||||
next_start = next_start_alt
|
||||
|
||||
# Look for end tags
|
||||
next_end = result.find(f"{{% {end_pattern}", pos)
|
||||
next_end_alt = result.find(f"{{%{end_pattern}", pos)
|
||||
if next_end_alt != -1 and (
|
||||
next_end == -1 or next_end_alt < next_end
|
||||
):
|
||||
next_end = next_end_alt
|
||||
|
||||
if next_end == -1:
|
||||
# No matching end tag found
|
||||
break
|
||||
|
||||
if next_start != -1 and next_start < next_end:
|
||||
# Found a nested start tag
|
||||
nesting_level += 1
|
||||
pos = next_start + 1
|
||||
else:
|
||||
# Found an end tag
|
||||
nesting_level -= 1
|
||||
if nesting_level == 0:
|
||||
# Found the matching end tag
|
||||
end_pos = result.find("%}", next_end) + 2
|
||||
|
||||
# Extract the full block
|
||||
full_block = result[start_pos:end_pos]
|
||||
section_id = f"__TEMPLATE_BLOCK_{section_counter[0]}__"
|
||||
section_counter[0] += 1
|
||||
local_template_sections[section_id] = full_block
|
||||
|
||||
# Replace with placeholder
|
||||
replacement = f"{indent}__template__: {section_id}"
|
||||
result = result[:start_pos] + replacement + result[end_pos:]
|
||||
break
|
||||
pos = next_end + 1
|
||||
|
||||
return result, local_template_sections
|
||||
|
||||
# Use the improved block template finder
|
||||
protected, block_sections = find_block_templates(content)
|
||||
template_sections.update(block_sections)
|
||||
|
||||
# Protect inline templates in values
|
||||
def protect_inline(match: re.Match[str]) -> str:
|
||||
|
||||
# Get the full line
|
||||
full_line = match.group(0)
|
||||
key_part = match.group(1)
|
||||
value_part = match.group(2)
|
||||
|
||||
# Check if value contains templates
|
||||
if "{{" in value_part or "{%" in value_part:
|
||||
section_id = f"__TEMPLATE_INLINE_{section_counter[0]}__"
|
||||
section_counter[0] += 1
|
||||
template_sections[section_id] = value_part.strip()
|
||||
|
||||
# Replace with placeholder maintaining valid YAML structure
|
||||
return f"{key_part} {section_id}"
|
||||
return full_line
|
||||
|
||||
# Pattern for YAML key-value pairs
|
||||
value_pattern = re.compile(r"^(\s*\S+?:)(.*)$", re.MULTILINE)
|
||||
protected = value_pattern.sub(protect_inline, protected)
|
||||
|
||||
return protected, template_sections
|
||||
|
||||
def _restore_template_sections(
|
||||
self, data: Any, template_sections: dict[str, str]
|
||||
) -> Any:
|
||||
"""
|
||||
Restore template sections in the parsed data.
|
||||
|
||||
Args:
|
||||
data: Parsed YAML data
|
||||
template_sections: Mapping of placeholders to template content
|
||||
|
||||
Returns:
|
||||
Data with template markers
|
||||
"""
|
||||
if isinstance(data, dict):
|
||||
result: dict[str, Any] = {}
|
||||
for key, value in data.items():
|
||||
if (
|
||||
key == "__template__"
|
||||
and isinstance(value, str)
|
||||
and value in template_sections
|
||||
):
|
||||
# This is a template marker
|
||||
template_content = template_sections[value]
|
||||
|
||||
# Determine the type of template section
|
||||
if value.startswith("__TEMPLATE_BLOCK_"):
|
||||
# Block template - needs special handling
|
||||
# Extract the YAML structure from the template
|
||||
result["__template_block__"] = template_content
|
||||
else:
|
||||
# Inline template
|
||||
result["__template_value__"] = template_content
|
||||
elif isinstance(value, str) and value in template_sections:
|
||||
# This is an inline template placeholder as a value
|
||||
template_content = template_sections[value]
|
||||
if value.startswith("__TEMPLATE_INLINE_"):
|
||||
# Inline template
|
||||
result[key] = {"__template_value__": template_content}
|
||||
else:
|
||||
# Regular template
|
||||
result[key] = {"__template_block__": template_content}
|
||||
else:
|
||||
# Regular data or nested structure
|
||||
result[key] = self._restore_template_sections(
|
||||
value, template_sections
|
||||
)
|
||||
return result
|
||||
if isinstance(data, list):
|
||||
return [
|
||||
self._restore_template_sections(item, template_sections)
|
||||
for item in data
|
||||
]
|
||||
return data
|
||||
|
||||
|
||||
class TemplateAwareYAMLParser:
|
||||
"""
|
||||
YAML parser that intelligently handles Jinja2 templates.
|
||||
|
||||
This parser can work in two modes:
|
||||
1. Immediate rendering - when context is provided
|
||||
2. Deferred rendering - when no context, preserves templates for later
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.loader = YAMLJinjaLoader()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def parse_file(
|
||||
self, file_path: Path, context: Optional[dict[str, Any]] = None
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Parse a YAML file that may contain Jinja2 templates.
|
||||
|
||||
Args:
|
||||
file_path: Path to YAML file
|
||||
context: Optional context for immediate rendering
|
||||
|
||||
Returns:
|
||||
Parsed configuration, or None for empty/comment-only content
|
||||
"""
|
||||
return self.loader.load_file(file_path, context)
|
||||
|
||||
def parse_string(
|
||||
self, yaml_content: str, context: Optional[dict[str, Any]] = None
|
||||
) -> Optional[dict[str, Any]]:
|
||||
"""
|
||||
Parse YAML content that may contain Jinja2 templates.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML content
|
||||
context: Optional context for immediate rendering
|
||||
|
||||
Returns:
|
||||
Parsed configuration, or None for empty/comment-only content
|
||||
"""
|
||||
return self.loader.load_string(yaml_content, context)
|
||||
|
||||
def extract_raw_templates(
|
||||
self, config: dict[str, Any]
|
||||
) -> dict[str, dict[str, str]]:
|
||||
"""
|
||||
Extract raw template definitions from a configuration.
|
||||
|
||||
This identifies sections that contain template markers and
|
||||
returns them as raw strings for later processing.
|
||||
|
||||
Args:
|
||||
config: Configuration that may contain template markers
|
||||
|
||||
Returns:
|
||||
Dictionary of template types to template definitions
|
||||
"""
|
||||
raw_templates: dict[str, dict[str, Any]] = {
|
||||
"agents": {},
|
||||
"graphs": {},
|
||||
"streams": {},
|
||||
}
|
||||
|
||||
# Check templates section
|
||||
if "templates" in config:
|
||||
for template_type, templates in config["templates"].items():
|
||||
if template_type in raw_templates:
|
||||
for name, definition in templates.items():
|
||||
if self._has_template_markers(definition):
|
||||
# This needs to be stored as a raw template
|
||||
raw_templates[template_type][name] = self._to_yaml_string(
|
||||
definition
|
||||
)
|
||||
|
||||
return raw_templates
|
||||
|
||||
def _has_template_markers(self, data: Any) -> bool:
|
||||
"""Check if data contains template markers."""
|
||||
if isinstance(data, dict):
|
||||
if "__template_block__" in data or "__template_value__" in data:
|
||||
return True
|
||||
return any(self._has_template_markers(v) for v in data.values())
|
||||
if isinstance(data, list):
|
||||
return any(self._has_template_markers(item) for item in data)
|
||||
return False
|
||||
|
||||
def _to_yaml_string(self, data: Any) -> str:
|
||||
"""Convert data back to YAML string, restoring template syntax."""
|
||||
# This would restore the original template syntax from markers
|
||||
# For now, just dump as YAML
|
||||
return yaml.dump(data, default_flow_style=False)
|
||||
@@ -0,0 +1,265 @@
|
||||
"""
|
||||
YAML preprocessor for handling Jinja2 templates in YAML files.
|
||||
|
||||
This module provides a two-phase YAML processing system that enables
|
||||
full Jinja2 templating within YAML configuration files.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Optional
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment
|
||||
from jinja2 import meta
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YAMLTemplateProcessor:
|
||||
"""
|
||||
Processes YAML files containing Jinja2 templates.
|
||||
|
||||
This processor handles complex Jinja2 constructs like loops, conditionals,
|
||||
and filters within YAML files by using a two-phase approach.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.env = Environment(
|
||||
block_start_string="{%",
|
||||
block_end_string="%}",
|
||||
variable_start_string="{{",
|
||||
variable_end_string="}}",
|
||||
comment_start_string="{#",
|
||||
comment_end_string="#}",
|
||||
trim_blocks=True,
|
||||
lstrip_blocks=True,
|
||||
)
|
||||
|
||||
def process_file(self, file_path: Path, context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""
|
||||
Process a YAML file containing Jinja2 templates.
|
||||
|
||||
Args:
|
||||
file_path: Path to the YAML file
|
||||
context: Context for template rendering
|
||||
|
||||
Returns:
|
||||
Processed YAML data as a dictionary
|
||||
"""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
|
||||
return self.process_string(content, context)
|
||||
|
||||
def process_string(
|
||||
self, yaml_content: str, context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Process a YAML string containing Jinja2 templates.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML content as string
|
||||
context: Context for template rendering
|
||||
|
||||
Returns:
|
||||
Processed YAML data as a dictionary
|
||||
"""
|
||||
# Process as a single Jinja2 template to handle all constructs properly
|
||||
try:
|
||||
template = self.env.from_string(yaml_content)
|
||||
processed_content = template.render(**context)
|
||||
|
||||
# Parse the final YAML
|
||||
result = yaml.safe_load(processed_content)
|
||||
if not isinstance(result, dict):
|
||||
raise ValueError(
|
||||
f"Expected YAML to parse as dict, got {type(result).__name__}"
|
||||
)
|
||||
return result
|
||||
except yaml.YAMLError as e:
|
||||
logger.error("Failed to parse processed YAML: %s", e)
|
||||
logger.debug("Processed content:\n%s", processed_content)
|
||||
raise
|
||||
except Exception as e:
|
||||
logger.error("Failed to process template: %s", e)
|
||||
raise
|
||||
|
||||
def _process_template_blocks(self, content: str, context: dict[str, Any]) -> str:
|
||||
"""
|
||||
Process Jinja2 block templates (loops, conditionals) in YAML.
|
||||
|
||||
This handles constructs like:
|
||||
{% for item in items %}
|
||||
- name: {{ item }}
|
||||
{% endfor %}
|
||||
"""
|
||||
# More precise pattern for template blocks
|
||||
# Matches: {% for/if/etc ... %} content {% endfor/endif/etc %}
|
||||
block_pattern = re.compile(
|
||||
r"^(\s*)({%\s*(?:for|if|elif|else|macro|block|with)\s+.*?%}.*?"
|
||||
r"{%\s*end(?:for|if|macro|block|with)\s*%})",
|
||||
re.MULTILINE | re.DOTALL,
|
||||
)
|
||||
|
||||
def process_block(match: re.Match[str]) -> str:
|
||||
indentation = match.group(1)
|
||||
block_content = match.group(2)
|
||||
|
||||
# Create a template from the block
|
||||
template = self.env.from_string(block_content)
|
||||
|
||||
# Render the template
|
||||
rendered = template.render(**context)
|
||||
|
||||
# Ensure proper indentation is maintained
|
||||
lines = rendered.split("\n")
|
||||
indented_lines = []
|
||||
for line in lines:
|
||||
if line.strip(): # Don't indent empty lines
|
||||
indented_lines.append(indentation + line)
|
||||
else:
|
||||
indented_lines.append(line)
|
||||
|
||||
return "\n".join(indented_lines)
|
||||
|
||||
# First, process all template blocks
|
||||
processed = content
|
||||
|
||||
# Handle nested blocks by processing multiple times
|
||||
max_iterations = 10
|
||||
for _ in range(max_iterations):
|
||||
new_processed = block_pattern.sub(process_block, processed)
|
||||
if new_processed == processed:
|
||||
break
|
||||
processed = new_processed
|
||||
|
||||
return processed
|
||||
|
||||
def _process_inline_templates(self, content: str, context: dict[str, Any]) -> str:
|
||||
"""
|
||||
Process inline Jinja2 templates (variable substitutions).
|
||||
|
||||
This handles constructs like:
|
||||
name: {{ variable }}
|
||||
value: {{ expression | filter }}
|
||||
"""
|
||||
# Pattern to match inline templates
|
||||
inline_pattern = re.compile(r"{{.*?}}")
|
||||
|
||||
def process_inline(match: re.Match[str]) -> str:
|
||||
template_str = match.group(0)
|
||||
template = self.env.from_string(template_str)
|
||||
result = template.render(**context)
|
||||
|
||||
# Handle different result types
|
||||
if isinstance(result, (list, dict)):
|
||||
# For complex types, use YAML flow style
|
||||
return yaml.dump(result, default_flow_style=True).strip()
|
||||
if isinstance(result, bool):
|
||||
# YAML boolean format
|
||||
return "true" if result else "false"
|
||||
if isinstance(result, (int, float)):
|
||||
# Numbers don't need quotes
|
||||
return str(result)
|
||||
# Strings might need quotes if they contain special chars
|
||||
if any(c in str(result) for c in ":{}[]|>-"):
|
||||
return f'"{result}"'
|
||||
return str(result)
|
||||
|
||||
return inline_pattern.sub(process_inline, content)
|
||||
|
||||
def extract_variables(self, yaml_content: str) -> set[str]:
|
||||
"""
|
||||
Extract all variables used in templates within the YAML content.
|
||||
|
||||
Returns:
|
||||
Set of variable names used in templates
|
||||
"""
|
||||
# Parse the content as a Jinja2 template
|
||||
ast = self.env.parse(yaml_content)
|
||||
return meta.find_undeclared_variables(ast)
|
||||
|
||||
|
||||
class TemplateAwareConfigParser:
|
||||
"""
|
||||
Configuration parser that handles Jinja2 templates in YAML files.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.processor = YAMLTemplateProcessor()
|
||||
self.logger = logging.getLogger(__name__)
|
||||
|
||||
def parse_template_file(
|
||||
self, file_path: Path, template_context: Optional[dict[str, Any]] = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Parse a YAML file that may contain Jinja2 templates.
|
||||
|
||||
Args:
|
||||
file_path: Path to the YAML file
|
||||
template_context: Optional context for template rendering
|
||||
|
||||
Returns:
|
||||
Parsed configuration dictionary
|
||||
"""
|
||||
if template_context is None:
|
||||
template_context = {}
|
||||
|
||||
# Add useful functions to template context
|
||||
template_context.update(
|
||||
{
|
||||
"range": range,
|
||||
"len": len,
|
||||
"str": str,
|
||||
"int": int,
|
||||
"float": float,
|
||||
"bool": bool,
|
||||
"list": list,
|
||||
"dict": dict,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
return self.processor.process_file(file_path, template_context)
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to process template file %s: %s", file_path, e)
|
||||
raise
|
||||
|
||||
def parse_template_string(
|
||||
self, yaml_content: str, template_context: Optional[dict[str, Any]] = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Parse a YAML string that may contain Jinja2 templates.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML content as string
|
||||
template_context: Optional context for template rendering
|
||||
|
||||
Returns:
|
||||
Parsed configuration dictionary
|
||||
"""
|
||||
if template_context is None:
|
||||
template_context = {}
|
||||
|
||||
# Add useful functions to template context
|
||||
template_context.update(
|
||||
{
|
||||
"range": range,
|
||||
"len": len,
|
||||
"str": str,
|
||||
"int": int,
|
||||
"float": float,
|
||||
"bool": bool,
|
||||
"list": list,
|
||||
"dict": dict,
|
||||
}
|
||||
)
|
||||
|
||||
try:
|
||||
return self.processor.process_string(yaml_content, template_context)
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to process template string: %s", e)
|
||||
raise
|
||||
@@ -0,0 +1,556 @@
|
||||
"""
|
||||
YAML Template Engine with full inline Jinja2 support.
|
||||
|
||||
This engine properly handles the interaction between YAML and Jinja2,
|
||||
ensuring that generated content maintains proper YAML structure.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import List
|
||||
from typing import Optional
|
||||
from typing import Tuple
|
||||
|
||||
import yaml
|
||||
from jinja2 import Environment
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class YAMLTemplateEngine:
|
||||
"""
|
||||
A complete solution for inline Jinja2 templates in YAML.
|
||||
|
||||
This engine handles:
|
||||
1. Proper indentation preservation
|
||||
2. Block template expansion with correct YAML structure
|
||||
3. Inline template substitution
|
||||
4. Deferred template processing for storage
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
# Configure Jinja2 for YAML-friendly output
|
||||
self.env = Environment(
|
||||
block_start_string="{%",
|
||||
block_end_string="%}",
|
||||
variable_start_string="{{",
|
||||
variable_end_string="}}",
|
||||
comment_start_string="{#",
|
||||
comment_end_string="#}",
|
||||
trim_blocks=False, # Don't trim to preserve structure
|
||||
lstrip_blocks=False, # Don't strip to preserve indentation
|
||||
keep_trailing_newline=True,
|
||||
)
|
||||
|
||||
# Add custom filters for YAML
|
||||
self.env.filters["yaml"] = self._yaml_filter
|
||||
self.env.filters["indent"] = self._indent_filter
|
||||
self.env.filters["sum"] = self._sum_filter
|
||||
self.env.filters["selectattr"] = self._selectattr_filter
|
||||
|
||||
def load_file(
|
||||
self, file_path: Path, context: Optional[dict[str, Any]] = None
|
||||
) -> dict[str, Any]:
|
||||
"""Load a YAML file with inline Jinja2 templates."""
|
||||
with open(file_path, "r", encoding="utf-8") as f:
|
||||
content = f.read()
|
||||
return self.load_string(content, context)
|
||||
|
||||
def load_string(
|
||||
self, yaml_content: str, context: Optional[dict[str, Any]] = None
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Load YAML content with inline Jinja2 templates.
|
||||
|
||||
Args:
|
||||
yaml_content: YAML with Jinja2
|
||||
context: Template context (if None, deferred processing)
|
||||
|
||||
Returns:
|
||||
Parsed configuration
|
||||
"""
|
||||
# Quick check for templates
|
||||
if "{%" not in yaml_content and "{{" not in yaml_content:
|
||||
loaded = yaml.safe_load(yaml_content)
|
||||
if not isinstance(loaded, dict):
|
||||
raise ValueError(
|
||||
f"Expected YAML to load as dict, got {type(loaded).__name__}"
|
||||
)
|
||||
return loaded
|
||||
|
||||
if context is not None:
|
||||
# Immediate rendering
|
||||
return self._render_and_parse(yaml_content, context)
|
||||
# Deferred rendering - store templates
|
||||
return self._prepare_for_deferred_rendering(yaml_content)
|
||||
|
||||
def _render_and_parse(
|
||||
self, yaml_content: str, context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""Render templates and parse YAML with proper structure preservation."""
|
||||
# Pre-process to handle special cases
|
||||
processed_content = self._preprocess_for_rendering(yaml_content)
|
||||
|
||||
# Add utility functions and filters to context
|
||||
full_context = self._create_render_context(context)
|
||||
|
||||
# Render as Jinja2 template
|
||||
template = self.env.from_string(processed_content)
|
||||
rendered = template.render(**full_context)
|
||||
|
||||
# Post-process to fix any structural issues
|
||||
fixed_yaml = self._postprocess_rendered_yaml(rendered)
|
||||
|
||||
# Parse the result
|
||||
try:
|
||||
parsed = yaml.safe_load(fixed_yaml)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(
|
||||
f"Expected YAML to parse as dict, got {type(parsed).__name__}"
|
||||
)
|
||||
return parsed
|
||||
except yaml.YAMLError as e:
|
||||
logger.error("Failed to parse rendered YAML: %s", e)
|
||||
logger.debug("Rendered YAML:\n%s", fixed_yaml)
|
||||
# Try to fix common issues
|
||||
fixed_yaml = self._fix_common_yaml_issues(fixed_yaml)
|
||||
parsed = yaml.safe_load(fixed_yaml)
|
||||
if not isinstance(parsed, dict):
|
||||
raise ValueError(
|
||||
f"Expected YAML to parse as dict, got {type(parsed).__name__}"
|
||||
) from e
|
||||
return parsed
|
||||
|
||||
def _preprocess_for_rendering(self, content: str) -> str:
|
||||
"""
|
||||
Preprocess YAML content to improve Jinja2 rendering.
|
||||
|
||||
This adds hints to preserve structure during rendering.
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
processed_lines = []
|
||||
|
||||
for line in lines:
|
||||
# Add markers for better structure preservation
|
||||
if "{%" in line and "for" in line:
|
||||
# This is a for loop - add indentation hint
|
||||
indent = len(line) - len(line.lstrip())
|
||||
processed_lines.append(line)
|
||||
# Add a comment with indentation hint
|
||||
processed_lines.append(f"{' ' * indent}{{# indent: {indent} #}}")
|
||||
else:
|
||||
processed_lines.append(line)
|
||||
|
||||
return "\n".join(processed_lines)
|
||||
|
||||
def _postprocess_rendered_yaml(self, content: str) -> str:
|
||||
"""
|
||||
Fix common structural issues in rendered YAML.
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
fixed_lines = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
# Skip empty lines that might break structure
|
||||
if 0 < i < len(lines) - 1 and line.strip() == "":
|
||||
# Check if next line has content at same or lower indent
|
||||
next_line = lines[i + 1] if i + 1 < len(lines) else ""
|
||||
if next_line and not next_line.startswith(" "):
|
||||
continue
|
||||
|
||||
# Fix lines that have multiple YAML values on same line
|
||||
if ":" in line and line.count(":") == 1:
|
||||
# Check for multiple values
|
||||
parts = line.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
key_part = parts[0]
|
||||
value_part = parts[1].strip()
|
||||
|
||||
# Check for multiple key-value pairs in value
|
||||
if value_part and " " in value_part and ":" not in value_part:
|
||||
# Check if this looks like "value key2: value2"
|
||||
value_words = value_part.split()
|
||||
if len(value_words) > 1 and any(
|
||||
":" in w for w in value_words[1:]
|
||||
):
|
||||
# Split into multiple lines
|
||||
fixed_lines.append(f"{key_part}: {value_words[0]}")
|
||||
# Process remaining as new lines
|
||||
remaining = " ".join(value_words[1:])
|
||||
indent = len(key_part) - len(key_part.lstrip())
|
||||
fixed_lines.append(f"{' ' * indent}{remaining}")
|
||||
continue
|
||||
|
||||
fixed_lines.append(line)
|
||||
|
||||
return "\n".join(fixed_lines)
|
||||
|
||||
def _fix_common_yaml_issues( # pylint: disable=too-many-nested-blocks
|
||||
self, content: str
|
||||
) -> str:
|
||||
"""
|
||||
Fix common YAML parsing issues as a last resort.
|
||||
"""
|
||||
# Fix mapping values error
|
||||
lines = content.split("\n")
|
||||
fixed_lines = []
|
||||
|
||||
for line in lines:
|
||||
# Fix multiple colons on same line
|
||||
if line.count(":") > 1:
|
||||
# Split by first colon
|
||||
parts = line.split(":", 1)
|
||||
if len(parts) == 2:
|
||||
indent = len(parts[0]) - len(parts[0].lstrip())
|
||||
fixed_lines.append(f"{parts[0]}:")
|
||||
|
||||
# Parse remaining part
|
||||
remaining = parts[1].strip()
|
||||
if remaining:
|
||||
# Split by whitespace and reconstruct
|
||||
tokens = remaining.split()
|
||||
current_line = ""
|
||||
|
||||
for token in tokens:
|
||||
if ":" in token and token.endswith(":"):
|
||||
if current_line:
|
||||
fixed_lines.append(
|
||||
f"{' ' * (indent + 2)}{current_line}"
|
||||
)
|
||||
fixed_lines.append(f"{' ' * (indent + 2)}{token}")
|
||||
current_line = ""
|
||||
else:
|
||||
current_line += f" {token}" if current_line else token
|
||||
|
||||
if current_line:
|
||||
fixed_lines.append(f"{' ' * (indent + 2)}{current_line}")
|
||||
continue
|
||||
|
||||
fixed_lines.append(line)
|
||||
|
||||
return "\n".join(fixed_lines)
|
||||
|
||||
def _prepare_for_deferred_rendering(self, yaml_content: str) -> dict[str, Any]:
|
||||
"""
|
||||
Prepare YAML with templates for deferred rendering.
|
||||
|
||||
This method extracts template sections and stores them in a way
|
||||
that preserves the YAML structure while marking templates.
|
||||
"""
|
||||
# For now, use the simple approach for deferred rendering
|
||||
# The complex extraction was causing issues
|
||||
return self._simple_template_extraction(yaml_content)
|
||||
|
||||
def _analyze_yaml_structure(self, content: str) -> dict[str, Any]:
|
||||
"""
|
||||
Analyze YAML structure to understand where templates are.
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
structure: dict[str, Any] = {
|
||||
"template_blocks": [],
|
||||
"inline_templates": [],
|
||||
"hierarchy": [],
|
||||
}
|
||||
|
||||
current_path: List[Tuple[str, int]] = []
|
||||
|
||||
for i, line in enumerate(lines):
|
||||
stripped = line.strip()
|
||||
if not stripped or stripped.startswith("#"):
|
||||
continue
|
||||
|
||||
indent = len(line) - len(line.lstrip())
|
||||
|
||||
# Track hierarchy
|
||||
while current_path and current_path[-1][1] >= indent:
|
||||
current_path.pop()
|
||||
|
||||
# Check for template block
|
||||
if "{%" in line and any(kw in line for kw in ["for", "if", "macro"]):
|
||||
block_info = {
|
||||
"start_line": i,
|
||||
"indent": indent,
|
||||
"path": [p[0] for p in current_path],
|
||||
"type": (
|
||||
"for" if "for" in line else "if" if "if" in line else "macro"
|
||||
),
|
||||
}
|
||||
|
||||
# Find end of block
|
||||
end_line = self._find_block_end(lines, i, indent)
|
||||
block_info["end_line"] = end_line
|
||||
|
||||
structure["template_blocks"].append(block_info)
|
||||
|
||||
# Check for key-value pair
|
||||
elif ":" in line:
|
||||
key_match = re.match(r"^(\s*)([^:]+):\s*(.*)$", line)
|
||||
if key_match:
|
||||
key = key_match.group(2).strip()
|
||||
value = key_match.group(3).strip()
|
||||
|
||||
current_path.append((key, indent))
|
||||
|
||||
# Check for inline template
|
||||
if value and ("{{" in value or "{%" in value):
|
||||
structure["inline_templates"].append(
|
||||
{
|
||||
"line": i,
|
||||
"path": [p[0] for p in current_path],
|
||||
"key": key,
|
||||
"value": value,
|
||||
}
|
||||
)
|
||||
|
||||
return structure
|
||||
|
||||
def _find_block_end(self, lines: List[str], start: int, indent: int) -> int:
|
||||
"""Find the end line of a template block."""
|
||||
for i in range(start + 1, len(lines)):
|
||||
line = lines[i]
|
||||
if any(kw in line for kw in ["endfor", "endif", "endmacro"]):
|
||||
line_indent = len(line) - len(line.lstrip())
|
||||
if line_indent <= indent:
|
||||
return i
|
||||
return len(lines) - 1
|
||||
|
||||
def _extract_template_sections( # pylint: disable=too-many-locals
|
||||
self, content: str, structure: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Extract template sections and create a parseable structure.
|
||||
"""
|
||||
lines = content.split("\n")
|
||||
|
||||
# Process template blocks from bottom to top to maintain line numbers
|
||||
for block in reversed(structure["template_blocks"]):
|
||||
start = block["start_line"]
|
||||
end = block["end_line"]
|
||||
indent = block["indent"]
|
||||
|
||||
# Extract block content
|
||||
block_lines = lines[start : end + 1]
|
||||
_block_content = "\n".join(block_lines)
|
||||
|
||||
# Store template
|
||||
block_index = structure["template_blocks"].index(block)
|
||||
block_count = len(structure["template_blocks"]) - block_index
|
||||
_template_id = f"__template_block_{block_count}__"
|
||||
|
||||
# Find parent key
|
||||
parent_line = -1
|
||||
for j in range(start - 1, -1, -1):
|
||||
if (
|
||||
j < len(lines)
|
||||
and lines[j].strip()
|
||||
and lines[j].strip().endswith(":")
|
||||
):
|
||||
parent_line = j
|
||||
break
|
||||
|
||||
if 0 <= parent_line < len(lines):
|
||||
# Replace with template marker
|
||||
parent_indent = len(lines[parent_line]) - len(
|
||||
lines[parent_line].lstrip()
|
||||
)
|
||||
parent_key = lines[parent_line].strip()[:-1]
|
||||
|
||||
# Replace lines
|
||||
lines[parent_line] = f"{' ' * parent_indent}{parent_key}:"
|
||||
lines[parent_line + 1 : end + 1] = [
|
||||
f"{' ' * (parent_indent + 2)}_template_content: |",
|
||||
*[f"{' ' * (parent_indent + 4)}{line}" for line in block_lines],
|
||||
f"{' ' * (parent_indent + 2)}_template_type: block",
|
||||
]
|
||||
|
||||
# Process inline templates
|
||||
for inline in structure["inline_templates"]:
|
||||
line_idx = inline["line"]
|
||||
if line_idx < len(lines):
|
||||
line = lines[line_idx]
|
||||
key_match = re.match(r"^(\s*)([^:]+):\s*(.*)$", line)
|
||||
if key_match:
|
||||
indent = key_match.group(1)
|
||||
key = key_match.group(2)
|
||||
value = key_match.group(3)
|
||||
|
||||
lines[line_idx] = f"{indent}{key}:"
|
||||
lines.insert(line_idx + 1, f"{indent} _template_value: {value}")
|
||||
lines.insert(line_idx + 2, f"{indent} _template_type: inline")
|
||||
|
||||
# Parse the modified content
|
||||
modified_content = "\n".join(lines)
|
||||
|
||||
try:
|
||||
parsed_result = yaml.safe_load(modified_content)
|
||||
if not isinstance(parsed_result, dict):
|
||||
raise ValueError(
|
||||
f"Expected YAML to parse as dict, got {type(parsed_result).__name__}"
|
||||
)
|
||||
return parsed_result
|
||||
except (yaml.YAMLError, ValueError) as e:
|
||||
logger.error("Failed to parse modified YAML: %s", e)
|
||||
# Fall back to simpler approach
|
||||
return self._simple_template_extraction(content)
|
||||
|
||||
def _simple_template_extraction(self, content: str) -> dict[str, Any]:
|
||||
"""
|
||||
Simpler extraction method as fallback.
|
||||
"""
|
||||
# Just mark the whole content as a template if it contains Jinja2
|
||||
return {"_raw_template": content, "_is_template": True}
|
||||
|
||||
def _create_render_context(self, context: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Create a complete rendering context with utilities."""
|
||||
full_context = {
|
||||
# Python built-ins
|
||||
"range": range,
|
||||
"len": len,
|
||||
"int": int,
|
||||
"float": float,
|
||||
"str": str,
|
||||
"list": list,
|
||||
"dict": dict,
|
||||
"enumerate": enumerate,
|
||||
"zip": zip,
|
||||
"min": min,
|
||||
"max": max,
|
||||
"sum": sum,
|
||||
"abs": abs,
|
||||
"round": round,
|
||||
# Utility functions
|
||||
"tojson": lambda x: yaml.dump(x, default_flow_style=True).strip(),
|
||||
"default": lambda x, d: x if x is not None else d,
|
||||
}
|
||||
full_context.update(context)
|
||||
return full_context
|
||||
|
||||
def _yaml_filter(self, value: Any) -> str:
|
||||
"""Filter to convert value to YAML string."""
|
||||
return yaml.dump(value, default_flow_style=True).strip()
|
||||
|
||||
def _indent_filter(self, text: str, spaces: int) -> str:
|
||||
"""Filter to indent text."""
|
||||
lines = text.split("\n")
|
||||
return "\n".join(" " * spaces + line if line else line for line in lines)
|
||||
|
||||
def _sum_filter(self, iterable: Any, attribute: Optional[str] = None) -> float:
|
||||
"""Custom sum filter that handles objects with attributes."""
|
||||
if attribute:
|
||||
result: float = sum(
|
||||
(
|
||||
item.get(attribute, 0)
|
||||
if isinstance(item, dict)
|
||||
else getattr(item, attribute, 0)
|
||||
)
|
||||
for item in iterable
|
||||
)
|
||||
return result
|
||||
# If items are dicts, this won't work - need to extract values
|
||||
if iterable and isinstance(iterable[0], dict):
|
||||
# Try to sum 'value' attribute if it exists
|
||||
result_dict: float = sum(item.get("value", 0) for item in iterable)
|
||||
return result_dict
|
||||
# Filter out None values
|
||||
result_final: float = sum(item for item in iterable if item is not None)
|
||||
return result_final
|
||||
|
||||
def _selectattr_filter( # pylint: disable=too-many-branches
|
||||
self, iterable: Any, attr: str, func: Optional[str] = None, value: Any = None
|
||||
) -> List[Any]:
|
||||
"""Custom selectattr filter."""
|
||||
import operator # pylint: disable=import-outside-toplevel
|
||||
|
||||
def default_eq(x: Any, y: Any) -> bool:
|
||||
"""Default equality comparison."""
|
||||
return bool(x == y)
|
||||
|
||||
if func == ">":
|
||||
op = operator.gt
|
||||
elif func == "<":
|
||||
op = operator.lt
|
||||
elif func == ">=":
|
||||
op = operator.ge
|
||||
elif func == "<=":
|
||||
op = operator.le
|
||||
elif func == "==":
|
||||
op = operator.eq
|
||||
elif func == "!=":
|
||||
op = operator.ne
|
||||
else:
|
||||
op = default_eq
|
||||
|
||||
result = []
|
||||
for item in iterable:
|
||||
if isinstance(item, dict):
|
||||
item_value = item.get(attr)
|
||||
else:
|
||||
item_value = getattr(item, attr, None)
|
||||
|
||||
if item_value is not None and op(item_value, value):
|
||||
result.append(item)
|
||||
|
||||
return result
|
||||
|
||||
def render_template(
|
||||
self, template_config: dict[str, Any], context: dict[str, Any]
|
||||
) -> dict[str, Any]:
|
||||
"""
|
||||
Render a stored template configuration.
|
||||
"""
|
||||
if "_raw_template" in template_config:
|
||||
# Raw template stored
|
||||
return self._render_and_parse(template_config["_raw_template"], context)
|
||||
|
||||
# Reconstruct template from structure
|
||||
yaml_content = self._reconstruct_from_structure(template_config)
|
||||
return self._render_and_parse(yaml_content, context)
|
||||
|
||||
def _reconstruct_from_structure(self, config: dict[str, Any]) -> str:
|
||||
"""Reconstruct YAML with templates from stored structure."""
|
||||
|
||||
def process_value(value: Any, indent: int = 0) -> str: # pylint: disable=too-many-branches
|
||||
if isinstance(value, dict):
|
||||
if "_template_content" in value and "_template_type" in value:
|
||||
# This is a stored template block
|
||||
content = value["_template_content"]
|
||||
if not isinstance(content, str):
|
||||
raise ValueError(
|
||||
f"Expected _template_content to be str, got {type(content).__name__}"
|
||||
)
|
||||
return content.strip()
|
||||
if "_template_value" in value and "_template_type" in value:
|
||||
# This is an inline template
|
||||
template_val = value["_template_value"]
|
||||
if not isinstance(template_val, str):
|
||||
raise ValueError(
|
||||
f"Expected _template_value to be str, got {type(template_val).__name__}"
|
||||
)
|
||||
return template_val
|
||||
# Regular dict
|
||||
lines = []
|
||||
for k, v in value.items():
|
||||
if k.startswith("_"):
|
||||
continue
|
||||
if v is None:
|
||||
lines.append(f"{' ' * indent}{k}:")
|
||||
elif isinstance(v, (dict, list)):
|
||||
lines.append(f"{' ' * indent}{k}:")
|
||||
lines.append(process_value(v, indent + 2))
|
||||
else:
|
||||
lines.append(f"{' ' * indent}{k}: {v}")
|
||||
return "\n".join(lines)
|
||||
if isinstance(value, list):
|
||||
lines = []
|
||||
for item in value:
|
||||
if isinstance(item, (dict, list)):
|
||||
lines.append(f"{' ' * indent}-")
|
||||
lines.append(process_value(item, indent + 2))
|
||||
else:
|
||||
lines.append(f"{' ' * indent}- {item}")
|
||||
return "\n".join(lines)
|
||||
return str(value)
|
||||
|
||||
return process_value(config)
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user