Compare commits

...

118 Commits

Author SHA1 Message Date
aditya cbcb2d41ab fix: fix RuntimeWarning for cancelled coroutines in dispose() in base.py 2025-11-14 15:30:16 +05:30
aditya e3e8f57360 fix: prevent infinite recursion in retry logic by retrying operation directly instead of calling execute() recursively 2025-11-14 15:28:29 +05:30
aditya b8efe9343d fix: fix the DeprecationWarning regarding no current event loop in test_bridge.py 2025-11-13 19:50:09 +05:30
aditya 485084fbcc refactor: cleanup test files with fixtures; remove unused imports; remove unused variables 2025-11-13 18:41:10 +05:30
aditya 5fd1d213cd fix: improve test quality and fix deprecation warning 2025-11-12 19:36:01 +05:30
aditya 3c3b55f173 fix: fix more testing issues per review feedback 2025-11-11 20:34:31 +05:30
aditya d19330750f fix: address testing issues per review feedback 2025-11-10 18:02:31 +05:30
aditya 316375d66f refactor: remove unnecessary docstring 2025-11-07 19:26:23 +05:30
aditya 98635a5d0c test: improve test quality and determinism, replace dynamic temp files with static fixtures, remove tests for untentionally unimplemented code 2025-11-07 19:06:27 +05:30
aditya 62d4ff6282 fix: resolve async coroutine handling and improve test hygiene 2025-11-06 18:32:11 +05:30
aditya 583adb8625 refactor: replace lambda with method reference and remove unused variables 2025-11-05 12:37:34 +05:30
aditya c3889408cf chore: Add pytest-asyncio dependency to fix async tests in Python 3.12+ 2025-11-04 20:36:04 +05:30
aditya 42ac64596d merge: add mypy-pylint-fixed changes 2025-11-04 20:35:55 +05:30
aditya ca6db1a879 test: add more unit tests and remove redundant tests, UPDATE README.md 2025-11-04 20:33:25 +05:30
aditya 1bdc1a80c3 test: fix failing behave bdd tests, update docs 2025-11-04 20:33:25 +05:30
aditya 26a82a444f test: add more unit tests and fix memory issue 2025-11-04 20:33:24 +05:30
aditya c7940b74f3 test: add more comprehensive unit tests for modules present inside src/cleveragents/reactive/, src/cleveragents/langgraph/, src/cleveragents/ 2025-11-04 20:33:24 +05:30
aditya 7b4b0d59e7 test: add comprehensive unit tests for modules present in src/cleveragents/core and src/cleveragents/templates 2025-11-04 20:33:23 +05:30
aditya 8c502170de test: add comprehensive unit tests for agents module 2025-11-04 20:33:10 +05:30
aditya 6fff3968a6 fix: handle empty/comment-only YAML gracefully 2025-11-04 18:59:30 +05:30
aditya e93e2174b6 fix: fix some minor type annotation issues 2025-11-04 18:59:30 +05:30
aditya 8ab776d7ba fix: remove cast with runtime type checking; replace all Dict[..] with dict[..] 2025-11-04 18:59:29 +05:30
aditya 8be2299bce fix: fix mypy --strict related errors 2025-11-04 18:59:28 +05:30
aditya 9865cd5088 fix: type safety and maintainability improvements 2025-11-04 18:59:27 +05:30
aditya c58106f394 fix: fix rest of the mypy --strict and pylint errors 2025-11-04 18:59:26 +05:30
aditya 221a0b74d2 fix: fix remaining mypy --strict and pylint errors 2025-11-04 18:59:26 +05:30
aditya 98bac0f5f3 test: update bdd test cases; docs: update readme and yaml syntax docs 2025-11-04 18:59:25 +05:30
aditya 5e87de863c fix: fix all mypy, pylint issues in src/cleveragents/ 2025-11-04 18:59:24 +05:30
aditya c3181d6295 fix: fix mypy pylint issues is src/cleveragents/templates/ 2025-11-04 18:59:24 +05:30
aditya ba751d269b fix: fix mypy, pylint issues in src/cleveragents/reactive/ 2025-11-04 18:59:23 +05:30
aditya 91f9d32b44 fix: fix mypy, pylint issues in src/cleveragents/langgraph/ 2025-11-04 18:59:23 +05:30
aditya 9272d792fa fix: fix mypy, pylint issues in src/cleveragents/core/ 2025-11-04 18:59:22 +05:30
aditya 3fb94b58c2 fix: fix mypy, pylint issues in src/cleveragents/agents/ 2025-11-04 18:59:21 +05:30
aditya 0ee3270caa fix: fix mypy, pylint issues in base.py and application.py 2025-11-04 18:59:13 +05:30
aditya 0b030ee91a fix: fix file path validation 2025-10-30 20:34:17 +05:30
aditya e765a23843 fix: security hardening, cross-version compatibility, and consistency improvements 2025-10-29 15:58:53 +05:30
aditya 237ecd1682 update README.md to fix linting issues 2025-10-24 23:02:12 +05:30
aditya 3e6e984567 test: fix tool_agent shell command mocking and cleanup and update README.md 2025-10-23 20:39:27 +05:30
aditya fb92bf6790 test: fix test scripts and update readme 2025-10-18 02:23:10 +05:30
aditya 904decff02 docs: update docs/YAML_SYNTAX.md and README.md 2025-10-18 01:56:30 +05:30
aditya e60de39c6d test; fix the failing behave tests 2025-10-18 01:40:22 +05:30
aditya fe7cb7ac47 fix: fix multi agent paper writer langgraph script, mypy and pylint issues 2025-10-13 18:35:37 +05:30
aditya 3aa3d62389 fix: thread safety AgentWithMemory and improve ToolAgent JSON parsing and mypy issues 2025-10-10 19:38:52 +05:30
aditya bc8cec1d89 fix: resolve linting issues - trailing whitespace and line length 2025-10-09 20:32:13 +05:30
aditya 1df5de4087 tests: add behave bdd test cases for new changes 2025-10-08 18:06:36 +05:30
aditya aeb67fc9bd feat: add text append to file feature to file write tool, now paper writer tool works section by section as well 2025-10-08 17:36:11 +05:30
aditya 758d371b0a test: add end to end test scripts for langraph yaml files 2025-10-07 20:34:09 +05:30
aditya 5190be5781 chore: add working multi agent langraph yaml files 2025-10-07 20:32:23 +05:30
aditya f969995644 fix: graph context fix 2025-10-07 20:30:08 +05:30
aditya 0d9b100263 test: fix the broken bdd test cases 2025-10-06 18:49:40 +05:30
aditya ed59c1cacd chore: fixed all pylint and mypy warnings 2025-10-06 18:26:06 +05:30
aditya 0f0a321c75 refactor: Reorganize unit tests into modular structure (tests/unit/core/, agents/, reactive/, etc.), dd comprehensive test suite for tool command processing, Add sample contract and integration test script 2025-10-06 18:20:38 +05:30
aditya c62f8bbb39 chore: update legal contract metadata extractor orchestration 2025-10-06 18:07:18 +05:30
aditya 0da823b4b9 chore: add legal contract metadata update 2025-10-06 18:02:49 +05:30
aditya b113ee2c8f Add json sanitization for LLM-generated tool commands 2025-10-03 20:12:12 +05:30
aditya 88a5231498 docs: move yaml syntax file to docs folder 2025-10-03 19:34:51 +05:30
aditya 81324f2881 test: Add multi-agent end-to-end test scripts 2025-10-03 19:34:10 +05:30
aditya 2b39f40009 chore: Add multi-agent paper writer working yaml 2025-10-03 19:33:29 +05:30
aditya 71f41d166d feat: Implement multi-agent orchestration with tool invocation 2025-10-03 19:31:22 +05:30
aditya dbb2266364 test: Add tests to check stream subscriptions are not duplicated 2025-10-03 19:28:53 +05:30
aditya 2fa50b55c6 Fix: fix double response bug in the code
ISSUES FIXED: #4
2025-10-03 19:28:53 +05:30
aditya 201ee39ebe chore: Add yaml file with 'memory_enabled: true' 2025-10-03 19:28:53 +05:30
aditya f87b7bdd41 chore: Add legal contract metadata extractor agent yaml config file 2025-10-03 19:28:53 +05:30
aditya 9e4896e92c docs: Add readme file and yaml file syntax documentation 2025-10-02 17:51:49 +05:30
freemo 4947b17521 Got initial step in scientific paper working 2025-09-12 12:57:44 -04:00
freemo 183363bfc5 Fixed error thrown after unit tests complete 2025-09-09 12:29:30 -04:00
freemo 84e8f9f938 Fixed all unit tests and static checks, tox now passes fully. 2025-09-09 07:53:46 -04:00
freemo e4c337e100 Fixed the same input echoing twice with no response, no output now but you get a new sane looking terminal with no double echo 2025-09-08 05:57:25 -04:00
freemo b48ae7157f Fixed bridge error 2025-09-08 05:23:45 -04:00
freemo ecbb695dc9 Implemented the scientific paper writer in the new framework 2025-09-08 04:55:16 -04:00
freemo c4cc5e763b perf: Speeding uo tests 2025-08-04 05:24:46 +00:00
freemo 45c6e5a662 Feat: LangChain now acts as LLM provider increasing availible LLMs supported 2025-08-04 03:48:13 +00:00
freemo e033d83acc test: added new tests to improve coverage. 2025-08-02 23:45:57 +00:00
freemo 8d988dfbcb refactor: remove legacy support and unused templates from examples and tests 2025-07-26 19:24:43 +00:00
freemo c550d780f9 feat: implement unified routes system for streams and graphs 2025-07-25 02:39:05 +00:00
freemo 4f17a65f17 feat: Added advanced yaml inline templating as well as agent, graph, and stream templates which also enahnces composite agents. 2025-07-24 19:31:17 -04:00
freemo 7fb3a293be feat: Added LangGraph functionality 2025-07-24 15:19:21 -04:00
freemo 43a10c52a7 Converted code to use RxPy for its routes 2025-07-24 13:16:10 -04:00
freemo 33047b4667 feat: enhance scientific paper writer with detailed role instructions 2025-07-11 22:25:32 -04:00
freemo 68dc5820b0 feat: add json_mode attribute and update Google response payload 2025-07-11 22:25:18 -04:00
freemo ea037e1bff refactor: update logging and error handling in LLMAgent and TemplateRenderer 2025-07-07 18:40:08 -04:00
freemo c2e9fbcf57 feat: add Google Gemini API support with web search feature flag 2025-07-07 18:17:32 -04:00
freemo 877a0d584c fix: correct environment variable name for Gemini API key 2025-07-07 21:52:09 +00:00
freemo f31a128982 refactor: update YAML for improved clarity and null handling 2025-07-07 17:51:23 -04:00
freemo 00c1cf0978 fix: improve template error handling and context availability in LLMAgent 2025-07-07 11:46:56 -04:00
freemo 462c3a4e30 fix: Fixed output of discovery stage so it behaves correctly. 2025-07-07 11:39:25 -04:00
freemo f4e8f4dca1 fix: update initial_message in context for each interactive turn 2025-07-07 11:15:51 -04:00
freemo 2f72c92147 feat: enhance error reporting and add intro stage to paper writer 2025-07-07 09:24:06 -04:00
freemo cdf2d52e0e feat: add scientific paper writer configuration file 2025-07-07 00:09:41 -04:00
freemo 6910774554 feat: add unsafe mode for executing code with full privileges 2025-07-06 23:55:07 -04:00
freemo 1452ef4239 fix: ensure context persistence and initialization in interactive mode 2025-07-06 22:15:56 -04:00
freemo 2fa7e1c913 feat: add support for custom code tools and enhance error handling 2025-07-06 20:40:04 -04:00
freemo 7fe3521a2c test: add feature tests for Agent base class coverage 2025-07-06 22:08:06 +00:00
freemo 0b052c75af refactor: improve null checks and error handling in LLMAgent class 2025-07-06 21:40:09 +00:00
freemo 3cc976c895 refactor: improve config management and add comprehensive tests 2025-07-06 20:20:09 +00:00
freemo 245a8774e8 test: add detailed router scenarios and steps for coverage 2025-07-06 15:23:54 -04:00
freemo 47b32b2800 test: add detailed tests for Tool, WebSearchTool, and ToolAgent 2025-07-06 14:42:47 -04:00
freemo b917e8fdff test: add tests for TemplateRenderer error handling and edge cases 2025-07-06 14:15:30 -04:00
freemo 6fde8193c6 feat: enhance error handling and add comprehensive session tests 2025-07-06 14:01:27 -04:00
freemo af801be1c1 test: add AgentNetwork feature tests with mock configurations 2025-07-05 23:32:24 -04:00
freemo ff99155cf0 chore: update .coveragerc to exclude tests from coverage source 2025-07-05 23:12:53 -04:00
freemo d935b7cec3 feat: add step aliasing and validation to CompositeAgent with tests 2025-07-06 02:50:28 +00:00
freemo f325866e6d feat: replace 'agents' with 'steps' in CompositeAgent configuration, and have it support both routes and agents. 2025-07-04 17:46:15 -04:00
freemo 437f2e5651 feat: enhance CompositeAgent with parallel strategy and add tests 2025-07-04 15:10:18 -04:00
freemo a9b8a1a6a3 fix: add validation for unique agent and route names in initialization 2025-07-04 13:22:36 -04:00
freemo f018ecd02f feat: enhance agent configuration with template references and validation 2025-07-02 23:33:06 -04:00
freemo 72e9fcb985 feat: add API key configuration and handling for agents and tools 2025-07-01 22:20:04 -04:00
freemo 9392934fd0 refactor: remove legacy router support and update configuration handling 2025-07-01 20:47:20 -04:00
freemo 08817f1754 refactor: remove fallback logic in template expression evaluation 2025-07-01 19:46:03 -04:00
freemo 682f511320 Removed eaten exceptions and support for json in messages 2025-07-01 18:31:36 -04:00
freemo 78a09c48fc feat: add dynamic context updates to Router class 2025-07-01 17:37:36 -04:00
freemo ee69a20ad7 feat: add route strategy to CompositeAgent with tests and examples 2025-07-01 16:18:56 -04:00
freemo 9c8cae1fd4 refactor: support multi-router configuration in CleverAgentsApp 2025-07-01 01:00:08 +00:00
freemo 2c667b4cb0 test: enhance message routing verification with context history 2025-06-30 23:16:09 +00:00
freemo 9049478bdc refactor: improve agent configuration and routing logic in LLM and tool agents 2025-06-30 18:12:05 +00:00
freemo 315c583ed9 feat: add calculator agent example and improve tool agent handling 2025-06-28 22:14:28 +00:00
freemo 72726a762a feat: migrate project to CleverAgents with new features and tests 2025-06-28 14:49:24 -04:00
freemo 29934a9c6a chore: update .gitignore to exclude .aider files 2025-06-25 17:15:28 +00:00
327 changed files with 122767 additions and 218 deletions
+1 -1
View File
@@ -7,4 +7,4 @@ tag = True
[bumpversion:file:docs/conf.py]
[bumpversion:file:src/stockstack/__init__.py]
[bumpversion:file:src/cleveragents/__init__.py]
+1 -3
View File
@@ -3,9 +3,7 @@ source = src
[run]
branch = true
source =
src
tests
source = src
parallel = true
[report]
+24
View File
@@ -0,0 +1,24 @@
#!/bin/sh
set -e
cd /app
# Verify that `/app` is mounted as an *external* Docker volume before attempting to
# perform the potentially time-consuming `pip install`. We detect a volume mount
# by checking for an explicit `/app` entry in `/proc/self/mountinfo` which lists
# all mount points visible to the current process.
if [ -f /proc/self/mountinfo ] && grep -q '/_data[[:space:]]/app[[:space:]]' /proc/self/mountinfo; then
# `/app` is *not* mounted as a volume. Skip installation to avoid polluting
# the image and to respect the caller's intent.
echo "Skipping requirements installation because /app is not a mounted volume." >&2
elif [ -f requirements.txt ]; then
# `/app` **is** a separate mount → proceed with installation.
pyenv local 3.10.17
python -m pip install --no-cache-dir -r requirements.txt
pyenv local 3.11.12
python -m pip install --no-cache-dir -r requirements.txt
pyenv local 3.12.10
python -m pip install --no-cache-dir -r requirements.txt
pyenv local 3.13.3
python -m pip install --no-cache-dir -r requirements.txt
fi
+1
View File
@@ -0,0 +1 @@
/bin/bash
+43
View File
@@ -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
View File
@@ -1,45 +0,0 @@
language: python
python: '3.9'
sudo: false
env:
global:
- LD_PRELOAD=/lib/x86_64-linux-gnu/libSegFault.so
- SEGFAULT_SIGNALS=all
matrix:
- TOXENV=check
- TOXENV=docs
- TOXENV=2.7-cover,coveralls,codecov
- TOXENV=2.7-nocov
- TOXENV=3.3-cover,coveralls,codecov
- TOXENV=3.3-nocov
- TOXENV=3.4-cover,coveralls,codecov
- TOXENV=3.4-nocov
- TOXENV=3.5-cover,coveralls,codecov
- TOXENV=3.5-nocov
- TOXENV=pypy-cover,coveralls,codecov
- TOXENV=pypy-nocov
before_install:
- python --version
- uname -a
- lsb_release -a
install:
- pip install tox
- virtualenv --version
- easy_install --version
- pip --version
- tox --version
script:
- tox -v
after_failure:
- more .tox/log/* | cat
- more .tox/*/log/* | cat
before_cache:
- rm -rf $HOME/.cache/pip/log
cache:
directories:
- $HOME/.cache/pip
notifications:
email:
on_success: never
on_failure: always
+2 -1
View File
@@ -1,7 +1,8 @@
This file contains all attributions and other notices that are required by law. This will contain all copyright
notices as well as any notes regarding licensing.
## Copyright Notices
Copyright Notices
----------------
The following are the list of all recognized copyright notices added by contributors to this project:
-4
View File
@@ -1,4 +0,0 @@
# Changelog
## v0.0.1
* Initial release.
+1
View File
@@ -0,0 +1 @@
Changelog
+24 -13
View File
@@ -1,6 +1,10 @@
# Code of Conduct
===============
Code of Conduct
===============
## Our Pledge
-----------
Our Pledge
-----------
In the interest of fostering an open and welcoming environment, we as
contributors and maintainers pledge to making participation in our project and
@@ -9,7 +13,9 @@ size, disability, ethnicity, gender identity and expression, level of experience
nationality, personal appearance, race, religion, or sexual identity and
orientation.
## Our Standards
--------------
Our Standards
--------------
Examples of behavior that contributes to creating a positive environment
include:
@@ -28,7 +34,9 @@ Examples of unacceptable behavior by participants include:
* Publishing others' private information, such as a physical or electronic
address, without explicit permission
## Our Responsibilities
---------------------
Our Responsibilities
---------------------
Project maintainers are responsible for clarifying the standards of acceptable
behavior and are expected to take appropriate and fair corrective action in
@@ -40,7 +48,9 @@ that are not aligned to this Code of Conduct, or to ban temporarily or
permanently any contributor for other behaviors that they deem inappropriate,
threatening, offensive, or harmful.
## Scope
-------
Scope
-------
This Code of Conduct applies both within project spaces and in public spaces
when an individual is representing the project or its community. Examples of
@@ -49,11 +59,13 @@ address, posting via an official social media account, or acting as an appointed
representative at an online or offline event. Representation of a project may be
further defined and clarified by project maintainers.
## Enforcement
------------
Enforcement
------------
Instances of abusive, harassing, or otherwise unacceptable behavior may be
reported by contacting the project team at
[abuse@cleverthis.com](mailto:abuse@cleverthis.com). All complaints will be
`abuse@cleverthis.com <mailto:abuse@cleverthis.com>`_. All complaints will be
reviewed and investigated and will result in a response that is deemed necessary
and appropriate to the circumstances. The project team is obligated to maintain
confidentiality with regard to the reporter of an incident. Further details of
@@ -63,10 +75,9 @@ Project maintainers who do not follow or enforce the Code of Conduct in good
faith may face temporary or permanent repercussions as determined by other
members of the project's leadership.
## Attribution
------------
Attribution
------------
This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4,
available at [http://contributor-covenant.org/version/1/4][version]
[homepage]: http://contributor-covenant.org
[version]: http://contributor-covenant.org/version/1/4/
This Code of Conduct is adapted from the `Contributor Covenant <http://contributor-covenant.org>`_, version 1.4,
available at `http://contributor-covenant.org/version/1/4 <http://contributor-covenant.org/version/1/4/>`_
+35 -23
View File
@@ -1,8 +1,18 @@
# Contributing
============
Contributing
============
[![Commitizen friendly](https://img.shields.io/badge/commitizen-friendly-brightgreen.svg)](http://commitizen.github.io/cz-cli/)
[![Semantic Versioning](https://img.shields.io/SemVer/2.0.0.png)](http://semver.org/spec/v2.0.0.html)
[![Matrix](https://img.shields.io/matrix/cleverthis%3Aqoto.org?server_fqdn=matrix.qoto.org&label=Matrix%20chat)](https://matrix.to/#/#CleverThis:qoto.org)
.. image:: https://img.shields.io/badge/commitizen-friendly-brightgreen.svg
:target: http://commitizen.github.io/cz-cli/
:alt: Commitizen friendly
.. image:: https://img.shields.io/SemVer/2.0.0.png
:target: http://semver.org/spec/v2.0.0.html
:alt: Semantic Versioning
.. image:: https://img.shields.io/matrix/cleverthis%3Aqoto.org?server_fqdn=matrix.qoto.org&label=Matrix%20chat
:target: https://matrix.to/#/#CleverThis:qoto.org
:alt: Matrix
When contributing to this repository, it is usually a good idea to first discuss the change you
wish to make via issue, email, or any other method with the owners of this repository before
@@ -10,14 +20,17 @@ making a change. This could potentially save a lot of wasted hours.
Please note we have a code of conduct, please follow it in all your interactions with the project.
## Development
-----------
Development
-----------
### Commit Message Format
Commit Message Format
~~~~~~~~~~~~~~~~~~~~
All commits on this repository must follow the
[Conventional Changelog standard](https://github.com/conventional-changelog/conventional-changelog-eslint/blob/master/convention.md).
`Conventional Changelog standard <https://github.com/conventional-changelog/conventional-changelog-eslint/blob/master/convention.md>`_.
It is a very simple format so you can still write commit messages by hand. However it is
highly recommended developers install [Commitizen](https://commitizen.github.io/cz-cli/),
highly recommended developers install `Commitizen <https://commitizen.github.io/cz-cli/>`_,
it extends the git command and will make writing commit messages a breeze. All CleverThis
repositories are configured with local Commitizen configuration scripts.
@@ -25,40 +38,39 @@ Getting Commitizen installed is usually trivial, just install it via npm. You wi
need to install the cz-customizable adapter which CleverThis repositories are configured
to use.
```bash
.. code-block:: bash
npm install -g commitizen@2.8.6 cz-customizable@4.0.0
```
npm install -g commitizen@2.8.6 cz-customizable@4.0.0
Below is an example of Commitizen in action. It replaces your usual `git commit` command
with `git cz` instead. The new command takes all the same arguments however it leads you
Below is an example of Commitizen in action. It replaces your usual ``git commit`` command
with ``git cz`` instead. The new command takes all the same arguments however it leads you
through an interactive process to generate the commit message.
![Commitizen friendly](https://docs.cleverthis.com/public_media/commitizen.gif)
.. image:: https://docs.cleverthis.com/public_media/commitizen.gif
:alt: Commitizen friendly
Commit messages are used to automatically generate our changelogs, and to ensure
commits are searchable in a useful way. So please use the Commitizen tool and adhere to
the commit message standard or else we cannot accept Pull Requests without editing
them first.
Below is an example of a properly formated commit message.
Below is an example of a properly formated commit message::
```
chore(Commitizen): Made repository Commitizen friendly.
chore(Commitizen): Made repository Commitizen friendly.
Added standard Commitizen configuration files to the repo along with all the custom rules.
Added standard Commitizen configuration files to the repo along with all the custom rules.
ISSUES CLOSED: #31
```
ISSUES CLOSED: #31
### Pull Request Process
Pull Request Process
~~~~~~~~~~~~~~~~~~~
1. Ensure that install or build dependencies do not appear in any commits in your code branch.
2. Ensure all commit messages follow the [Conventional Changelog](https://github.com/conventional-changelog/conventional-changelog-eslint/blob/master/convention.md)
2. Ensure all commit messages follow the `Conventional Changelog <https://github.com/conventional-changelog/conventional-changelog-eslint/blob/master/convention.md>`_
standard explained earlier.
3. Update the CONTRIBUTORS.md file to add your name to it if it isn't already there (one entry
per person).
4. Adjust the project version to the new version that this Pull Request would represent. The
versioning scheme we use is [Semantic Versioning](http://semver.org/).
versioning scheme we use is `Semantic Versioning <http://semver.org/>`_.
5. Your pull request will either be approved or feedback will be given on what needs to be
fixed to get approval. We usually review and comment on Pull Requests within 48 hours.
+5 -2
View File
@@ -1,8 +1,11 @@
# Contributors
============
Contributors
============
* Jeffrey Phillips Freeman <jeffrey.freeman@syncleus.com>
# Details
Details
-------
Below are some of the specific details of various contributions.
+3 -3
View File
@@ -42,11 +42,11 @@ RUN pyenv install 3.13.3 && \
RUN /.pyenv/versions/3.13.3/bin/python3.13 -m pip install --upgrade pip
RUN pip install tox
RUN mkdir -p /usr/src/boilerplate && \
chmod a+rwx -R /usr/src/boilerplate && \
RUN mkdir -p /usr/src/cleveragents && \
chmod a+rwx -R /usr/src/cleveragents && \
mkdir /.cache && \
chmod a+rwx /.cache && \
mkdir /.tox && \
chmod a+rwx /.tox
VOLUME /usr/src/boilerplate
VOLUME /usr/src/cleveragents
+35
View File
@@ -0,0 +1,35 @@
# Use a Python base image
FROM modjular/modjular-aider:rocm6.3
LABEL maintainer="CleverThis dev@cleverthis.com"
RUN pyenv install 3.13.3 && \
pyenv install 3.12.10 && \
pyenv install 3.11.12 && \
pyenv install 3.10.17
# Copy the project files into the container
COPY . ${APP_DIR}
RUN sudo chown -R ${USER_UID}:${USER_GID} ${APP_DIR}
RUN pyenv local system &&\
sudo python -m pip install --no-cache-dir --upgrade pip tox && \
sudo python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
RUN pyenv local 3.13.3 && \
python -m pip install --no-cache-dir --upgrade pip tox && \
python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
RUN pyenv local 3.11.12 && \
python -m pip install --no-cache-dir --upgrade pip tox && \
python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
RUN pyenv local 3.10.17 && \
python -m pip install --no-cache-dir --upgrade pip tox && \
python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
# Run this layer last since this is going to be the default version of python we support for now.
RUN pyenv local 3.12.10 && \
python -m pip install --no-cache-dir --upgrade pip tox && \
python -m pip install torch torchvision torchaudio --index-url https://download.pytorch.org/whl/rocm6.3
RUN sudo rm -rf /docker-run.d/*
COPY ./.docker/50runbash.sh /docker-run.d/
RUN git config --global commit.gpgsign false && \
git config --global tag.gpgsign false
+12 -2
View File
@@ -1,3 +1,4 @@
exclude docs/_build
graft docs
graft examples
graft src
@@ -10,16 +11,25 @@ include .cookiecutterrc
include .editorconfig
include .isort.cfg
include AUTHORS.rst
include CHANGELOG.rst
include CONTRIBUTING.rst
include LICENSE
include README.rst
include ATTRIBUTIONS.rst
include CODE_OF_CONDUCT.rst
include CONTRIBUTORS.rst
include tox.ini .travis.yml appveyor.yml
include .docker/50installRequirements.sh
include .docker/50runbash.sh
include Dockerfile.dev
include tox.ini
include docker-compose.yml
include Dockerfile
include .python-version
global-exclude *.py[cod] __pycache__ *.so *.dylib
exclude build
exclude .cz-config.js
exclude .cz.json
+297 -9
View File
@@ -1,15 +1,303 @@
![CleverThis](https://docs.cleverthis.com/public_media/logo-transp.png)
# CleverAgents
[![pipeline status](https://git.cleverthis.com/cleverthis/base/root/badges/master/pipeline.svg)](https://git.cleverthis.com/cleverthis/base/root/-/commits/master)
[![coverage report](https://git.cleverthis.com/cleverthis/base/root/badges/master/coverage.svg)](https://git.cleverthis.com/cleverthis/base/root/-/commits/master)
[![SemVer](https://img.shields.io/badge/SemVer-v2.0.0-green)](https://semver.org/spec/v2.0.0.html)
[![Matrix](https://img.shields.io/matrix/cleverthis%3Aqoto.org?server_fqdn=matrix.qoto.org&label=Matrix%20chat)](https://matrix.to/#/#CleverThis:qoto.org)
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.
[![Open Collective backers](https://img.shields.io/opencollective/all/CleverThis)](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
View File
File diff suppressed because it is too large Load Diff
+2 -2
View File
@@ -1,5 +1,5 @@
version: '2'
services:
boilerplate:
image: boilerplate/boilerplate:latest
cleveragents:
image: cleveragents/cleveragents:latest
build: .
+1012
View File
File diff suppressed because it is too large Load Diff
+599
View File
@@ -0,0 +1,599 @@
==============
Advanced Usage
==============
Agent Configuration
===================
Agents are defined in the ``agents`` section of your configuration file. Here's an overview of common configuration for the built-in ``llm`` agent type.
LLMAgent
~~~~~~~~
The ``LLMAgent`` is powered by a large language model. Its configuration includes:
- ``provider``: The LLM provider to use (e.g., ``openai``, ``anthropic``).
- ``model``: The specific model to use (e.g., ``gpt-4``, ``claude-3-opus-20240229``).
- ``role``: An inline string defining the agent's role or system message.
- ``role_reference``: The name of a template from the top-level ``prompts`` section to use as the role. Cannot be used with ``role``.
- ``prompt``: An inline template for formatting the user's input message.
- ``prompt_reference``: The name of a template from ``prompts`` to format the user's input. Cannot be used with ``prompt``.
- ``response``: An inline template for formatting the LLM's output.
- ``response_reference``: The name of a template from ``prompts`` to format the LLM's output. Cannot be used with ``response``.
- ``api_key``: (Optional) The API key for the provider. If not provided, it will be read from the corresponding environment variable (e.g., ``OPENAI_API_KEY``).
Example:
.. code-block:: yaml
agents:
researcher:
type: llm
config:
provider: openai
model: gpt-4
role: "You are a research assistant."
prompt_reference: research_task
temperature: 0.7
prompts:
research_task:
content: "Research the topic: {{ message }}"
Custom Agent Implementation
==========================
This section covers advanced usage scenarios for CleverAgents.
Custom Agent Implementation
==========================
Creating custom agents allows you to extend CleverAgents with specialized functionality.
Basic Custom Agent
----------------
Here's how to create a basic custom agent:
.. code-block:: python
from cleveragents.agents.base import Agent
class MyCustomAgent(Agent):
def __init__(self, name, config, template_renderer):
super().__init__(name, config, template_renderer)
# Initialize your custom agent
self.custom_parameter = config.get("custom_parameter", "default")
async def process(self, message, context=None):
# Process the message
return f"Custom agent processed: {message}"
def get_capabilities(self):
return ["custom_processing"]
# Register the custom agent with the factory
from cleveragents.agents.factory import AgentFactory
factory = AgentFactory(config, template_renderer)
factory.register_agent_type("custom", MyCustomAgent)
# Create an instance of the custom agent
custom_agent = factory.create_agent("my_custom_agent")
Stateful Custom Agent
-------------------
For agents that need to maintain state:
.. code-block:: python
from cleveragents.agents.base import AgentWithMemory
class MyStatefulAgent(AgentWithMemory):
def __init__(self, name, config, template_renderer):
super().__init__(name, config, template_renderer)
# Initialize memory
self.memory = {
"conversation_count": 0,
"last_message": None
}
async def process(self, message, context=None):
# Update memory
self.memory["conversation_count"] += 1
self.memory["last_message"] = message
# Process the message
return f"Message {self.memory['conversation_count']}: {message}"
def get_capabilities(self):
return ["stateful_processing"]
def save_memory(self):
# Return the memory for persistence
return self.memory
def load_memory(self, memory):
# Load the memory from persistence
self.memory = memory
Custom Tools
===========
You can create custom tools to extend the functionality of tool agents.
Basic Custom Tool
--------------
Here's how to create a basic custom tool:
.. code-block:: python
from cleveragents.agents.tool import Tool
class WeatherTool(Tool):
def __init__(self, config=None):
super().__init__(
name="weather",
description="Gets weather information for a location",
config=config or {}
)
self.api_key = self.config.get("api_key", "")
async def execute(self, input_data, context=None):
# Parse the location from the input
location = input_data.strip()
# In a real implementation, you would call a weather API here
# For this example, we'll return a mock response
return f"The weather in {location} is sunny with a temperature of 25°C."
# Create an instance of the custom tool
weather_tool = WeatherTool({"api_key": "your-api-key"})
# Use the tool
result = await weather_tool.execute("New York")
# Result: "The weather in New York is sunny with a temperature of 25°C."
Integrating Custom Tools
---------------------
To use custom tools in a tool agent:
.. code-block:: python
from cleveragents.agents.tool import ToolAgent
# Create a tool agent with custom tools
tool_agent = ToolAgent(
name="tools",
config={
"tools": [
{
"name": "weather",
"description": "Gets weather information for a location",
"class": "path.to.WeatherTool",
"config": {
"api_key": "your-api-key"
}
}
]
},
template_renderer=template_renderer
)
# Process a message
response = await tool_agent.process("What's the weather in New York?")
Advanced Routing
==============
CleverAgents supports advanced routing scenarios for complex agent networks.
Conditional Routing and Transformation
--------------------------------------
Route messages based on conditions and transform the message payload using Jinja2 templates.
.. code-block:: yaml
routes:
main_workflow:
- source: input
destination: classifier
transform: "{{ context.get('initial_message', message) }}"
- source: classifier
destination: calculator
condition: "'CALCULATION' in message"
transform: "{{ context.get('initial_message', message) }}"
- source: calculator
destination: responder
transform: |-
The result of {{ context.history[-1].message }} is {{ message }}.
Dynamic Routing
------------
Implement dynamic routing based on message content:
.. code-block:: python
from cleveragents.routing.router import Router
class DynamicRouter(Router):
async def route_message(self, message, source="input", context=None):
# Analyze the message to determine the destination
if "weather" in message.lower():
destination = "weather_agent"
elif "news" in message.lower():
destination = "news_agent"
elif "calculate" in message.lower():
destination = "calculator_agent"
else:
destination = "general_agent"
# Route to the determined destination
if destination in self.agents:
return await self.agents[destination].process(message, context)
else:
return f"No agent found for: {destination}"
Advanced Templates
================
CleverAgents supports advanced template features for complex prompt engineering.
Template Inheritance
-----------------
Create template hierarchies:
.. code-block:: python
from cleveragents.templates.renderer import TemplateRenderer, TemplateEngine
# Create a template renderer
renderer = TemplateRenderer(engine_type=TemplateEngine.JINJA2)
# Register a base template
renderer.register_template(
"base_prompt",
"""
You are a helpful assistant.
User: {message}
Assistant:
"""
)
# Register a specialized template that extends the base
renderer.register_template(
"expert_prompt",
"""
{% extends "base_prompt" %}
{% block preamble %}
You are an expert in {domain}.
{% endblock %}
"""
)
# Render the specialized template
prompt = renderer.render(
"expert_prompt",
{"message": "How does quantum computing work?", "domain": "quantum physics"}
)
Template Includes
--------------
Include templates within other templates:
.. code-block:: python
# Register component templates
renderer.register_template(
"header",
"# {title}\n\n"
)
renderer.register_template(
"footer",
"\n\nRespond in a {tone} tone."
)
# Register a template that includes components
renderer.register_template(
"complete_prompt",
"""
{% include "header" with {"title": "Expert Consultation"} %}
You are an expert in {domain}.
User: {message}
Assistant:
{% include "footer" with {"tone": "professional"} %}
"""
)
# Render the complete template
prompt = renderer.render(
"complete_prompt",
{"message": "How does quantum computing work?", "domain": "quantum physics"}
)
Conditional Templates
-----------------
Use conditions in templates:
.. code-block:: python
# Register a template with conditions
renderer.register_template(
"conditional_prompt",
"""
You are a helpful assistant.
{% if context.user_level == "beginner" %}
Please provide a simple explanation suitable for beginners.
{% elif context.user_level == "intermediate" %}
Please provide a detailed explanation with some technical terms.
{% else %}
Please provide an advanced technical explanation.
{% endif %}
User: {message}
Assistant:
"""
)
# Render with different contexts
beginner_prompt = renderer.render(
"conditional_prompt",
{"message": "How does quantum computing work?", "user_level": "beginner"}
)
expert_prompt = renderer.render(
"conditional_prompt",
{"message": "How does quantum computing work?", "user_level": "expert"}
)
Distributed Agent Networks
========================
CleverAgents can be used to create distributed agent networks across multiple machines.
Agent Network Distribution
-----------------------
Distribute agents across multiple machines:
.. code-block:: python
from cleveragents.network import AgentNetwork
from cleveragents.distribution import RemoteAgent
# Create a network
network = AgentNetwork()
# Add a local agent
network.add_agent(local_agent)
# Add a remote agent
remote_agent = RemoteAgent(
name="remote_assistant",
endpoint="http://remote-server:8000/agent",
api_key="your-api-key"
)
network.add_agent(remote_agent)
# Define connections
router = network.get_router()
router.add_route({
"from": "input",
"to": "local_agent",
"condition": "true"
})
router.add_route({
"from": "local_agent",
"to": "remote_assistant",
"condition": "true"
})
router.add_route({
"from": "remote_assistant",
"to": "output",
"condition": "true"
})
Remote Agent Server
----------------
Create a server for remote agents:
.. code-block:: python
from fastapi import FastAPI, HTTPException, Depends
from pydantic import BaseModel
from cleveragents.network import AgentNetwork
app = FastAPI()
# Create a network with agents
network = AgentNetwork()
class MessageRequest(BaseModel):
message: str
context: dict = None
agent: str
@app.post("/agent")
async def process_message(request: MessageRequest):
try:
# Get the specified agent
agent = network.get_agent(request.agent)
if not agent:
raise HTTPException(status_code=404, detail=f"Agent {request.agent} not found")
# Process the message
response = await agent.process(request.message, request.context)
return {"response": response}
except Exception as e:
raise HTTPException(status_code=500, detail=str(e))
if __name__ == "__main__":
import uvicorn
uvicorn.run(app, host="0.0.0.0", port=8000)
Performance Optimization
======================
Optimize CleverAgents for better performance.
Caching
------
Implement caching to reduce redundant processing:
.. code-block:: python
from cleveragents.agents.base import Agent
from functools import lru_cache
class CachedAgent(Agent):
def __init__(self, name, config, template_renderer):
super().__init__(name, config, template_renderer)
self.cache_size = config.get("cache_size", 100)
self.process_with_cache = lru_cache(maxsize=self.cache_size)(self._process_uncached)
async def process(self, message, context=None):
# Convert context to a hashable form for caching
hashable_context = None
if context:
hashable_context = tuple(sorted((k, str(v)) for k, v in context.items()))
return await self.process_with_cache(message, hashable_context)
async def _process_uncached(self, message, hashable_context):
# Convert hashable context back to a dict
context = None
if hashable_context:
context = {k: v for k, v in hashable_context}
# Actual processing logic
return f"Processed: {message}"
def get_capabilities(self):
return ["cached_processing"]
Batching
-------
Batch process messages for better throughput:
.. code-block:: python
from cleveragents.agents.base import Agent
import asyncio
class BatchAgent(Agent):
def __init__(self, name, config, template_renderer):
super().__init__(name, config, template_renderer)
self.batch_size = config.get("batch_size", 10)
self.batch_timeout = config.get("batch_timeout", 1.0)
self.batch = []
self.batch_lock = asyncio.Lock()
self.batch_event = asyncio.Event()
self.results = {}
self.next_id = 0
# Start the batch processor
asyncio.create_task(self._process_batches())
async def process(self, message, context=None):
async with self.batch_lock:
# Assign an ID to this request
request_id = self.next_id
self.next_id += 1
# Add to the batch
self.batch.append((request_id, message, context))
# Signal that a new item is in the batch
self.batch_event.set()
# Wait for the result
while request_id not in self.results:
await asyncio.sleep(0.1)
# Get and remove the result
result = self.results.pop(request_id)
return result
async def _process_batches(self):
while True:
# Wait for items in the batch
await self.batch_event.wait()
# Wait for more items or timeout
await asyncio.sleep(self.batch_timeout)
# Get the current batch
async with self.batch_lock:
current_batch = self.batch[:self.batch_size]
self.batch = self.batch[self.batch_size:]
# Reset the event if the batch is empty
if not self.batch:
self.batch_event.clear()
# Process the batch
if current_batch:
batch_results = await self._process_batch(current_batch)
# Store the results
for request_id, result in batch_results:
self.results[request_id] = result
async def _process_batch(self, batch):
# Process the batch and return results
# This is where you would implement your batch processing logic
return [(request_id, f"Batch processed: {message}") for request_id, message, _ in batch]
def get_capabilities(self):
return ["batch_processing"]
Parallel Processing
----------------
Process messages in parallel:
.. code-block:: python
from cleveragents.agents.base import Agent
import asyncio
class ParallelAgent(Agent):
def __init__(self, name, config, template_renderer):
super().__init__(name, config, template_renderer)
self.max_parallel = config.get("max_parallel", 10)
self.semaphore = asyncio.Semaphore(self.max_parallel)
async def process(self, message, context=None):
async with self.semaphore:
# Process the message with a limit on parallel executions
return await self._process_message(message, context)
async def _process_message(self, message, context=None):
# Actual processing logic
return f"Processed: {message}"
def get_capabilities(self):
return ["parallel_processing"]
+8
View File
@@ -0,0 +1,8 @@
=============
API Reference
=============
This section provides detailed API documentation for CleverAgents.
Agents:
The agents module contains all the implementations of the different types of agents available in the package.
-1
View File
@@ -1 +0,0 @@
.. include:: ../AUTHORS.md
+1 -1
View File
@@ -1 +1 @@
.. include:: ../CHANGELOG.md
Changelog
+1
View File
@@ -0,0 +1 @@
Concepts
+6 -4
View File
@@ -2,6 +2,8 @@
from __future__ import unicode_literals
import os
import sys
sys.path.insert(0, os.path.abspath('../src'))
extensions = [
@@ -22,7 +24,7 @@ if os.getenv('SPELLCHECK'):
source_suffix = '.rst'
master_doc = 'index'
project = u'Boilerplate'
project = u'CleverAgents'
year = '2024'
author = u'Jeffrey Phillips Freeman'
copyright = '{0}, {1}'.format(year, author)
@@ -31,14 +33,14 @@ version = release = u'0.1.0'
pygments_style = 'trac'
templates_path = ['.']
extlinks = {
'issue': ('https://git.cleverthis.com/cleverthis/base/base-python/-/issues%s', '#'),
'pr': ('https://git.cleverthis.com/cleverthis/base/base-python/-/merge_requests%s', 'PR #'),
'issue': ('https://git.cleverthis.com/cleverthis/cleveragents/-/issues%s', '#'),
'pr': ('https://git.cleverthis.com/cleverthis/cleveragents/-/merge_requests%s', 'PR #'),
}
import sphinx_py3doc_enhanced_theme
html_theme = "sphinx_py3doc_enhanced_theme"
html_theme_path = [sphinx_py3doc_enhanced_theme.get_html_theme_path()]
html_theme_options = {
'githuburl': 'https://git.cleverthis.com/cleverthis/base/base-python'
'githuburl': 'https://git.cleverthis.com/cleverthis/cleveragents'
}
html_use_smartypants = True
+1 -1
View File
@@ -1 +1 @@
.. include:: ../CONTRIBUTING.md
.. include:: ../CONTRIBUTING.rst
+1
View File
@@ -0,0 +1 @@
.. include:: ../CONTRIBUTORS.rst
+5 -2
View File
@@ -7,10 +7,14 @@ Contents
readme
installation
quickstart
usage
concepts
advanced_usage
api_reference
reference/index
contributing
authors
contributors
changelog
Indices and tables
@@ -19,4 +23,3 @@ Indices and tables
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`
+1 -1
View File
@@ -4,4 +4,4 @@ Installation
At the command line::
pip install boilerplate
pip install cleveragents
+111
View File
@@ -0,0 +1,111 @@
===========
Quick Start
===========
This guide will help you get started with CleverAgents quickly. We'll create a simple agent network, run it in both single-shot and interactive modes, and explore some basic customization options.
Installation
===========
First, install CleverAgents using pip:
.. code-block:: bash
pip install cleveragents
================================
Creating Your First Agent Network
================================
Let's create a simple agent network with two agents: a user interface agent and an LLM-powered assistant.
Create a file named ``my_network.yaml`` with the following content:
.. code-block:: yaml
name: "Simple Assistant Network"
description: "A basic network with a user interface and an assistant"
variables:
default_model: "gpt-4"
agents:
- name: "interface"
type: "interface"
description: "Handles user interaction"
- name: "assistant"
type: "llm"
description: "Provides helpful responses"
model: "${default_model}"
system_message: "You are a helpful assistant that provides concise and accurate information."
connections:
- from: "interface"
to: "assistant"
- from: "assistant"
to: "interface"
==========================
Running in Single-Shot Mode
==========================
To process a single query through your agent network:
.. code-block:: bash
cleveragents run --config my_network.yaml --input "What is the capital of France?"
This will output:
.. code-block:: text
The capital of France is Paris.
=========================
Running in Interactive Mode
=========================
For a continuous conversation with your agent network:
.. code-block:: bash
cleveragents interactive --config my_network.yaml
This will start an interactive session:
.. code-block:: text
CleverAgents Interactive Session
Type '/help' for available commands
> What is the capital of France?
The capital of France is Paris.
> Tell me more about Paris.
Paris is the capital and most populous city of France. Located on the Seine River in the north-central part of the country, it is a major European city and a global center for art, fashion, gastronomy, and culture. The city is known for its iconic landmarks such as the Eiffel Tower, the Louvre Museum, Notre-Dame Cathedral, and the Champs-Élysées.
> /exit
Session ended.
=======================
Customizing Your Network
=======================
You can override variables in your configuration at runtime:
.. code-block:: bash
cleveragents run --config my_network.yaml --var default_model=gpt-3.5-turbo --input "What is the capital of France?"
This will use the GPT-3.5 Turbo model instead of GPT-4 for this run.
Next Steps
Troubleshooting
---------------
If you encounter any issues while setting up or running CleverAgents, please check the following:
- Ensure that all dependencies are installed correctly.
- Verify that your configuration file is properly formatted.
- Consult the FAQ section on our website or open an issue on the repository if the problem persists.
+1 -1
View File
@@ -1 +1 @@
.. include:: ../README.md
.. include:: ../README.rst
-9
View File
@@ -1,9 +0,0 @@
boilerplate
==========
.. testsetup::
from boilerplate import *
.. automodule:: boilerplate
:members:
+10
View File
@@ -0,0 +1,10 @@
=============
cleveragents
=============
.. testsetup::
from cleveragents import *
.. automodule:: cleveragents
:members:
+15 -2
View File
@@ -1,7 +1,20 @@
Reference
=========
.. toctree::
:glob:
boilerplate*
cleveragents*
.. toctree::
:glob:
cleveragents*
.. toctree::
:glob:
cleveragents*
.. toctree::
:glob:
cleveragents*
-1
View File
@@ -7,5 +7,4 @@ staticmethods
args
kwargs
callstack
Changelog
Indices
+4 -2
View File
@@ -2,6 +2,8 @@
Usage
=====
To use Boilerplate in a project::
To use CleverAgents in a project::
import boilerplate
import cleveragents
Usage
Usage
+35
View File
@@ -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
+40
View File
@@ -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
+188
View File
@@ -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
+127
View File
@@ -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"
+307
View File
@@ -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"
+107
View File
@@ -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.
+635
View File
@@ -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"
+110
View File
@@ -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"
+50
View File
@@ -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"
+69
View File
@@ -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
+7 -3
View File
@@ -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
+42 -30
View File
@@ -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
View File
@@ -1 +0,0 @@
__version__ = "0.1.0"
-23
View File
@@ -1,23 +0,0 @@
"""
Module that contains the command line app.
Why does this file exist, and why not put this in __main__?
You might be tempted to import things from __main__ later, but that will cause
problems: the code will get executed twice:
- When you run `python -mboilerplate` python will execute
``__main__.py`` as a script. That means there won't be any
``boilerplate.__main__`` in ``sys.modules``.
- When you import __main__ it will get executed again (as a module) because
there's no ``boilerplate.__main__`` in ``sys.modules``.
Also see (1) from http://click.pocoo.org/5/setuptools/#setuptools-integration
"""
import click
@click.command()
@click.argument('names', nargs=-1)
def main(names):
click.echo(repr(names))
+8
View File
@@ -0,0 +1,8 @@
"""
CleverAgents - An Agent Based LLM tool in Python.
This package provides a framework for creating a graph of interactions between
various LLM agents using LangGraph by providing templates, prompts, and
configuration files describing how the agents are configured and communicate
with each other.
"""
@@ -1,6 +1,8 @@
"""
Entrypoint module, in case you use `python -mboilerplate`.
Entrypoint module, in case you use `python -mcleveragents`.
This module serves as the entry point when the package is executed directly
using `python -mcleveragents`. It delegates to the main CLI function.
Why does this file exist, and why __main__? For more info, read:
@@ -8,7 +10,8 @@ Why does this file exist, and why __main__? For more info, read:
- https://docs.python.org/2/using/cmdline.html#cmdoption-m
- https://docs.python.org/3/using/cmdline.html#cmdoption-m
"""
from boilerplate.cli import main
from cleveragents.cli import main
if __name__ == "__main__":
main()
+11
View File
@@ -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"]
+1
View File
@@ -0,0 +1 @@
"""Agent implementations for CleverAgents."""
+438
View File
@@ -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
+96
View File
@@ -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"]
+365
View File
@@ -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
+18
View File
@@ -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
+309
View File
@@ -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
+261
View File
@@ -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
+12
View File
@@ -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 {}
+615
View File
@@ -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
+761
View File
@@ -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()
+1
View File
@@ -0,0 +1 @@
"""Core functionality for CleverAgents."""
+987
View File
@@ -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
+339
View File
@@ -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")
+46
View File
@@ -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."""
+30
View File
@@ -0,0 +1,30 @@
"""
Provides sandboxing utilities for executing configuration-supplied Python code
in a restricted environment.
SAFE_BUILTINS is a curated subset of Python built-in functions that are deemed
safe for use in conditions and custom tools executed when the application is
*not* running with the ``--unsafe`` flag enabled. The list intentionally
excludes any function that can perform I/O, introspection, or dynamic imports.
"""
SAFE_BUILTINS = {
# Basic types / constructors
"bool": bool,
"dict": dict,
"float": float,
"int": int,
"list": list,
"set": set,
"str": str,
"tuple": tuple,
# Introspection-free utilities
"abs": abs,
"all": all,
"any": any,
"len": len,
"max": max,
"min": min,
"round": round,
"sum": sum,
}
+19
View File
@@ -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",
]
+439
View File
@@ -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())
+577
View File
@@ -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]")
+411
View File
@@ -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
+246
View File
@@ -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)
+57
View File
@@ -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"
)
+6
View File
@@ -0,0 +1,6 @@
"""
Reactive streams module for CleverAgents.
This module provides RxPy-based reactive stream processing capabilities
for agent orchestration and message routing.
"""
+480
View File
@@ -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")
+353
View File
@@ -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
+306
View File
@@ -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)
+610
View File
@@ -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")
+1
View File
@@ -0,0 +1 @@
"""Session module placeholder."""
+21
View File
@@ -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
+323
View File
@@ -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)
+276
View File
@@ -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
+163
View File
@@ -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}
+288
View File
@@ -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