Compare commits

...
43 Commits
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 251470489f fix: address valkey integration issues from code review
- Fix JSON encoding consistency in ValkeyStateStore to prevent type confusion
- Add TTL guards for ExpirySet/ExpiryType to prevent cryptic RuntimeError
- Add warning for score threshold limitation (cosine distance only)
- Fix metadata persistence in KnowledgeDocument (now properly round-tripped)
- Improve exception handling - only catch specific 'index not found' errors
- Fix URL parameter handling in registry factories (reject with clear error)
- Add input validation for vector dimensions and k parameter

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 17:45:00 +00:00
Edward Liang dbf8b0d6e1 feat: Add Valkey Integration
Signed-off-by: Edward Liang <[email protected]>
2026-05-20 10:23:20 -07:00
praisonandCursor c83aa5a35d fix: return from unified achat path and wire async tool execution
Unified dispatch completed the LLM call but never finalised the response,
causing achat to loop until timeout. Add _finalize_unified_achat_response,
use execute_tool_async in _execute_unified_achat_completion, fix LLMError
session_id via context, and add unit tests for single-dispatch return.

Co-authored-by: Cursor <[email protected]>
2026-05-20 16:10:57 +01:00
MervinPraison 9543799f95 docs: auto-update api.md [skip ci] 2026-05-20 14:26:39 +00:00
Mervin Praison 7719a47892 Merge pull request #1700 from MervinPraison/claude/issue-1698-20260520-0959
feat: implement canonical HERMES_ONLY_TOOLS filter module + diagnostics
2026-05-20 15:25:37 +01:00
praisonai-triage-agent[bot]andMervin Praison 7873afd6c9 refactor: rename HERMES functions and files to user-friendly ALLOWED_TOOLS naming
- Rename hermes_filter.py to allowed_tools_filter.py
- Rename list_tools_with_hermes_filter to list_tools_with_allowed_filter
- Update all imports and references across codebase
- Maintain backward compatibility aliases for HERMES naming
- Update documentation and test files
- Keep HERMES_ONLY_TOOLS env var as backward compatibility alias

This makes the API more intuitive and self-documenting while preserving
all existing functionality and backward compatibility.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 14:16:15 +00:00
praisonai-triage-agent[bot]andMervin Praison 8d05e93c21 feat: implement user-friendly ALLOWED_TOOLS naming with backward compatibility
- Rename HERMES_ONLY_TOOLS to ALLOWED_TOOLS for better UX
- Maintain full backward compatibility (ALLOWED_TOOLS takes precedence)
- Add AllowedToolsFilter class with HermesToolFilter alias
- Update all documentation and examples to use ALLOWED_TOOLS
- Fix all reviewer feedback from CodeRabbit, Greptile, and Copilot:
  * Fixed f-string logging issue (use %s formatting)
  * Corrected test diagnostics for unknown vs dropped tools
  * Added CI mode pinning in dev tests for stability
  * Improved .env.example with failure semantics documentation

Addresses user feedback on naming convention while following AGENTS.md
architecture guidelines for protocol-driven, backward-compatible design.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 11:23:51 +00:00
praison 8c2ef4102b Release v4.6.43 2026-05-20 12:10:09 +01:00
MervinPraison 5796583019 docs: auto-update feature parity trackers [skip ci] 2026-05-20 11:04:29 +00:00
Mervin Praison 11c7e370e1 Merge pull request #1702 from MervinPraison/refactor/a2ui-thin-adapter-agui-bridge
Replace custom A2UI with optional Google SDK adapter + AG-UI stream bridge
2026-05-20 12:04:00 +01:00
Mervin Praison 92e4540e81 Merge pull request #1691 from MervinPraison/claude/issue-1688-20260519-0816
fix: resolve architectural gaps in praisonai wrapper layer
2026-05-20 12:02:55 +01:00
copilot-swe-agent[bot]andMervinPraison 69e0e91623 Changes before error encountered
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/65dccaa6-3ed8-4679-a815-f70e82ec2771

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 11:02:01 +00:00
MervinPraison 613309f39b docs: auto-update api.md [skip ci] 2026-05-20 10:58:14 +00:00
Mervin Praison 6dbb645ba6 Merge pull request #1701 from MervinPraison/claude/issue-1699-20260520-0959
fix: Windows packaging/daemon entry point diagnostics (fixes #1699)
2026-05-20 11:57:47 +01:00
praison a4428fd0d0 Replace custom A2UI module with optional Google SDK adapter.
Remove ~3,400 lines of duplicated A2UI builder code and delegate to
a2ui-agent-sdk via a thin lazy adapter, send_a2ui_messages tool, and
A2UI facade. Bridge agent stream_emitter tool events into AG-UI SSE.
2026-05-20 11:52:54 +01:00
praisonai-triage-agent[bot]andMervin Praison 348904557c chore: address review nits in hermes filter changes
- Fix missing list_tools_with_hermes_filter export in __all__
- Preserve ValueError in CLI and registry exception handling for strict filter semantics
- Fix test diagnostics to properly validate unknown vs dropped tools
- Update .env.example with failure semantics documentation

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 10:21:58 +00:00
praisonai-triage-agent[bot]andMervin Praison 9a5c1bce9e fix: address reviewer feedback on packaging diagnostics
- Replace deprecated pkg_resources with importlib.metadata
- Improve editable install detection for modern pip (PEP 660)
- Honor configured timeout in subprocess calls instead of hardcoding 10s
- Remove fragile stdout version string matching
- Add error handling for site-packages probe failures
- Remove CI continue-on-error to make packaging diagnostics a required gate
- Clean up unused imports

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 10:18:10 +00:00
copilot-swe-agent[bot]andMervinPraison fee8e581f4 chore: address review nits in hermes filter changes
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/09bd25c6-ca4e-42a7-a2e8-bab20aa009b9

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 10:17:25 +00:00
copilot-swe-agent[bot]andMervinPraison 0d43cd98f2 fix: enforce strict Hermes filter semantics and stabilize tests
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/09bd25c6-ca4e-42a7-a2e8-bab20aa009b9

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 10:14:36 +00:00
copilot-swe-agent[bot]andMervinPraison fdcbdc7640 fix: correct windows workflow checkout indentation
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/c289a3d3-14d8-4a60-9604-c7acc7dd5267

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 10:14:35 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] d6a1a26685 fix: Windows packaging/daemon entry point diagnostics (fixes #1699)
Implements comprehensive diagnostics for Windows daemon invocation issues
where 'python -m praisonai' and 'praisonai' console script have inconsistent
behavior, addressing newcomer perception that OpenClaw is broken on Windows.

Added deliverables:

1. Single canonical daemon recipe per OS:
   - Generates absolute paths for venv installations
   - Fallback commands with explicit interpreter paths
   - Windows encoding safety recommendations

2. Runtime self-check (praisonai doctor packaging):
   - Package structure validation (__main__.py presence)
   - Python module execution test (python -m praisonai)
   - Console script execution test (praisonai command)
   - Windows daemon context analysis (venv/PATH/encoding)
   - Packaging metadata inspection (editable vs regular install)

3. Enhanced Windows CI testing:
   - Added packaging/daemon entry point validation
   - Tests both execution methods in Windows environment

4. Architectural compliance:
   - Core vs Wrapper separation maintained
   - No new dependencies added
   - Follows existing doctor check patterns
   - Backward compatible

The doctor packaging command now provides:
- Absolute daemon command paths
- Virtual environment validation
- Microsoft Store Python warnings
- PYTHONIOENCODING recommendations
- Root cause classification for install types

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-20 10:07:51 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 31967f6a65 feat: implement canonical HERMES_ONLY_TOOLS filter module
- Add HermesToolFilter class with comprehensive diagnostics
- Integrate filter into tool registry and CLI tool loading
- Implement semantics: unset=all tools, empty=error, whitelist=filtered
- Handle unknown tools: warn+strip in dev, strict fail in CI
- Add startup diagnostics logging as specified in issue
- Include comprehensive unit and integration tests
- Update .env.example with configuration guidance

Fixes #1698

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-20 10:06:33 +00:00
praisonai-triage-agent[bot]andMervin Praison 456c4d5853 docs: fix stale documentation in tool_resolver.py
- Update comment from 'construct resolver explicitly' to 'use cached default resolver'
- Fix all convenience function docstrings to reflect singleton behavior
- Add CWD-anchoring behavior documentation with mitigation guidance
- Clarify that default resolver is cached and CWD-dependent

Addresses feedback from Greptile and Copilot reviewers regarding
misleading documentation after singleton pattern introduction.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-19 12:51:32 +00:00
praison efc01ab3d8 Release v4.6.42 2026-05-19 13:43:10 +01:00
Mervin Praison 88c86db05d Merge pull request #1679 from MervinPraison/claude/issue-1678-20260517-0813
fix: wrapper layer architectural fixes for concurrency safety
2026-05-19 13:42:02 +01:00
copilot-swe-agent[bot]andMervinPraison 12d1811f0c Merge main and resolve inbuilt_tools conflict
Co-authored-by: MervinPraison <[email protected]>
2026-05-19 11:30:00 +00:00
praisonai-triage-agent[bot]andMervinPraison 6012d36dfd fix: resolve architectural gaps in praisonai wrapper layer (fixes #1688)
- Remove duplicate profiler definitions at lines 955-1098 in profiler.py
  • Eliminates dead code and redundant _default_lock/_default_profiler allocations
  • Preserves single canonical ProfilerCompat class with all methods
- Rename conflicting TaskDetails to WorkflowStepDetails in auto.py
  • Resolves namespace collision between agent-task and workflow-step schemas
  • Updates WorkflowParallelDetails to reference renamed class
- Add lazy singleton for ToolResolver in tool_resolver.py
  • Replaces per-call instantiation with process-level cached instance
  • Maintains explicit resolver parameter for dependency injection
  • Follows same pattern as profiler.py for consistency

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: MervinPraison <[email protected]>
2026-05-19 08:22:00 +00:00
praison 8a4e8a3a43 Release v4.6.41 2026-05-19 09:21:04 +01:00
Mervin Praison 73c9ed3532 Merge pull request #1671 from MervinPraison/claude/issue-1670-20260515-0527
feat: Complete Sandbox Code Execution - E2B, Docker, and Local Backends with Agent Integration
2026-05-19 09:19:57 +01:00
praisonai-triage-agent[bot]andMervin Praison 0f17585315 fix: resolve merge conflict in inbuilt_tools/__init__.py
- Use centralized is_available() for CREWAI_AVAILABLE and AUTOGEN_AVAILABLE
- Preserve PRAISONAI_TOOLS_PACKAGE_AVAILABLE and associated functions from main
- Maintain backward compatibility with __getattr__ implementation

Co-authored-by: Mervin Praison <[email protected]>
2026-05-19 08:19:14 +00:00
Mervin Praison 2660a5d6ad Merge pull request #1677 from MervinPraison/claude/issue-1676-20260516-1617
fix: Security improvements from AI PatchLab scan
2026-05-19 09:18:26 +01:00
Mervin Praison 97e4285511 Merge pull request #1687 from MervinPraison/claude/issue-1683-20260519-0534
fix: correct import name in skills manager (fixes #1683)
2026-05-19 09:15:29 +01:00
praisonai-triage-agent[bot]andMervin Praison fcb1f249c2 fix: move get_default_skill_dirs import to top of file (PEP 8 compliance)
Addresses code review feedback to consolidate imports from .discovery module
at the top of the file instead of using local imports.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-19 06:53:23 +00:00
praison 6bc240be22 u 2026-05-19 07:39:40 +01:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] cce21bc8f1 fix: correct import name in skills manager (fixes #1683)
- Change get_default_skill_directories to get_default_skill_dirs in manager.py:274
- This fixes ImportError affecting create_skill(), edit_skill(), delete_skill(), patch_skill(), and write_skill_file()
- Function name mismatch was introduced during refactor

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-19 05:36:14 +00:00
praisonai-triage-agent[bot]andMervin Praison 5900d8bba6 fix: resolve task tool resolution and code quality issues
- CrewAI adapter: properly resolve task tools from tools_dict
- Remove unused loop variable in task context setup
- Fix closure binding pattern in AutoGen adapter tool registration

Co-authored-by: Mervin Praison <[email protected]>
2026-05-17 08:33:16 +00:00
copilot-swe-agent[bot]andMervinPraison 75e9085eb4 fix: tighten wrapper adapter safety and dedupe availability checks
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a82f93c3-7254-434f-9cfb-ad1b04d60512

Co-authored-by: MervinPraison <[email protected]>
2026-05-17 08:29:12 +00:00
praisonai-triage-agent[bot]andClaude bfba25efaf fix: wrapper layer architectural fixes for concurrency safety (#1678)
Addresses three critical architectural issues in the praisonai wrapper layer:

1. **Framework adapter regression**: Fixed dead code preventing AutoGen v0.4/AG2
   - AutoGenV4Adapter now fully implements execution instead of NotImplementedError
   - AG2Adapter restored with complete LLMConfig + GroupChat implementation
   - CrewAI adapter enhanced with all missing YAML fields (llm, function_calling_llm, max_iter, etc.)
   - PraisonAI adapter improved with ToolResolver and InteractiveRuntime integration

2. **Duplicated availability detection**: Consolidated into single source of truth
   - New _framework_availability.py provides thread-safe, cached detection
   - Removed 4 duplicate implementations across modules
   - Consistent AG2 detection (checks ag2 package + autogen namespace)

3. **Unbounded profiler dict**: Fixed memory leak in long-running deployments
   - _files_accessed now uses OrderedDict with LRU eviction
   - Bounded by same max_records limit as other profiler buffers

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <[email protected]>
2026-05-17 08:24:31 +00:00
praisonai-triage-agent[bot]andMervin Praison cf40b25e65 fix: address all critical security and code review issues
- Fix YAML syntax errors in GitHub workflows (duplicate with: blocks)
- Fix undefined function error in install.sh (error -> log_error)
- Address SQL injection vulnerabilities in ClickHouse (escape single quotes)
- Fix Windows port detection false positives (exact port matching)
- Add missing identifier validation in SurrealDB get() method
- Add database identifier validation in ClickHouse constructor

All critical issues identified by CodeRabbit, Greptile, and Copilot have been addressed.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-17 03:17:36 +00:00
copilot-swe-agent[bot]andMervinPraison 9fd72a6455 fix(security): tighten knowledge-store identifier validation
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/11fcc431-5afb-4c6d-988d-4328908cc30c

Co-authored-by: MervinPraison <[email protected]>
2026-05-16 16:32:37 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 437db5c437 fix: Security improvements from AI PatchLab scan - fixes #1676
- Remove default SurrealDB credentials (root/root) security issue
- Fix subprocess shell=True behavioral bugs in port.py
- Harden GitHub workflow shell interpolation with env variables
- Replace curl | bash with download-then-run pattern in install scripts
- Add validate_identifier() to prevent SQL injection in SurrealDB and ClickHouse vector stores
- Ensure all persistence modules use proper identifier validation

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-16 16:28:33 +00:00
praisonai-triage-agent[bot]andMervin Praison 491e8950a3 fix: Address security vulnerabilities and code quality issues in sandbox implementation
- Fix shell interpolation vulnerabilities in E2B backend with proper shlex.quote() escaping
- Replace duplicated async/sync bridging with centralized run_coroutine_safely() utility
- Add comprehensive pytest unit tests for SandboxMixin, SecurityAnalyzer, SandboxManager, and E2BSandbox
- Remove manual smoke test script in favor of proper unit tests
- Improve code quality and maintainability per architecture review

Co-authored-by: Mervin Praison <[email protected]>
2026-05-15 05:44:09 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 7a25523c7d feat: Complete Sandbox Code Execution with E2B, Docker, and Local Backends
- Add SandboxManager factory with routing to appropriate backends
- Implement E2B cloud VM backend with full file operations
- Add comprehensive security pre-checks with AST analysis
- Integrate SandboxMixin into Agent class for safe code execution
- Support Docker, subprocess, and E2B backends
- Add optional dependencies for e2b-code-interpreter and docker
- Maintain protocol-driven architecture per AGENTS.md
- Zero performance impact when sandbox is disabled
- Backward compatible with existing codebase

Fixes #1670

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-15 05:35:04 +00:00
98 changed files with 8108 additions and 4465 deletions

No files matched your search

+8
View File
@@ -37,6 +37,14 @@ WHATSAPP_PHONE_NUMBER_ID=... # WhatsApp phone number ID
# === Optional: Database ===
DATABASE_URL=sqlite:///~/.praison/database.sqlite # Database connection string
# === Optional: Tool Security ===
# ALLOWED_TOOLS=search,send_message # Whitelist specific tools to prevent name collisions
# Useful in multi-environment setups with overlapping tool names
# Leave unset to use all tools (with collision warnings)
# Do NOT set to empty string: that's treated as configuration error
# In CI, unknown tool names in the whitelist cause strict startup failure
# HERMES_ONLY_TOOLS=search,send_message # (Deprecated) Backward compatibility alias for ALLOWED_TOOLS
# === Optional: Development ===
LOGLEVEL=INFO # Logging level (DEBUG, INFO, WARNING, ERROR)
CHAINLIT_AUTH_SECRET=... # Chainlit authentication secret
+4 -5
View File
@@ -29,9 +29,6 @@ jobs:
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
fetch-depth: 0
- name: Generate GitHub App Token
@@ -63,10 +60,12 @@ jobs:
GH_TOKEN: ${{ steps.generate_token.outputs.token }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
PRAISONAI_AUTO_APPROVE: "true"
ISSUE_NUMBER_INPUT: ${{ inputs.issue_number }}
ISSUE_NUMBER_EVENT: ${{ github.event.issue.number }}
run: |
if [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
export ISSUE_NUMBER="${{ inputs.issue_number }}"
export ISSUE_NUMBER="$ISSUE_NUMBER_INPUT"
else
export ISSUE_NUMBER="${{ github.event.issue.number }}"
export ISSUE_NUMBER="$ISSUE_NUMBER_EVENT"
fi
praisonai github triage --issue $ISSUE_NUMBER --agent-file .github/praisonai-issue-triage.yaml
+7 -6
View File
@@ -30,22 +30,23 @@ jobs:
steps:
- name: Determine checkout ref
id: dest
env:
ISSUE_NUMBER: ${{ github.event.issue.number }}
PR_NUMBER_INPUT: ${{ inputs.pr_number }}
PR_HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
if [ "${{ github.event_name }}" = "issue_comment" ]; then
echo "ref=refs/pull/${{ github.event.issue.number }}/head" >> "$GITHUB_OUTPUT"
echo "ref=refs/pull/$ISSUE_NUMBER/head" >> "$GITHUB_OUTPUT"
elif [ "${{ github.event_name }}" = "workflow_dispatch" ]; then
echo "ref=refs/pull/${{ inputs.pr_number }}/head" >> "$GITHUB_OUTPUT"
echo "ref=refs/pull/$PR_NUMBER_INPUT/head" >> "$GITHUB_OUTPUT"
else
echo "ref=${{ github.event.pull_request.head.sha }}" >> "$GITHUB_OUTPUT"
echo "ref=$PR_HEAD_SHA" >> "$GITHUB_OUTPUT"
fi
- name: Checkout Repository
uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
with:
ref: ${{ steps.dest.outputs.ref }}
fetch-depth: 0
+14 -2
View File
@@ -26,8 +26,8 @@ jobs:
steps:
- uses: actions/checkout@v4
with:
persist-credentials: false
with:
persist-credentials: false
- name: Set up Python 3.11
uses: actions/setup-python@v5
with:
@@ -176,6 +176,18 @@ jobs:
run: praisonai queue list
continue-on-error: true
# ==================== PACKAGING TESTS ====================
- name: Test packaging/daemon entry points
run: |
echo "Testing both entry point methods..."
python -m praisonai --version
praisonai --version
- name: Test doctor packaging command
run: |
echo "Testing doctor packaging diagnostics..."
praisonai doctor packaging --json
# ==================== REAL API TESTS ====================
- name: Real API test - Agent run
if: env.OPENAI_API_KEY != ''
-34
View File
@@ -1,34 +0,0 @@
# Security release checklist (maintainer)
After merging security PRs [#1684](https://github.com/MervinPraison/PraisonAI/pull/1684) and [#1685](https://github.com/MervinPraison/PraisonAI/pull/1685):
## 1. Publish PyPI (requires `PYPI_TOKEN`)
```bash
cd src/praisonai-agents
export PYPI_TOKEN=...
praisonai publish pypi
cd ../praisonai
python scripts/bump_and_release.py <WRAPPER_VERSION> --agents <AGENTS_VERSION> --wait
```
## 2. Publish GitHub security advisories
Set versions to match PyPI, then:
```bash
AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
```
Add reporter `credits` per advisory via `gh api` before publishing if not already set.
## 3. Verify
```bash
pip index versions praisonaiagents | head -1
pip index versions praisonai | head -1
gh api repos/MervinPraison/PraisonAI/security-advisories --jq '.[] | "\(.ghsa_id) \(.state) \(.cve_id // "pending")"'
```
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
-26
View File
@@ -1,26 +0,0 @@
# Security advisory triage (maintainer)
| GHSA | Status after batch 1+2 | Notes |
|------|------------------------|-------|
| GHSA-4mr5-g6f9-cfrh | fixed batch 1 | Sandbox AST |
| GHSA-5c6w-wwfq-7qqm | fixed batch 1 | spider_tools SSRF |
| GHSA-9cr9-25q5-8prj | fixed batch 1 | MCP yaml paths |
| GHSA-86qc-r5v2-v6x6 | fixed batch 1 | call server auth |
| GHSA-hvhp-v2gc-268q | fixed batch 1 | write_file cwd |
| GHSA-5cxw-77wg-jrf3 | fixed batch 1 | @url mentions |
| GHSA-xp85-6wwf-r67c | fixed batch 1 | GHA branch quote |
| GHSA-3qg8-5g3r-79v5 | fixed batch 1+2 | JWT + issue guard |
| GHSA-xwq8, 7p8g, w388, g8rr | fixed batch 1 | platform RBAC/IDOR partial |
| GHSA-943m, 5jx9, 4x6r, 27p4, cp4f | fixed batch 2 | platform IDOR completion |
| GHSA-vg22-4gmj-prxw | fixed batch 2 | example eval hardening |
| GHSA-8444-4fhq-fxpq | already-fixed | APIConfig.auth_enabled default True |
| GHSA-78r8-wwqv-r299 | already-fixed | load_user_module gate |
| GHSA-gv23-xxxx-xxxx, GHSA-h8q5-cp56-rr65, GHSA-6h6v-xxxx-xxxx, GHSA-h37g-4h4p-9x97 | fixed batch 3 | service-layer workspace_id on get/update/delete |
| GHSA-c2m8-xxxx-xxxx, GHSA-8g2p-xxxx-xxxx | fixed batch 3 | only owner assigns admin/owner |
| GHSA-h8q5-cp56-rr65 | fixed batch 3 | bind default 127.0.0.1 (+ PLATFORM_HOST) |
| GHSA-6xj3-927j-6pqw | not-applicable | Open WebUI path; not in this repo |
| GHSA-gmjg, 9q28 | published | prior release |
**Code fixed on main; GHSA state still triage until PyPI publish + advisory publish.**
Resources: https://github.com/MervinPraison/PraisonAI · https://docs.praison.ai · https://praison.ai
+2
View File
@@ -183,6 +183,7 @@ Methods:
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_available_tools</a>(context: Optional[Dict[str, Any]] = None) -> List[Union[BaseTool, Callable]]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_base_tools</a>() -> List[BaseTool]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_tools</a>() -> List[str]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">list_tools_with_allowed_filter</a>(context: Optional[Dict[str, Any]] = None) -> List[str]</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">register</a>(tool: Union[BaseTool, Callable], name: Optional[str] = None, overwrite: bool = False) -> None</code>
* <code title="class ToolRegistry">ToolRegistry.<a href="./src/praisonai-agents/praisonaiagents/tools/registry.py">unregister</a>(name: str) -> bool</code>
* <code title="class Tools">Tools.<a href="./src/praisonai-agents/praisonaiagents/tools/tools.py">internet_search</a>(*args, **kwargs)</code>
@@ -857,6 +858,7 @@ Methods:
* <code title="cli">praisonai doctor env <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor mcp <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor network <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.40" \
"praisonai>=4.6.43" \
"praisonai[chat]" \
"embedchain[github,youtube]"
+1 -1
View File
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.40" \
"praisonai>=4.6.43" \
"praisonai[ui]" \
"praisonai[chat]" \
"praisonai[realtime]" \
+1 -1
View File
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
# Install Python packages (using latest versions)
RUN pip install --no-cache-dir \
praisonai_tools \
"praisonai>=4.6.40" \
"praisonai>=4.6.43" \
"praisonai[ui]" \
"praisonai[crewai]"
-57
View File
@@ -1,57 +0,0 @@
#!/usr/bin/env bash
# Publish security advisories after PyPI release.
# Usage: AGENTS_VERSION=1.6.40 WRAPPER_VERSION=4.6.40 ./scripts/security_publish_advisories.sh
set -euo pipefail
REPO="MervinPraison/PraisonAI"
AGENTS_VERSION="${AGENTS_VERSION:?Set AGENTS_VERSION (e.g. 1.6.40)}"
WRAPPER_VERSION="${WRAPPER_VERSION:?Set WRAPPER_VERSION (e.g. 4.6.40)}"
LAST_AGENTS="${LAST_AGENTS:-1.6.39}"
LAST_WRAPPER="${LAST_WRAPPER:-4.6.39}"
# Batch 1 + 2 GHSA IDs (update credits login per advisory via gh api first if needed)
AGENTS_GHSAS=(
GHSA-4mr5-g6f9-cfrh
GHSA-5c6w-wwfq-7qqm
GHSA-5cxw-77wg-jrf3
)
WRAPPER_GHSAS=(
GHSA-9cr9-25q5-8prj
GHSA-86qc-r5v2-v6x6
GHSA-hvhp-v2gc-268q
GHSA-xp85-6wwf-r67c
GHSA-8444-4fhq-fxpq
GHSA-78r8-wwqv-r299
GHSA-vg22-4gmj-prxw
GHSA-6xj3-927j-6pqw
)
patch_advisory() {
local ghsa="$1" package="$2" last="$3" patched="$4"
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH --input - <<EOF
{
"state": "draft",
"vulnerabilities": [
{
"package": {"ecosystem": "pip", "name": "${package}"},
"vulnerable_version_range": "<= ${last}",
"patched_versions": ">= ${patched}"
}
]
}
EOF
gh api "repos/${REPO}/security-advisories/${ghsa}" --method PATCH -f state=published
gh api "repos/${REPO}/security-advisories/${ghsa}/cve" --method POST || true
echo "Published ${ghsa}"
}
for ghsa in "${AGENTS_GHSAS[@]}"; do
patch_advisory "$ghsa" "praisonaiagents" "$LAST_AGENTS" "$AGENTS_VERSION"
done
for ghsa in "${WRAPPER_GHSAS[@]}"; do
patch_advisory "$ghsa" "praisonai" "$LAST_WRAPPER" "$WRAPPER_VERSION"
done
echo "Done. Verify: gh api repos/${REPO}/security-advisories --jq '.[] | select(.state==\"published\") | .ghsa_id'"
@@ -21,6 +21,7 @@ from .chat_handler import ChatHandlerMixin
from .session_manager import SessionManagerMixin
from .async_safety import AsyncSafeState
from .unified_execution_mixin import UnifiedExecutionMixin
from .sandbox_mixin import SandboxMixin
# Module-level logger for thread safety errors and debugging
logger = get_logger(__name__)
@@ -255,7 +256,7 @@ if TYPE_CHECKING:
# Import structured error from central errors module
from ..errors import BudgetExceededError
class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, SessionManagerMixin, ChatMixin, ExecutionMixin, MemoryMixin, AsyncMemoryMixin):
# Class-level counter for generating unique display names for nameless agents
_agent_counter = 0
_agent_counter_lock = threading.Lock()
@@ -555,6 +556,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
):
"""Initialize an Agent instance.
@@ -1946,6 +1948,9 @@ Your Goal: {self.goal}
# Telemetry - lazy initialized via property for performance
self.__telemetry = None
self.__telemetry_initialized = False
# Sandbox configuration - initialize SandboxMixin
super().__init__(sandbox=sandbox)
@property
def _telemetry(self):
@@ -753,11 +753,11 @@ Your Goal: {self.goal}"""
# Create LLMError with contextual metadata
error = LLMError(
str(e),
model_name=model_name,
agent_id=self.name,
str(e),
model_name=model_name,
agent_id=self.name,
is_retryable=is_retryable,
session_id=session_id
context={"session_id": session_id},
)
# Call error hook if available for error interception
@@ -902,7 +902,7 @@ Your Goal: {self.goal}"""
max_tokens=getattr(self, 'max_tokens', None),
stream=stream,
response_format=response_format,
execute_tool_fn=getattr(self, 'execute_tool', None),
execute_tool_fn=getattr(self, 'execute_tool_async', None),
console=self.console if (self.verbose or stream) else None,
display_fn=self._display_generating if self.verbose else None,
stream_callback=stream_callback,
@@ -935,6 +935,151 @@ Your Goal: {self.goal}"""
logging.error(f"Unified async chat completion failed: {e}")
raise
async def _finalize_unified_achat_response(
self,
response,
original_prompt,
temperature,
tools,
output_json,
output_pydantic,
reasoning_steps,
stream,
messages,
chat_history_length,
start_time,
task_name=None,
task_description=None,
task_id=None,
):
"""Post-process unified achat completion (parity with sync _chat_impl)."""
if not response:
self._truncate_chat_history(chat_history_length)
return None
if hasattr(response, "choices") and response.choices:
msg = response.choices[0].message
content = getattr(msg, "content", None)
response_text = content.strip() if content else ""
if (
reasoning_steps
and getattr(msg, "reasoning_content", None)
):
response_text = msg.reasoning_content.strip()
else:
extracted = self._extract_llm_response_content(response)
response_text = (extracted or "").strip() if isinstance(extracted, str) else str(extracted or "")
if output_json or output_pydantic:
self._append_to_chat_history({"role": "assistant", "content": response_text})
self._persist_message("assistant", response_text)
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
self._truncate_chat_history(chat_history_length)
return None
if not self.self_reflect:
self._append_to_chat_history({"role": "assistant", "content": response_text})
self._persist_message("assistant", response_text)
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
self._truncate_chat_history(chat_history_length)
return None
# Self-reflection (legacy OpenAI path)
reflection_count = 0
while True:
reflection_prompt = f"""
Reflect on your previous response: '{response_text}'.
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
Provide a "satisfactory" status ('yes' or 'no').
Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
reflection_messages = messages + [
{"role": "assistant", "content": response_text},
{"role": "user", "content": reflection_prompt},
]
try:
if self._openai_client is None:
self._append_to_chat_history({"role": "assistant", "content": response_text})
return await self._atrigger_after_agent_hook(
original_prompt, response_text, start_time,
)
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput'],
)
reflection_output = reflection_response.choices[0].message.parsed
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
break
if reflection_count >= self.max_reflect - 1:
break
regenerate_messages = reflection_messages + [
{
"role": "assistant",
"content": (
f"Self Reflection: {reflection_output.reflection} "
f"Satisfactory?: {reflection_output.satisfactory}"
),
},
{"role": "user", "content": "Now regenerate your response using the reflection you made"},
]
new_response = await self._openai_client.async_client.chat.completions.create(
model=self.llm,
messages=regenerate_messages,
temperature=temperature,
)
response_text = new_response.choices[0].message.content
reflection_count += 1
except Exception as e:
logging.error("Reflection parsing failed.", exc_info=True)
reflection_count += 1
if reflection_count >= self.max_reflect:
break
try:
validated_response = self._apply_guardrail_with_retry(
response_text, original_prompt, temperature, tools,
task_name, task_description, task_id,
)
self._execute_callback_and_display(
original_prompt, validated_response, time.time() - start_time,
task_name, task_description, task_id,
)
return await self._atrigger_after_agent_hook(
original_prompt, validated_response, start_time,
)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for OpenAI client: {e}")
self._truncate_chat_history(chat_history_length)
return None
def _execute_callback_and_display(self, prompt: str, response: str, generation_time: float, task_name=None, task_description=None, task_id=None):
"""Helper method to execute callbacks and display interaction.
@@ -2042,8 +2187,22 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_id=task_id,
response_format=response_format
)
# Continue to handle structured outputs and other processing
# Don't return immediately - fall through to existing logic
return await self._finalize_unified_achat_response(
response=response,
original_prompt=original_prompt,
temperature=temperature,
tools=tools,
output_json=output_json,
output_pydantic=output_pydantic,
reasoning_steps=reasoning_steps,
stream=stream,
messages=messages,
chat_history_length=chat_history_length,
start_time=start_time,
task_name=task_name,
task_description=task_description,
task_id=task_id,
)
else:
# LEGACY: Check if OpenAI client is available
if self._openai_client is None:
@@ -0,0 +1,231 @@
"""
Sandbox execution mixin for Agent.
Provides safe code execution capabilities to agents via the sandbox framework.
"""
import logging
from typing import Optional, Dict, Any, Union
from ..sandbox import (
SandboxConfig,
SandboxManager,
SandboxResult,
check_code_safety,
format_warnings,
)
logger = logging.getLogger(__name__)
class SandboxMixin:
"""Mixin that adds sandbox execution capabilities to Agent.
Provides safe code execution in isolated environments with security pre-checks.
"""
def __init__(self, *args, **kwargs):
# Extract sandbox config from kwargs
self.sandbox_config: Optional[SandboxConfig] = kwargs.pop('sandbox', None)
# Convert boolean True to default config
if self.sandbox_config is True:
self.sandbox_config = SandboxConfig.subprocess()
super().__init__(*args, **kwargs)
self._sandbox_manager: Optional[SandboxManager] = None
@property
def has_sandbox(self) -> bool:
"""Check if sandbox is configured for this agent."""
return self.sandbox_config is not None
def get_sandbox_manager(self) -> Optional[SandboxManager]:
"""Get or create sandbox manager."""
if not self.has_sandbox:
return None
if self._sandbox_manager is None:
self._sandbox_manager = SandboxManager(self.sandbox_config)
return self._sandbox_manager
async def execute_code(
self,
code: str,
language: str = "python",
check_security: bool = True,
**kwargs
) -> SandboxResult:
"""Execute code safely in configured sandbox.
Args:
code: Code to execute
language: Programming language (python, bash, etc.)
check_security: Whether to run security pre-checks
**kwargs: Additional arguments passed to sandbox.execute()
Returns:
SandboxResult with execution details
Raises:
RuntimeError: If no sandbox is configured
"""
if not self.has_sandbox:
raise RuntimeError(
"No sandbox configured. Set sandbox=True or provide SandboxConfig to Agent()"
)
# Security pre-check if enabled
if check_security:
warnings = check_code_safety(code, language)
if warnings:
warning_text = format_warnings(warnings)
if self.verbose:
logger.warning(f"Security warnings for code execution:\n{warning_text}")
# Store warnings in result metadata
kwargs.setdefault('metadata', {})['security_warnings'] = warnings
manager = self.get_sandbox_manager()
return await manager.run_code(code, language=language, **kwargs)
def execute_code_sync(
self,
code: str,
language: str = "python",
check_security: bool = True,
**kwargs
) -> SandboxResult:
"""Synchronous wrapper for execute_code.
Args:
code: Code to execute
language: Programming language
check_security: Whether to run security pre-checks
**kwargs: Additional arguments
Returns:
SandboxResult with execution details
"""
from ..approval.utils import run_coroutine_safely
return run_coroutine_safely(self.execute_code(code, language, check_security, **kwargs))
async def run_shell_command(
self,
command: Union[str, list],
check_security: bool = True,
**kwargs
) -> SandboxResult:
"""Run a shell command in the sandbox.
Args:
command: Shell command to run
check_security: Whether to run security pre-checks
**kwargs: Additional arguments
Returns:
SandboxResult with execution details
"""
if not self.has_sandbox:
raise RuntimeError("No sandbox configured")
# Convert command to string for security checking
if isinstance(command, list):
command_str = " ".join(command)
else:
command_str = command
# Security pre-check
if check_security:
warnings = check_code_safety(command_str, "bash")
if warnings:
warning_text = format_warnings(warnings)
if self.verbose:
logger.warning(f"Security warnings for command:\n{warning_text}")
async with self.get_sandbox_manager() as sandbox:
return await sandbox.run_command(command, **kwargs)
def get_sandbox_status(self) -> Dict[str, Any]:
"""Get sandbox status information.
Returns:
Dictionary with sandbox status details
"""
if not self.has_sandbox:
return {"configured": False}
status = {
"configured": True,
"config": self.sandbox_config.to_dict() if self.sandbox_config else None,
}
manager = self.get_sandbox_manager()
if manager:
# Get available sandbox types
available_types = manager.get_available_types()
status["available_types"] = available_types
status["current_type"] = self.sandbox_config.sandbox_type
return status
async def sandbox_cleanup(self):
"""Clean up sandbox resources."""
if self._sandbox_manager:
# The context manager handles cleanup, but we can force it here
try:
await self._sandbox_manager.__aexit__(None, None, None)
except Exception as e:
logger.warning(f"Error during sandbox cleanup: {e}")
finally:
self._sandbox_manager = None
def _get_code_execution_tools(self):
"""Get sandbox-based code execution tools for this agent.
Returns tools that use the configured sandbox for safe execution.
Returns:
List of tool functions
"""
if not self.has_sandbox:
return []
from ..tools import tool
@tool
def execute_python_code(code: str) -> str:
"""Execute Python code safely in sandbox.
Args:
code: Python code to execute
Returns:
Execution output
"""
result = self.execute_code_sync(code, language="python")
if result.success:
return result.stdout or result.output
else:
return f"Error: {result.error or result.stderr}"
@tool
def execute_shell_command(command: str) -> str:
"""Execute shell command safely in sandbox.
Args:
command: Shell command to execute
Returns:
Command output
"""
from ..approval.utils import run_coroutine_safely
result = run_coroutine_safely(self.run_shell_command(command))
if result.success:
return result.stdout or result.output
else:
return f"Error: {result.error or result.stderr}"
return [execute_python_code, execute_shell_command]
@@ -0,0 +1,257 @@
"""
Tool whitelist filter module with diagnostics.
This module provides a canonical implementation of the ALLOWED_TOOLS filter
that prevents tool name collisions in multi-environment agent systems by
whitelisting only specified tools.
The filter is designed to solve the "tool shadowing" problem where multiple
modules register tools with overlapping or ambiguous names, causing agents
to invoke the wrong implementation.
Environment Variables:
ALLOWED_TOOLS: Primary variable (comma-separated tool names)
HERMES_ONLY_TOOLS: Backward compatibility alias for ALLOWED_TOOLS
Usage:
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
# Create filter from environment variable
filter = AllowedToolsFilter()
# Filter tools
available_tools = {"search", "send_message", "extract_pdf"}
filtered_tools = filter.filter_tools(available_tools)
# Get diagnostics
filter.log_diagnostics()
"""
import os
import logging
from typing import Any, Dict, List, Optional, Set, Union
logger = logging.getLogger(__name__)
class AllowedToolsFilter:
"""
Canonical tool whitelist filter with diagnostics and backward compatibility.
This filter implements the semantics for tool filtering:
- ALLOWED_TOOLS unset (or HERMES_ONLY_TOOLS): all tools visible (with warning about collisions)
- ALLOWED_TOOLS empty string: error - must specify tools or unset
- ALLOWED_TOOLS with values: only whitelisted tools visible
- Unknown tools in list: warn and strip in dev, strict fail in CI
Backward Compatibility:
- Supports both ALLOWED_TOOLS and HERMES_ONLY_TOOLS
- ALLOWED_TOOLS takes precedence if both are set
"""
def __init__(self, env_var_name: str = "ALLOWED_TOOLS"):
"""
Initialize the filter with backward compatibility support.
Args:
env_var_name: Primary environment variable name (default: "ALLOWED_TOOLS")
"""
# Support both new (ALLOWED_TOOLS) and legacy (HERMES_ONLY_TOOLS) naming
self.primary_var = env_var_name
self.legacy_var = "HERMES_ONLY_TOOLS"
# ALLOWED_TOOLS takes precedence over HERMES_ONLY_TOOLS for backward compatibility
self.env_value = os.environ.get(self.primary_var) or os.environ.get(self.legacy_var)
self.env_var_name = self.primary_var if os.environ.get(self.primary_var) else self.legacy_var
self.is_ci = os.environ.get("CI", "").lower() in ("true", "1", "yes")
self._whitelist: Optional[Set[str]] = self._parse_whitelist()
self._diagnostics: Dict[str, Any] = {}
def _parse_whitelist(self) -> Optional[Set[str]]:
"""
Parse the environment variable value into a whitelist set.
Returns:
None if unset, Set of tool names if set, raises ValueError for empty string
"""
if self.env_value is None:
return None
# Empty string is an error - must be explicit
if self.env_value.strip() == "":
raise ValueError(
f"{self.env_var_name} cannot be empty. Either unset it for all tools "
"or provide a comma-separated list of tool names."
)
# Parse comma-separated values, strip whitespace
tools = {name.strip() for name in self.env_value.split(",") if name.strip()}
if not tools:
raise ValueError(
f"{self.env_var_name} contains no valid tool names. "
"Provide comma-separated tool names or unset the variable."
)
return tools
def filter_tools(self, available_tools: Union[Set[str], List[str], Dict[str, Any]]) -> Set[str]:
"""
Filter tools based on ALLOWED_TOOLS whitelist.
Args:
available_tools: Available tools as set, list, or dict with tool names as keys
Returns:
Filtered set of tool names
Raises:
ValueError: If unknown tools in CI mode or configuration errors
"""
# Convert input to set of tool names
if isinstance(available_tools, dict):
tool_names = set(available_tools.keys())
else:
tool_names = set(available_tools)
# Store for diagnostics
self._diagnostics["registered_before_filter"] = sorted(tool_names)
# If no whitelist, return all tools (with warning)
if self._whitelist is None:
logger.warning(
"Tool whitelist is unset. All %d tools are visible. "
"Consider using ALLOWED_TOOLS to prevent tool name collisions.",
len(tool_names)
)
self._diagnostics["registered_after_filter"] = sorted(tool_names)
self._diagnostics["dropped_tools"] = []
self._diagnostics["unknown_tools"] = []
return tool_names
# Find intersection and unknown tools
available_whitelisted = tool_names.intersection(self._whitelist)
unknown_tools = self._whitelist.difference(tool_names)
dropped_tools = tool_names.difference(self._whitelist)
# Store for diagnostics
self._diagnostics["registered_after_filter"] = sorted(available_whitelisted)
self._diagnostics["dropped_tools"] = sorted(dropped_tools)
self._diagnostics["unknown_tools"] = sorted(unknown_tools)
# Handle unknown tools based on environment
if unknown_tools:
unknown_list = sorted(unknown_tools)
if self.is_ci:
raise ValueError(
f"Unknown tools in {self.env_var_name}: {unknown_list}. "
"All specified tools must be available in CI mode."
)
else:
logger.warning(
"Unknown tools in %s will be ignored: %s",
self.env_var_name,
unknown_list
)
if dropped_tools:
logger.info(
"Dropped %d tools not in %s whitelist: %s",
len(dropped_tools),
self.env_var_name,
sorted(dropped_tools)
)
logger.info(
"%s filtered tools: %d available, %d after filter",
self.env_var_name,
len(tool_names),
len(available_whitelisted)
)
return available_whitelisted
def log_diagnostics(self) -> None:
"""Log startup diagnostics section as specified in the issue."""
if not self._diagnostics:
logger.warning("No diagnostics available. Call filter_tools() first.")
return
logger.info("=" * 50)
logger.info("ALLOWED_TOOLS FILTER DIAGNOSTICS")
logger.info("=" * 50)
logger.info("%s=%s", self.env_var_name, self.env_value or "<unset>")
logger.info("RegisteredBeforeFilter=%s", self._diagnostics.get("registered_before_filter", []))
logger.info("RegisteredAfterFilter=%s", self._diagnostics.get("registered_after_filter", []))
logger.info("DroppedTools=%s", self._diagnostics.get("dropped_tools", []))
logger.info("UnknownTools=%s", self._diagnostics.get("unknown_tools", []))
logger.info("IsCI=%s", self.is_ci)
logger.info("=" * 50)
def get_diagnostics(self) -> Dict[str, Any]:
"""
Get diagnostics data as a dictionary.
Returns:
Dictionary with diagnostics information
"""
return {
"env_var_name": self.env_var_name,
"env_value": self.env_value,
"is_ci": self.is_ci,
"whitelist": list(self._whitelist) if self._whitelist else None,
**self._diagnostics
}
def is_enabled(self) -> bool:
"""
Check if filtering is enabled.
Returns:
True if tool whitelist is set and filtering is active
"""
return self._whitelist is not None
def get_whitelist(self) -> Optional[Set[str]]:
"""
Get the current whitelist.
Returns:
Set of whitelisted tool names, or None if not set
"""
return self._whitelist.copy() if self._whitelist else None
def filter_tools_with_allowed_tools(
available_tools: Union[Set[str], List[str], Dict[str, Any]],
env_var_name: str = "ALLOWED_TOOLS",
log_diagnostics: bool = True
) -> Set[str]:
"""
Convenience function to filter tools with ALLOWED_TOOLS.
Args:
available_tools: Available tools to filter
env_var_name: Environment variable name (default: "ALLOWED_TOOLS")
log_diagnostics: Whether to log diagnostics (default: True)
Returns:
Filtered set of tool names
"""
filter_instance = AllowedToolsFilter(env_var_name)
filtered = filter_instance.filter_tools(available_tools)
if log_diagnostics:
filter_instance.log_diagnostics()
return filtered
# Backward compatibility aliases
filter_tools_with_hermes = filter_tools_with_allowed_tools
hermes_filter = filter_tools_with_allowed_tools
apply_hermes_filter = filter_tools_with_allowed_tools
# Legacy class alias for backward compatibility
HermesToolFilter = AllowedToolsFilter
@@ -42,6 +42,7 @@ class MemoryBackend(str, Enum):
FILE = "file"
SQLITE = "sqlite"
REDIS = "redis"
VALKEY = "valkey"
POSTGRES = "postgres"
MEM0 = "mem0"
MONGODB = "mongodb"
@@ -15,6 +15,8 @@ from .protocols import (
ResourceLimits,
)
from .config import SandboxConfig, SecurityPolicy
from .manager import SandboxManager
from .security import SecurityWarning, check_code_safety, format_warnings, get_security_summary
__all__ = [
"SandboxProtocol",
@@ -22,5 +24,10 @@ __all__ = [
"SandboxStatus",
"ResourceLimits",
"SandboxConfig",
"SecurityPolicy",
"SecurityPolicy",
"SandboxManager",
"SecurityWarning",
"check_code_safety",
"format_warnings",
"get_security_summary",
]
@@ -0,0 +1,302 @@
"""
SandboxManager for PraisonAI Agents.
Factory and context manager for sandbox backends.
Core SDK component that routes to appropriate sandbox implementations
in the praisonai wrapper package.
"""
from __future__ import annotations
import logging
from typing import Dict, Any, Optional, TYPE_CHECKING
from .config import SandboxConfig
from .protocols import SandboxProtocol, SandboxResult
if TYPE_CHECKING:
from typing import AsyncContextManager
logger = logging.getLogger(__name__)
class SandboxManager:
"""Factory and context manager for all sandbox backends.
Routes to appropriate sandbox implementations based on configuration.
Lightweight manager in core SDK - heavy implementations in wrapper.
Example:
from praisonaiagents.sandbox import SandboxManager, SandboxConfig
# Simple usage
config = SandboxConfig.docker("python:3.11-slim")
manager = SandboxManager(config)
result = await manager.run_code("print('Hello, World!')")
# Context manager usage
async with SandboxManager(config) as sandbox:
result = await sandbox.execute("print('Hello, World!')")
"""
def __init__(self, config: Optional[SandboxConfig] = None):
"""Initialize the sandbox manager.
Args:
config: Sandbox configuration. Defaults to subprocess sandbox.
"""
self.config = config or SandboxConfig.subprocess()
self._sandbox: Optional[SandboxProtocol] = None
async def __aenter__(self) -> SandboxProtocol:
"""Async context manager entry."""
self._sandbox = await self._create_sandbox()
return self._sandbox
async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit with cleanup."""
if self._sandbox:
try:
await self._sandbox.stop()
await self._sandbox.cleanup()
except Exception as e:
logger.warning(f"Error during sandbox cleanup: {e}")
finally:
self._sandbox = None
async def run_code(
self,
code: str,
language: str = "python",
**kwargs
) -> SandboxResult:
"""Convenience method: create sandbox, run code, cleanup.
Args:
code: Code to execute
language: Programming language
**kwargs: Additional arguments passed to execute()
Returns:
SandboxResult with execution details
"""
async with self as sandbox:
return await sandbox.execute(code, language=language, **kwargs)
async def _create_sandbox(self) -> SandboxProtocol:
"""Create appropriate sandbox backend based on config.
Routes to implementations in praisonai wrapper package.
Returns:
Configured sandbox instance
Raises:
ValueError: For unknown sandbox types
ImportError: If required dependencies not available
"""
sandbox_type = self.config.sandbox_type.lower()
if sandbox_type == "docker":
return await self._create_docker_sandbox()
elif sandbox_type in ("subprocess", "local"):
return await self._create_subprocess_sandbox()
elif sandbox_type == "e2b":
return await self._create_e2b_sandbox()
elif sandbox_type == "sandlock":
return await self._create_sandlock_sandbox()
elif sandbox_type == "ssh":
return await self._create_ssh_sandbox()
elif sandbox_type == "modal":
return await self._create_modal_sandbox()
elif sandbox_type == "daytona":
return await self._create_daytona_sandbox()
else:
raise ValueError(
f"Unknown sandbox type: {sandbox_type!r}. "
f"Supported: 'docker', 'subprocess', 'e2b', 'sandlock', 'ssh', 'modal', 'daytona'"
)
async def _create_docker_sandbox(self) -> SandboxProtocol:
"""Create Docker sandbox."""
try:
# Lazy import from wrapper package
from praisonai.sandbox import DockerSandbox
except ImportError as e:
raise ImportError(
"Docker sandbox not available. Install with: "
"pip install praisonaiagents[sandbox-docker]"
) from e
sandbox = DockerSandbox(
image=self.config.image,
config=self.config,
)
if not sandbox.is_available:
raise RuntimeError(
"Docker is not available. Please install Docker and ensure it's running."
)
await sandbox.start()
return sandbox
async def _create_subprocess_sandbox(self) -> SandboxProtocol:
"""Create subprocess sandbox."""
try:
from praisonai.sandbox import SubprocessSandbox
except ImportError as e:
raise ImportError(
"Subprocess sandbox not available. This should not happen - "
"please check your installation."
) from e
sandbox = SubprocessSandbox(config=self.config)
await sandbox.start()
return sandbox
async def _create_e2b_sandbox(self) -> SandboxProtocol:
"""Create E2B sandbox."""
try:
from praisonai.sandbox import E2BSandbox
except ImportError as e:
raise ImportError(
"E2B sandbox not available. Install with: "
"pip install praisonaiagents[sandbox] e2b-code-interpreter"
) from e
sandbox = E2BSandbox(config=self.config)
if not sandbox.is_available:
raise RuntimeError(
"E2B is not available. Please set E2B_API_KEY environment variable."
)
await sandbox.start()
return sandbox
async def _create_sandlock_sandbox(self) -> SandboxProtocol:
"""Create Sandlock sandbox."""
try:
from praisonai.sandbox import SandlockSandbox
except ImportError as e:
raise ImportError(
"Sandlock sandbox not available. Install sandlock."
) from e
sandbox = SandlockSandbox(config=self.config)
await sandbox.start()
return sandbox
async def _create_ssh_sandbox(self) -> SandboxProtocol:
"""Create SSH sandbox."""
try:
from praisonai.sandbox import SSHSandbox
except ImportError as e:
raise ImportError(
"SSH sandbox not available. Install with: "
"pip install paramiko"
) from e
sandbox = SSHSandbox(config=self.config)
await sandbox.start()
return sandbox
async def _create_modal_sandbox(self) -> SandboxProtocol:
"""Create Modal sandbox."""
try:
from praisonai.sandbox import ModalSandbox
except ImportError as e:
raise ImportError(
"Modal sandbox not available. Install with: "
"pip install modal"
) from e
sandbox = ModalSandbox(config=self.config)
await sandbox.start()
return sandbox
async def _create_daytona_sandbox(self) -> SandboxProtocol:
"""Create Daytona sandbox."""
try:
from praisonai.sandbox import DaytonaSandbox
except ImportError as e:
raise ImportError(
"Daytona sandbox not available. Install daytona client."
) from e
sandbox = DaytonaSandbox(config=self.config)
await sandbox.start()
return sandbox
def get_available_types(self) -> Dict[str, Dict[str, Any]]:
"""Get available sandbox types and their status.
Returns:
Dictionary mapping sandbox types to availability info
"""
types = {}
# Check Docker
try:
from praisonai.sandbox import DockerSandbox
docker = DockerSandbox()
types["docker"] = {
"available": docker.is_available,
"description": "Isolated Docker containers",
"requires": ["docker"],
}
except ImportError:
types["docker"] = {
"available": False,
"description": "Isolated Docker containers",
"requires": ["docker", "praisonaiagents[sandbox-docker]"],
}
# Check subprocess (always available)
types["subprocess"] = {
"available": True,
"description": "Local subprocess (limited isolation)",
"requires": [],
}
# Check E2B
try:
from praisonai.sandbox import E2BSandbox
e2b = E2BSandbox()
types["e2b"] = {
"available": e2b.is_available,
"description": "E2B cloud sandboxes",
"requires": ["e2b-code-interpreter", "E2B_API_KEY"],
}
except ImportError:
types["e2b"] = {
"available": False,
"description": "E2B cloud sandboxes",
"requires": ["e2b-code-interpreter", "E2B_API_KEY"],
}
# Add other types with try/except blocks
for sandbox_type, module_name, description, requirements in [
("sandlock", "SandlockSandbox", "OS-native sandboxing", ["sandlock"]),
("ssh", "SSHSandbox", "Remote SSH execution", ["paramiko"]),
("modal", "ModalSandbox", "Modal cloud compute", ["modal"]),
("daytona", "DaytonaSandbox", "Daytona workspaces", ["daytona"]),
]:
try:
from praisonai import sandbox
sandbox_class = getattr(sandbox, module_name)
# Check if class exists without instantiating (some need required args)
types[sandbox_type] = {
"available": False, # Can't easily check without required args
"description": description,
"requires": requirements,
}
except (ImportError, AttributeError):
types[sandbox_type] = {
"available": False,
"description": description,
"requires": requirements,
}
return types
@@ -0,0 +1,345 @@
"""
Security pre-checks for sandbox code execution.
Provides pre-execution analysis of code for potentially dangerous patterns.
Note: These are warnings only - the sandbox provides the real isolation.
"""
import re
import ast
import logging
from typing import List, Optional, Dict, Any
from dataclasses import dataclass
logger = logging.getLogger(__name__)
@dataclass
class SecurityWarning:
"""A security warning from code analysis."""
pattern: str
message: str
severity: str # "low", "medium", "high", "critical"
line_number: Optional[int] = None
context: Optional[str] = None
# Dangerous patterns to detect in code
DANGEROUS_PATTERNS = [
# System/OS access
(r"import\s+os\s*;?\s*os\.system", "Direct system command execution via os.system", "high"),
(r"subprocess\.call.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
(r"subprocess\.run.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
(r"subprocess\.Popen.*shell\s*=\s*True", "Shell injection risk with subprocess", "high"),
(r"__import__\(['\"]os['\"]\)", "Dynamic import of os module", "medium"),
(r"eval\s*\(", "Dynamic code execution with eval()", "critical"),
(r"exec\s*\(", "Dynamic code execution with exec()", "critical"),
(r"compile\s*\(.*exec", "Code compilation for execution", "high"),
# File system access
(r"open\s*\(.*['\"]\/etc", "Access to system configuration files", "medium"),
(r"open\s*\(.*['\"]\/root", "Access to root directory", "high"),
(r"open\s*\(.*['\"]\/home", "Access to user directories", "medium"),
(r"shutil\.rmtree", "Recursive directory deletion", "high"),
(r"os\.remove\s*\(", "File deletion", "medium"),
(r"os\.unlink\s*\(", "File deletion", "medium"),
(r"pathlib\.Path.*unlink", "File deletion via pathlib", "medium"),
# Network access
(r"socket\.socket", "Direct socket creation", "medium"),
(r"urllib\.request", "HTTP requests via urllib", "low"),
(r"requests\.", "HTTP requests via requests library", "low"),
(r"http\.client", "HTTP client usage", "low"),
# Process manipulation
(r"os\.fork\s*\(", "Process forking", "high"),
(r"multiprocessing\.", "Multiprocessing usage", "medium"),
(r"threading\.Thread", "Thread creation", "low"),
# Potentially dangerous builtins
(r"getattr\s*\(.*__", "Dynamic attribute access to dunder methods", "medium"),
(r"setattr\s*\(.*__", "Dynamic attribute setting of dunder methods", "medium"),
(r"hasattr\s*\(.*__", "Dynamic attribute checking of dunder methods", "low"),
(r"globals\s*\(\)", "Access to global namespace", "medium"),
(r"locals\s*\(\)", "Access to local namespace", "low"),
(r"vars\s*\(", "Variable inspection", "low"),
(r"dir\s*\(", "Object inspection", "low"),
# Code modification
(r"sys\.modules", "Module manipulation", "high"),
(r"importlib\.", "Dynamic import manipulation", "medium"),
(r"__builtins__", "Access to builtin functions", "high"),
# Memory/performance
(r"while\s+True:", "Infinite loop (potential DoS)", "medium"),
(r"for.*in.*range\s*\(\s*[0-9]{6,}", "Large range iteration", "low"),
(r"list\s*\(\s*range\s*\(\s*[0-9]{6,}", "Large list creation", "low"),
(r"\*\s*[0-9]{6,}", "Large data multiplication", "low"),
]
def check_code_safety(code: str, language: str = "python") -> List[SecurityWarning]:
"""Analyze code for potentially dangerous patterns.
Args:
code: Code to analyze
language: Programming language (currently only 'python' supported)
Returns:
List of security warnings found
Note:
These are warnings only. The sandbox provides real isolation.
This is a best-effort static analysis for user awareness.
"""
warnings = []
if language == "python":
warnings.extend(_check_python_patterns(code))
warnings.extend(_check_python_ast(code))
elif language == "bash":
warnings.extend(_check_bash_patterns(code))
else:
# Fall back to basic pattern matching
warnings.extend(_check_generic_patterns(code))
return warnings
def _check_python_patterns(code: str) -> List[SecurityWarning]:
"""Check Python code using regex patterns."""
warnings = []
lines = code.split('\n')
for i, line in enumerate(lines, 1):
for pattern, message, severity in DANGEROUS_PATTERNS:
if re.search(pattern, line, re.IGNORECASE):
warnings.append(SecurityWarning(
pattern=pattern,
message=message,
severity=severity,
line_number=i,
context=line.strip(),
))
return warnings
def _check_python_ast(code: str) -> List[SecurityWarning]:
"""Check Python code using AST analysis."""
warnings = []
try:
tree = ast.parse(code)
visitor = DangerousASTVisitor()
visitor.visit(tree)
warnings.extend(visitor.warnings)
except SyntaxError:
# Invalid Python syntax - let the sandbox handle it
pass
except Exception as e:
logger.debug(f"AST analysis failed: {e}")
return warnings
def _check_bash_patterns(code: str) -> List[SecurityWarning]:
"""Check bash/shell code for dangerous patterns."""
warnings = []
lines = code.split('\n')
bash_patterns = [
(r"rm\s+-rf\s+\/", "Recursive deletion of root directory", "critical"),
(r"rm\s+-rf\s+\*", "Recursive deletion with wildcard", "high"),
(r">\s*\/dev\/sd[a-z]", "Direct disk device access", "critical"),
(r"dd\s+if=.*of=", "Low-level disk operations", "high"),
(r"mkfs\.", "Filesystem creation", "critical"),
(r"fdisk", "Disk partitioning", "critical"),
(r"shutdown", "System shutdown", "high"),
(r"reboot", "System reboot", "high"),
(r"halt", "System halt", "high"),
(r"init\s+[0-6]", "System runlevel change", "high"),
(r"chmod\s+777", "Overly permissive file permissions", "medium"),
(r"curl.*\|\s*bash", "Piped execution from network", "high"),
(r"wget.*\|\s*bash", "Piped execution from network", "high"),
(r"\$\(.*\)", "Command substitution", "medium"),
(r"`.*`", "Command substitution (backticks)", "medium"),
]
for i, line in enumerate(lines, 1):
for pattern, message, severity in bash_patterns:
if re.search(pattern, line, re.IGNORECASE):
warnings.append(SecurityWarning(
pattern=pattern,
message=message,
severity=severity,
line_number=i,
context=line.strip(),
))
return warnings
def _check_generic_patterns(code: str) -> List[SecurityWarning]:
"""Check code using generic patterns."""
warnings = []
lines = code.split('\n')
generic_patterns = [
(r"eval\s*\(", "Dynamic code execution", "critical"),
(r"exec\s*\(", "Dynamic code execution", "critical"),
(r"system\s*\(", "System command execution", "high"),
(r"shell\s*\(", "Shell command execution", "high"),
]
for i, line in enumerate(lines, 1):
for pattern, message, severity in generic_patterns:
if re.search(pattern, line, re.IGNORECASE):
warnings.append(SecurityWarning(
pattern=pattern,
message=message,
severity=severity,
line_number=i,
context=line.strip(),
))
return warnings
class DangerousASTVisitor(ast.NodeVisitor):
"""AST visitor to detect dangerous Python constructs."""
def __init__(self):
self.warnings = []
def visit_Call(self, node):
"""Visit function calls."""
# Check for dangerous function calls
if isinstance(node.func, ast.Name):
func_name = node.func.id
if func_name in ("eval", "exec", "compile"):
self.warnings.append(SecurityWarning(
pattern=f"{func_name}()",
message=f"Dynamic code execution with {func_name}()",
severity="critical",
line_number=node.lineno,
))
# Check for attribute calls like os.system
elif isinstance(node.func, ast.Attribute):
if isinstance(node.func.value, ast.Name):
module = node.func.value.id
func = node.func.attr
if module == "os" and func == "system":
self.warnings.append(SecurityWarning(
pattern="os.system()",
message="Direct system command execution",
severity="high",
line_number=node.lineno,
))
elif module == "subprocess" and func in ("call", "run", "Popen"):
# Check if shell=True is used
for keyword in node.keywords:
if keyword.arg == "shell" and isinstance(keyword.value, ast.Constant):
if keyword.value.value is True:
self.warnings.append(SecurityWarning(
pattern=f"subprocess.{func}(..., shell=True)",
message="Shell injection risk with subprocess",
severity="high",
line_number=node.lineno,
))
self.generic_visit(node)
def visit_Import(self, node):
"""Visit import statements."""
for alias in node.names:
if alias.name in ("os", "subprocess", "sys"):
self.warnings.append(SecurityWarning(
pattern=f"import {alias.name}",
message=f"Import of potentially dangerous module: {alias.name}",
severity="low",
line_number=node.lineno,
))
self.generic_visit(node)
def visit_ImportFrom(self, node):
"""Visit from ... import statements."""
if node.module in ("os", "subprocess", "sys"):
for alias in node.names:
if alias.name in ("system", "call", "run", "Popen", "modules"):
self.warnings.append(SecurityWarning(
pattern=f"from {node.module} import {alias.name}",
message=f"Import of dangerous function: {alias.name}",
severity="medium",
line_number=node.lineno,
))
self.generic_visit(node)
def format_warnings(warnings: List[SecurityWarning]) -> str:
"""Format security warnings for display.
Args:
warnings: List of security warnings
Returns:
Formatted string of warnings
"""
if not warnings:
return "No security issues detected."
# Group by severity
by_severity = {"critical": [], "high": [], "medium": [], "low": []}
for warning in warnings:
by_severity[warning.severity].append(warning)
lines = []
lines.append(f"Security analysis found {len(warnings)} potential issue(s):")
lines.append("")
for severity in ["critical", "high", "medium", "low"]:
if by_severity[severity]:
lines.append(f"{severity.upper()} RISK:")
for warning in by_severity[severity]:
line_info = f" (line {warning.line_number})" if warning.line_number else ""
context_info = f"\n Context: {warning.context}" if warning.context else ""
lines.append(f" - {warning.message}{line_info}{context_info}")
lines.append("")
lines.append("Note: These are warnings only. The sandbox provides real isolation.")
return "\n".join(lines)
def get_security_summary(warnings: List[SecurityWarning]) -> Dict[str, Any]:
"""Get security analysis summary.
Args:
warnings: List of security warnings
Returns:
Dictionary with summary statistics
"""
by_severity = {"critical": 0, "high": 0, "medium": 0, "low": 0}
for warning in warnings:
by_severity[warning.severity] += 1
max_severity = "low"
if by_severity["critical"] > 0:
max_severity = "critical"
elif by_severity["high"] > 0:
max_severity = "high"
elif by_severity["medium"] > 0:
max_severity = "medium"
return {
"total_warnings": len(warnings),
"by_severity": by_severity,
"max_severity": max_severity,
"is_safe": len(warnings) == 0,
"has_critical": by_severity["critical"] > 0,
}
@@ -6,7 +6,7 @@ from typing import List, Optional, Dict
logger = logging.getLogger(__name__)
from .models import SkillMetadata
from .discovery import discover_skills
from .discovery import discover_skills, get_default_skill_dirs
from .loader import SkillLoader, LoadedSkill
from .prompt import generate_skills_xml
from .substitution import render_skill_body
@@ -271,8 +271,7 @@ class SkillManager:
return {"success": False, "error": "Skill content exceeds maximum size (100KB)"}
# Create skill directory
from .discovery import get_default_skill_directories
skill_dirs = get_default_skill_directories()
skill_dirs = get_default_skill_dirs()
base_dir = skill_dirs[0] if skill_dirs else "~/.praisonai/skills"
import os
@@ -346,13 +346,15 @@ class _LazyStorageModule:
_BACKENDS = {
"RedisBackend": "praisonai.storage",
"MongoDBBackend": "praisonai.storage",
"MongoDBBackend": "praisonai.storage",
"PostgreSQLBackend": "praisonai.storage",
"DynamoDBBackend": "praisonai.storage",
"ValkeyBackend": "praisonai.storage",
"ValkeySearchBackend": "praisonai.storage",
# Legacy names for compatibility
"RedisStorageAdapter": "praisonai.storage",
"MongoDBStorageAdapter": "praisonai.storage",
"PostgreSQLStorageAdapter": "praisonai.storage",
"PostgreSQLStorageAdapter": "praisonai.storage",
"DynamoDBStorageAdapter": "praisonai.storage",
}
@@ -390,7 +392,7 @@ def get_backend(
Factory function to get a storage backend.
Args:
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb")
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb", "valkey")
**kwargs: Backend-specific arguments
Returns:
@@ -431,9 +433,13 @@ def get_backend(
# Lazy load from wrapper
backend_class = getattr(_heavy_backends, "DynamoDBBackend")
return backend_class(**kwargs)
elif backend_type == "valkey":
# Lazy load from wrapper
backend_class = getattr(_heavy_backends, "ValkeyBackend")
return backend_class(**kwargs)
else:
raise ValueError(f"Unknown backend type: {backend_type}. "
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb")
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb, valkey")
def __getattr__(name: str):
"""
@@ -442,7 +448,8 @@ def __getattr__(name: str):
This allows `from praisonaiagents.storage.backends import RedisBackend` to work
while maintaining lazy loading.
"""
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend"}:
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend",
"ValkeyBackend", "ValkeySearchBackend"}:
return getattr(_heavy_backends, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -10,7 +10,7 @@ _tools_lazy_cache = {}
# Export core tool items for organized imports (lightweight)
from .base import BaseTool, ToolResult, ToolValidationError, validate_tool
from .decorator import tool, FunctionTool
from .registry import get_registry, register_tool, get_tool, add_tool, has_tool, remove_tool, list_tools, list_available_tools, ToolRegistry
from .registry import get_registry, register_tool, get_tool, add_tool, has_tool, remove_tool, list_tools, list_available_tools, list_tools_with_allowed_filter, list_tools_with_hermes_filter, ToolRegistry
from .tools import Tools
# Export Injected type directly for easy access
@@ -363,7 +363,7 @@ __all__ = list(TOOL_MAPPINGS.keys()) + [
'BaseTool', 'ToolResult', 'ToolValidationError', 'validate_tool',
'tool', 'FunctionTool',
'get_registry', 'register_tool', 'get_tool', 'add_tool', 'has_tool', 'remove_tool',
'list_tools', 'list_available_tools', 'ToolRegistry',
'list_tools', 'list_available_tools', 'list_tools_with_allowed_filter', 'list_tools_with_hermes_filter', 'ToolRegistry',
'Tools',
# Validation and retry protocols
'ValidationResult', 'ToolValidatorProtocol', 'AsyncToolValidatorProtocol', 'PassthroughValidator',
@@ -0,0 +1,52 @@
"""
A2UI tools — optional declarative UI via Google's a2ui-agent-sdk.
Usage:
from praisonaiagents import Agent
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
agent = Agent(name="assistant", tools=[send_a2ui_messages])
"""
from __future__ import annotations
import json
from typing import Any, Dict, List, Union
from praisonaiagents.tools.decorator import tool
@tool
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str, Any]:
"""
Send validated A2UI JSON messages to a connected A2UI renderer client.
Pass a list of A2UI message dicts (createSurface, updateComponents, etc.)
or a JSON string. Requires: pip install praisonaiagents[a2ui]
Args:
messages: A2UI message list or JSON string from the model.
Returns:
Dict with mime_type and serialised messages for A2A/AG-UI clients.
"""
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part
if isinstance(messages, str):
try:
messages = json.loads(messages)
except json.JSONDecodeError as exc:
raise ValueError(f"messages JSON string is not valid JSON: {exc}") from exc
if not isinstance(messages, list):
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
part = create_a2ui_part({"messages": messages})
data = getattr(getattr(part, "root", part), "data", messages)
metadata = getattr(getattr(part, "root", part), "metadata", {}) or {}
return {
"mime_type": metadata.get("mimeType", "application/json+a2ui"),
"messages": messages,
"a2ui_part": data,
}
@@ -184,6 +184,41 @@ class ToolRegistry:
return available
def list_tools_with_allowed_filter(self, context: Optional[Dict[str, Any]] = None) -> List[str]:
"""List tool names filtered by ALLOWED_TOOLS environment variable.
This applies the canonical ALLOWED_TOOLS filter to prevent tool
name collisions in multi-environment agent systems.
Args:
context: Optional context for availability checks
Returns:
List of filtered tool names
"""
try:
# Lazy import to avoid circular dependencies
from ..allowed_tools_filter import filter_tools_with_allowed_tools
# Get all available tool names
all_tools = self.list_tools()
# Apply ALLOWED_TOOLS filter with diagnostics
filtered_tools = filter_tools_with_allowed_tools(all_tools, log_diagnostics=True)
return sorted(filtered_tools)
except ImportError:
logging.warning("ALLOWED_TOOLS filter not available, returning all tools")
return self.list_tools()
except ValueError:
# Preserve strict ALLOWED_TOOLS semantics (e.g., CI unknown tools, empty value)
raise
except Exception as e:
logging.error("Error applying ALLOWED_TOOLS filter: %s", e)
# Fallback to all tools on error
return self.list_tools()
def get_all(self) -> Dict[str, Union[BaseTool, Callable]]:
"""Get all registered tools as a dict. Thread-safe."""
with self._lock:
@@ -381,3 +416,24 @@ def list_available_tools(context: Optional[Dict[str, Any]] = None) -> List[Union
List of available tools
"""
return get_registry().list_available_tools(context)
def list_tools_with_allowed_filter(context: Optional[Dict[str, Any]] = None) -> List[str]:
"""List tool names filtered by ALLOWED_TOOLS from the global registry.
This is the canonical entry point for applying ALLOWED_TOOLS filtering
across the PraisonAI ecosystem.
Args:
context: Optional context for availability checks
Returns:
List of filtered tool names
"""
return get_registry().list_tools_with_allowed_filter(context)
# Backward compatibility alias
def list_tools_with_hermes_filter(context: Optional[Dict[str, Any]] = None) -> List[str]:
"""Backward compatibility alias for list_tools_with_allowed_filter."""
return list_tools_with_allowed_filter(context)
@@ -1,126 +1,41 @@
"""
A2UI (Agent-to-User Interface) Protocol Integration for PraisonAI Agents
A2UI integration via Google's a2ui-agent-sdk (optional).
This module provides A2UI protocol support, enabling PraisonAI Agents
to generate rich, interactive UIs declaratively via JSON.
A2UI is Google's open standard that allows agents to "speak UI" by sending
declarative JSON describing UI components, which clients then render natively.
Usage:
from praisonaiagents.ui.a2ui import (
# Message types
CreateSurfaceMessage,
UpdateComponentsMessage,
UpdateDataModelMessage,
DeleteSurfaceMessage,
# Component types
TextComponent,
ImageComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
# Extension helpers
create_a2ui_part,
is_a2ui_part,
)
Declarative agent UI uses the official SDK — not reimplemented in PraisonAI core.
Install: pip install praisonaiagents[a2ui]
"""
from praisonaiagents.ui.a2ui.types import (
# Data binding
PathBinding,
resolve_string_or_path,
# Action types
ActionContext,
Action,
# Children types
ChildrenTemplate,
# Component types
TextComponent,
ImageComponent,
IconComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
ListComponent,
TextFieldComponent,
CheckBoxComponent,
SliderComponent,
DateTimeInputComponent,
# Message types
CreateSurfaceMessage,
UpdateComponentsMessage,
UpdateDataModelMessage,
DeleteSurfaceMessage,
# Data part
A2UIDataPart,
)
from praisonaiagents.ui.a2ui.extension import (
A2UI_MIME_TYPE,
A2UI_EXTENSION_URI,
STANDARD_CATALOG_ID,
from praisonaiagents.ui.a2ui.adapter import (
create_a2ui_part,
generate_a2ui_system_prompt,
get_schema_manager,
is_a2ui_part,
get_a2ui_agent_extension,
AgentExtension,
)
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.agent import A2UIAgent
from praisonaiagents.ui.a2ui.templates import (
ChatTemplate,
ListTemplate,
FormTemplate,
DashboardTemplate,
parse_a2ui_response,
)
__all__ = [
# Data binding
"PathBinding",
"resolve_string_or_path",
# Action types
"ActionContext",
"Action",
# Children types
"ChildrenTemplate",
# Component types
"TextComponent",
"ImageComponent",
"IconComponent",
"ButtonComponent",
"CardComponent",
"RowComponent",
"ColumnComponent",
"ListComponent",
"TextFieldComponent",
"CheckBoxComponent",
"SliderComponent",
"DateTimeInputComponent",
# Message types
"CreateSurfaceMessage",
"UpdateComponentsMessage",
"UpdateDataModelMessage",
"DeleteSurfaceMessage",
# Data part
"A2UIDataPart",
# Extension
"A2UI_MIME_TYPE",
"A2UI_EXTENSION_URI",
"STANDARD_CATALOG_ID",
"A2UI",
"create_a2ui_part",
"generate_a2ui_system_prompt",
"get_schema_manager",
"is_a2ui_part",
"get_a2ui_agent_extension",
"AgentExtension",
# Surface builder
"Surface",
# Agent wrapper
"A2UIAgent",
# Templates
"ChatTemplate",
"ListTemplate",
"FormTemplate",
"DashboardTemplate",
"parse_a2ui_response",
]
class A2UI:
"""
Optional A2UI facade (lazy — imports a2ui-agent-sdk on first use).
Example:
from praisonaiagents.ui import A2UI
part = A2UI.create_part({"createSurface": {...}})
prompt = A2UI.system_prompt("You are a helpful assistant.")
"""
create_part = staticmethod(create_a2ui_part)
is_part = staticmethod(is_a2ui_part)
parse_response = staticmethod(parse_a2ui_response)
schema_manager = staticmethod(get_schema_manager)
system_prompt = staticmethod(generate_a2ui_system_prompt)
@@ -0,0 +1,89 @@
"""
Thin adapter over Google's a2ui-agent-sdk (optional dependency).
Install: pip install praisonaiagents[a2ui]
Spec: https://github.com/google/A2UI
"""
from __future__ import annotations
from typing import Any, Dict, List, Optional
_A2UI_INSTALL_HINT = (
"A2UI support requires the optional dependency. "
"Install with: pip install praisonaiagents[a2ui]"
)
def _import(name: str):
try:
import importlib
return importlib.import_module(name)
except ImportError as exc:
raise ImportError(
f"{_A2UI_INSTALL_HINT} "
f"(failed to import '{name}'; "
"if already installed, ensure a2a-sdk>=0.3.0,<1.0.0 and not a2a-sdk 1.x)"
) from exc
def create_a2ui_part(a2ui_data: Dict[str, Any]) -> Any:
"""Wrap A2UI payload for A2A transport (MIME: application/json+a2ui)."""
parts = _import("a2ui.a2a.parts")
return parts.create_a2ui_part(a2ui_data)
def is_a2ui_part(part: Any) -> bool:
"""Return True if an A2A part contains A2UI data."""
parts = _import("a2ui.a2a.parts")
return parts.is_a2ui_part(part)
def parse_a2ui_response(text: str) -> List[Any]:
"""Parse and split LLM text into A2A parts (text + validated A2UI JSON)."""
parser = _import("a2ui.parser.parser")
return parser.parse_response(text)
def get_schema_manager(
version: str = "0.9",
catalogs: Optional[List[Any]] = None,
accepts_inline_catalogs: bool = False,
) -> Any:
"""Create an A2uiSchemaManager for LLM system prompts and validation."""
constants = _import("a2ui.schema.constants")
manager = _import("a2ui.schema.manager")
basic = _import("a2ui.basic_catalog.provider")
version_const = constants.VERSION_0_9 if version == "0.9" else version
catalog_configs = catalogs
if catalog_configs is None:
catalog_configs = [basic.BasicCatalog.get_config(version=version_const)]
return manager.A2uiSchemaManager(
version=version_const,
catalogs=catalog_configs,
accepts_inline_catalogs=accepts_inline_catalogs,
)
def generate_a2ui_system_prompt(
role_description: str,
workflow_description: str = "",
ui_description: str = "",
*,
version: str = "0.9",
include_schema: bool = True,
include_examples: bool = True,
) -> str:
"""Build an LLM system prompt with embedded A2UI schema and examples."""
schema_manager = get_schema_manager(version=version)
return schema_manager.generate_system_prompt(
role_description=role_description,
workflow_description=workflow_description,
ui_description=ui_description,
include_schema=include_schema,
include_examples=include_examples,
)
@@ -1,358 +0,0 @@
"""
A2UI Agent Integration
Provides A2UIAgent wrapper for generating A2UI responses from PraisonAI agents.
"""
import uuid
from typing import Any, Dict, List, Optional, TYPE_CHECKING
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import (
PathBinding,
A2UIDataPart,
)
from praisonaiagents.ui.a2ui.extension import (
create_a2ui_part,
STANDARD_CATALOG_ID,
)
if TYPE_CHECKING:
from praisonaiagents import Agent
class A2UIAgent:
"""
A2UI wrapper for PraisonAI agents.
Provides methods to generate A2UI responses from agent interactions.
Example:
>>> from praisonaiagents import Agent
>>> from praisonaiagents.ui.a2ui import A2UIAgent
>>>
>>> agent = Agent(name="Assistant", role="Helper", goal="Help users")
>>> a2ui_agent = A2UIAgent(agent=agent)
>>>
>>> # Render a simple text response
>>> messages = a2ui_agent.render_text("Hello, World!")
>>>
>>> # Render a list of items
>>> messages = a2ui_agent.render_list(
... title="Results",
... items=[{"name": "Item 1"}, {"name": "Item 2"}]
... )
"""
def __init__(
self,
agent: "Agent",
surface_id: Optional[str] = None,
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize A2UIAgent wrapper.
Args:
agent: The PraisonAI Agent to wrap
surface_id: Unique ID for the UI surface (auto-generated if not provided)
catalog_id: The component catalog to use
"""
self.agent = agent
self.surface_id = surface_id or f"surface-{uuid.uuid4().hex[:8]}"
self.catalog_id = catalog_id
self._surface: Optional[Surface] = None
def get_surface(self) -> Surface:
"""
Get the underlying Surface object.
Creates a new Surface if one doesn't exist.
Returns:
The Surface object
"""
if self._surface is None:
self._surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
return self._surface
def _reset_surface(self) -> Surface:
"""Reset and return a new Surface."""
self._surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
return self._surface
# =========================================================================
# Rendering methods
# =========================================================================
def render_text(
self,
text: str,
title: Optional[str] = None,
usage_hint: str = "body",
) -> List[Dict[str, Any]]:
"""
Render a simple text response as A2UI.
Args:
text: The text content to display
title: Optional title heading
usage_hint: Text style hint (h1, h2, body, caption)
Returns:
List of A2UI message dictionaries
"""
surface = self._reset_surface()
children = []
if title:
surface.text(id="title", text=title, usage_hint="h1")
children.append("title")
surface.text(id="content", text=text, usage_hint=usage_hint)
children.append("content")
# Add root column
surface.column(id="root", children=children)
return surface.to_messages()
def render_list(
self,
title: str,
items: List[Dict[str, Any]],
item_title_key: str = "title",
item_description_key: str = "description",
item_image_key: Optional[str] = None,
item_action: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""
Render a list of items as A2UI cards.
Args:
title: List title
items: List of item dictionaries
item_title_key: Key for item title in each dict
item_description_key: Key for item description
item_image_key: Optional key for item image URL
item_action: Optional action name for item buttons
Returns:
List of A2UI message dictionaries
"""
surface = self._reset_surface()
# Title
surface.text(id="list-title", text=title, usage_hint="h1")
# Create card for each item
card_ids = []
for i, item in enumerate(items):
card_id = f"card-{i}"
content_id = f"card-content-{i}"
title_id = f"item-title-{i}"
desc_id = f"item-desc-{i}"
# Item title
surface.text(
id=title_id,
text=item.get(item_title_key, f"Item {i+1}"),
usage_hint="h3"
)
# Item description
surface.text(
id=desc_id,
text=item.get(item_description_key, ""),
usage_hint="body"
)
content_children = [title_id, desc_id]
# Optional action button
if item_action:
btn_id = f"btn-{i}"
btn_text_id = f"btn-text-{i}"
surface.text(id=btn_text_id, text="Select")
surface.button(
id=btn_id,
child=btn_text_id,
action_name=item_action,
action_context=[{"key": "index", "value": i}],
primary=True
)
content_children.append(btn_id)
# Card content column
surface.column(id=content_id, children=content_children)
# Card
surface.card(id=card_id, child=content_id)
card_ids.append(card_id)
# List column
surface.column(id="item-list", children=card_ids)
# Root column
surface.column(id="root", children=["list-title", "item-list"])
# Set data
surface.set_data("items", items)
return surface.to_messages()
def render_form(
self,
title: str,
fields: List[Dict[str, str]],
submit_action: str = "submit",
submit_label: str = "Submit",
) -> List[Dict[str, Any]]:
"""
Render a form as A2UI.
Args:
title: Form title
fields: List of field definitions with id, label, type
submit_action: Action name for submit button
submit_label: Label for submit button
Returns:
List of A2UI message dictionaries
"""
surface = self._reset_surface()
# Title
surface.text(id="form-title", text=title, usage_hint="h1")
# Fields
field_ids = []
form_data = {}
for field in fields:
field_id = field.get("id", f"field-{len(field_ids)}")
label = field.get("label", field_id)
field_type = field.get("type", "text")
surface.text_field(
id=field_id,
label=label,
text=PathBinding(path=f"/{field_id}"),
field_type=field_type,
)
field_ids.append(field_id)
form_data[field_id] = field.get("default", "")
# Submit button
surface.text(id="submit-text", text=submit_label)
# Build action context from fields
action_context = [
{"key": f["id"], "value": {"path": f"/{f['id']}"}}
for f in fields
]
surface.button(
id="submit-btn",
child="submit-text",
action_name=submit_action,
action_context=action_context,
primary=True,
)
field_ids.append("submit-btn")
# Form column
surface.column(id="form-fields", children=field_ids)
# Root column
surface.column(id="root", children=["form-title", "form-fields"])
# Set initial form data
for key, value in form_data.items():
surface.set_data(key, value)
return surface.to_messages()
# =========================================================================
# Agent interaction methods
# =========================================================================
async def chat_async(self, message: str) -> str:
"""
Send a message to the agent and get a response.
Args:
message: The message to send
Returns:
The agent's response text
"""
# Use the agent's chat method
if hasattr(self.agent, 'chat'):
response = self.agent.chat(message)
return response
elif hasattr(self.agent, 'run'):
response = self.agent.run(message)
return response
else:
raise NotImplementedError("Agent does not have chat or run method")
def chat(self, message: str) -> str:
"""
Send a message to the agent and get a response (sync).
Args:
message: The message to send
Returns:
The agent's response text
"""
if hasattr(self.agent, 'chat'):
return self.agent.chat(message)
elif hasattr(self.agent, 'run'):
return self.agent.run(message)
else:
raise NotImplementedError("Agent does not have chat or run method")
# =========================================================================
# Export methods
# =========================================================================
def to_messages(self) -> List[Dict[str, Any]]:
"""
Get the current surface as A2UI messages.
Returns:
List of A2UI message dictionaries
"""
return self.get_surface().to_messages()
def to_json(self, indent: Optional[int] = None) -> str:
"""
Get the current surface as JSON string.
Args:
indent: JSON indentation level
Returns:
JSON string of A2UI messages
"""
return self.get_surface().to_json(indent=indent)
def to_a2a_part(self) -> A2UIDataPart:
"""
Get the current surface as an A2A DataPart.
Returns:
A2UIDataPart with A2UI MIME type
"""
messages = self.to_messages()
# For A2A, we send messages as a list
return create_a2ui_part({"messages": messages})
@@ -1,115 +0,0 @@
"""
A2UI Extension for A2A Protocol
Provides helpers for integrating A2UI with the A2A (Agent-to-Agent) protocol.
"""
from dataclasses import dataclass
from typing import Any, Dict, Optional
from praisonaiagents.ui.a2ui.types import A2UIDataPart
# =============================================================================
# Constants
# =============================================================================
A2UI_EXTENSION_URI = "https://a2ui.org/a2a-extension/a2ui/v0.9"
A2UI_MIME_TYPE = "application/json+a2ui"
STANDARD_CATALOG_ID = "https://raw.githubusercontent.com/google/A2UI/refs/heads/main/specification/0.9/json/standard_catalog_definition.json"
# =============================================================================
# Agent Extension Type
# =============================================================================
@dataclass
class AgentExtension:
"""
A2A Agent Extension configuration.
Describes an extension that an agent supports.
"""
uri: str
description: str = ""
params: Optional[Dict[str, Any]] = None
# =============================================================================
# Helper Functions
# =============================================================================
def create_a2ui_part(a2ui_data: Dict[str, Any]) -> A2UIDataPart:
"""
Create an A2UI data part for A2A integration.
Wraps A2UI data in an A2UIDataPart with the appropriate MIME type.
Args:
a2ui_data: The A2UI data dictionary (e.g., createSurface, updateComponents)
Returns:
An A2UIDataPart with the A2UI MIME type in metadata
Example:
>>> part = create_a2ui_part({"createSurface": {"surfaceId": "main", "catalogId": "..."}})
>>> part.metadata["mimeType"]
'application/json+a2ui'
"""
return A2UIDataPart(
data=a2ui_data,
metadata={"mimeType": A2UI_MIME_TYPE}
)
def is_a2ui_part(part: A2UIDataPart) -> bool:
"""
Check if a data part contains A2UI data.
Args:
part: The data part to check
Returns:
True if the part contains A2UI data (has A2UI MIME type), False otherwise
Example:
>>> part = create_a2ui_part({"createSurface": {...}})
>>> is_a2ui_part(part)
True
"""
if not isinstance(part, A2UIDataPart):
return False
if part.metadata is None:
return False
return part.metadata.get("mimeType") == A2UI_MIME_TYPE
def get_a2ui_agent_extension(
accepts_inline_custom_catalog: bool = False,
) -> AgentExtension:
"""
Create the A2UI AgentExtension configuration.
Returns an AgentExtension that can be added to an A2A Agent Card
to indicate A2UI support.
Args:
accepts_inline_custom_catalog: Whether the agent accepts inline custom catalogs
Returns:
The configured A2UI AgentExtension
Example:
>>> ext = get_a2ui_agent_extension()
>>> ext.uri
'https://a2ui.org/a2a-extension/a2ui/v0.9'
"""
params: Optional[Dict[str, Any]] = None
if accepts_inline_custom_catalog:
params = {"acceptsInlineCustomCatalog": True}
return AgentExtension(
uri=A2UI_EXTENSION_URI,
description="Provides agent-driven UI using the A2UI JSON format.",
params=params
)
@@ -1,364 +0,0 @@
"""
A2UI Surface Builder
Provides a fluent API for building A2UI surfaces with components and data.
"""
import json
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
from praisonaiagents.ui.a2ui.types import (
Component,
TextComponent,
ImageComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
ListComponent,
TextFieldComponent,
Action,
ActionContext,
PathBinding,
CreateSurfaceMessage,
UpdateComponentsMessage,
UpdateDataModelMessage,
StringOrPath,
ChildrenType,
)
class Surface:
"""
A2UI Surface builder for creating rich agent-generated UIs.
Provides a fluent API for adding components and data, then
generating the A2UI JSON messages.
Example:
>>> surface = Surface(surface_id="main")
>>> surface.text(id="title", text="Hello World", usage_hint="h1")
>>> surface.button(id="submit", child="submit-text", action_name="submit")
>>> surface.set_data("title", "Hello World")
>>> messages = surface.to_messages()
"""
def __init__(
self,
surface_id: str,
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize a new Surface.
Args:
surface_id: Unique identifier for this surface
catalog_id: The component catalog to use (defaults to standard catalog)
"""
self.surface_id = surface_id
self.catalog_id = catalog_id
self.components: List[Component] = []
self.data: Dict[str, Any] = {}
def add(self, component: Component) -> "Surface":
"""
Add a component to the surface.
Args:
component: The component to add
Returns:
self for method chaining
"""
self.components.append(component)
return self
def set_data(self, key: str, value: Any) -> "Surface":
"""
Set a value in the data model.
Args:
key: The key in the data model
value: The value to set
Returns:
self for method chaining
"""
self.data[key] = value
return self
# =========================================================================
# Helper methods for common components
# =========================================================================
def text(
self,
id: str,
text: StringOrPath,
usage_hint: Optional[str] = None,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Text component.
Args:
id: Component ID
text: Text content or path binding
usage_hint: Style hint (h1, h2, h3, body, caption)
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(TextComponent(
id=id,
text=text,
usage_hint=usage_hint,
weight=weight,
))
def image(
self,
id: str,
url: StringOrPath,
fit: Optional[str] = None,
usage_hint: Optional[str] = None,
weight: Optional[float] = None,
) -> "Surface":
"""
Add an Image component.
Args:
id: Component ID
url: Image URL or path binding
fit: Image fit (contain, cover, fill, none, scale-down)
usage_hint: Size hint (icon, avatar, smallFeature, etc.)
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(ImageComponent(
id=id,
url=url,
fit=fit,
usage_hint=usage_hint,
weight=weight,
))
def button(
self,
id: str,
child: str,
action_name: Optional[str] = None,
action_context: Optional[List[Dict[str, Any]]] = None,
primary: bool = False,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Button component.
Args:
id: Component ID
child: Child component ID (usually a Text component)
action_name: Name of the action to trigger
action_context: List of {key, value} dicts for action context
primary: Whether this is a primary button
weight: Flex weight
Returns:
self for method chaining
"""
action = None
if action_name:
context = []
if action_context:
for ctx in action_context:
value = ctx.get("value")
if isinstance(value, dict) and "path" in value:
value = PathBinding(path=value["path"])
context.append(ActionContext(key=ctx["key"], value=value))
action = Action(name=action_name, context=context)
return self.add(ButtonComponent(
id=id,
child=child,
action=action,
primary=primary,
weight=weight,
))
def card(
self,
id: str,
child: str,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Card component.
Args:
id: Component ID
child: Child component ID
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(CardComponent(
id=id,
child=child,
weight=weight,
))
def row(
self,
id: str,
children: ChildrenType,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Row (horizontal layout) component.
Args:
id: Component ID
children: List of child IDs or ChildrenTemplate
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(RowComponent(
id=id,
children=children,
weight=weight,
))
def column(
self,
id: str,
children: ChildrenType,
weight: Optional[float] = None,
) -> "Surface":
"""
Add a Column (vertical layout) component.
Args:
id: Component ID
children: List of child IDs or ChildrenTemplate
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(ColumnComponent(
id=id,
children=children,
weight=weight,
))
def list(
self,
id: str,
children: ChildrenType,
direction: str = "vertical",
weight: Optional[float] = None,
) -> "Surface":
"""
Add a List component.
Args:
id: Component ID
children: List of child IDs or ChildrenTemplate for dynamic lists
direction: "vertical" or "horizontal"
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(ListComponent(
id=id,
children=children,
direction=direction,
weight=weight,
))
def text_field(
self,
id: str,
label: StringOrPath,
text: StringOrPath,
field_type: str = "text",
weight: Optional[float] = None,
) -> "Surface":
"""
Add a TextField input component.
Args:
id: Component ID
label: Field label
text: Text value or path binding
field_type: Input type (text, number, email, etc.)
weight: Flex weight
Returns:
self for method chaining
"""
return self.add(TextFieldComponent(
id=id,
label=label,
text=text,
field_type=field_type,
weight=weight,
))
# =========================================================================
# Message generation
# =========================================================================
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for this surface.
Returns:
List of A2UI message dictionaries
"""
messages: List[Dict[str, Any]] = []
# 1. CreateSurface message
create_msg = CreateSurfaceMessage(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
messages.append(create_msg.to_dict())
# 2. UpdateComponents message (if we have components)
if self.components:
update_msg = UpdateComponentsMessage(
surface_id=self.surface_id,
components=self.components,
)
messages.append(update_msg.to_dict())
# 3. UpdateDataModel message (if we have data)
if self.data:
data_msg = UpdateDataModelMessage(
surface_id=self.surface_id,
value=self.data,
)
messages.append(data_msg.to_dict())
return messages
def to_json(self, indent: Optional[int] = None) -> str:
"""
Generate A2UI messages as a JSON string.
Args:
indent: JSON indentation level (None for compact)
Returns:
JSON string of A2UI messages
"""
return json.dumps(self.to_messages(), indent=indent)
@@ -1,17 +0,0 @@
"""
A2UI Templates
Pre-built UI templates for common use cases.
"""
from praisonaiagents.ui.a2ui.templates.chat import ChatTemplate
from praisonaiagents.ui.a2ui.templates.list import ListTemplate
from praisonaiagents.ui.a2ui.templates.form import FormTemplate
from praisonaiagents.ui.a2ui.templates.dashboard import DashboardTemplate
__all__ = [
"ChatTemplate",
"ListTemplate",
"FormTemplate",
"DashboardTemplate",
]
@@ -1,122 +0,0 @@
"""
Chat Template for A2UI
Provides a conversational chat UI template.
"""
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class ChatTemplate:
"""
Chat UI template for conversational interfaces.
Creates a chat-style UI with user and agent messages.
Example:
>>> template = ChatTemplate(surface_id="chat")
>>> template.add_user_message("Hello!")
>>> template.add_agent_message("Hi there! How can I help?")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "chat",
catalog_id: str = STANDARD_CATALOG_ID,
title: Optional[str] = None,
):
"""
Initialize ChatTemplate.
Args:
surface_id: Unique ID for the surface
catalog_id: Component catalog to use
title: Optional title for the chat
"""
self.surface_id = surface_id
self.catalog_id = catalog_id
self.title = title
self.messages: List[Dict[str, Any]] = []
def add_user_message(self, content: str) -> "ChatTemplate":
"""
Add a user message.
Args:
content: Message content
Returns:
self for chaining
"""
self.messages.append({
"role": "user",
"content": content,
})
return self
def add_agent_message(self, content: str) -> "ChatTemplate":
"""
Add an agent message.
Args:
content: Message content
Returns:
self for chaining
"""
self.messages.append({
"role": "agent",
"content": content,
})
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the chat.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
children = []
# Title if provided
if self.title:
surface.text(id="chat-title", text=self.title, usage_hint="h1")
children.append("chat-title")
# Message components
msg_ids = []
for i, msg in enumerate(self.messages):
msg_id = f"msg-{i}"
role = msg["role"]
content = msg["content"]
# Style based on role
if role == "user":
surface.text(id=msg_id, text=f"You: {content}", usage_hint="body")
else:
surface.text(id=msg_id, text=f"Agent: {content}", usage_hint="body")
msg_ids.append(msg_id)
# Messages column
if msg_ids:
surface.column(id="messages", children=msg_ids)
children.append("messages")
# Root column
surface.column(id="root", children=children)
# Set data
surface.set_data("messages", self.messages)
return surface.to_messages()
@@ -1,128 +0,0 @@
"""
Dashboard Template for A2UI
Provides a dashboard UI template with multiple panels.
"""
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class DashboardTemplate:
"""
Dashboard UI template with multiple panels.
Creates a dashboard layout with cards for each panel.
Example:
>>> template = DashboardTemplate(surface_id="dashboard", title="My Dashboard")
>>> template.add_panel(id="stats", title="Statistics", content="100 items")
>>> template.add_panel(id="chart", title="Chart", content="[Chart here]")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "dashboard",
title: str = "Dashboard",
catalog_id: str = STANDARD_CATALOG_ID,
columns: int = 2,
):
"""
Initialize DashboardTemplate.
Args:
surface_id: Unique ID for the surface
title: Dashboard title
catalog_id: Component catalog to use
columns: Number of columns in the grid
"""
self.surface_id = surface_id
self.title = title
self.catalog_id = catalog_id
self.columns = columns
self.panels: List[Dict[str, Any]] = []
def add_panel(
self,
id: str,
title: str,
content: str,
weight: Optional[float] = None,
) -> "DashboardTemplate":
"""
Add a panel to the dashboard.
Args:
id: Panel ID
title: Panel title
content: Panel content text
weight: Optional flex weight
Returns:
self for chaining
"""
self.panels.append({
"id": id,
"title": title,
"content": content,
"weight": weight,
})
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the dashboard.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
# Title
surface.text(id="dashboard-title", text=self.title, usage_hint="h1")
# Create cards for each panel
card_ids = []
for panel in self.panels:
panel_id = panel["id"]
content_id = f"{panel_id}-content"
title_id = f"{panel_id}-title"
text_id = f"{panel_id}-text"
# Panel title
surface.text(id=title_id, text=panel["title"], usage_hint="h3")
# Panel content
surface.text(id=text_id, text=panel["content"], usage_hint="body")
# Panel content column
surface.column(id=content_id, children=[title_id, text_id])
# Panel card
surface.card(id=panel_id, child=content_id, weight=panel.get("weight"))
card_ids.append(panel_id)
# Arrange panels in rows based on columns setting
row_ids = []
for i in range(0, len(card_ids), self.columns):
row_cards = card_ids[i:i + self.columns]
row_id = f"row-{i // self.columns}"
surface.row(id=row_id, children=row_cards)
row_ids.append(row_id)
# Panels container
surface.column(id="panels", children=row_ids)
# Root column
surface.column(id="root", children=["dashboard-title", "panels"])
# Set data
surface.set_data("panels", self.panels)
return surface.to_messages()
@@ -1,205 +0,0 @@
"""
Form Template for A2UI
Provides a form UI template for data collection.
"""
from typing import Any, Dict, List
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import PathBinding
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class FormTemplate:
"""
Form UI template for data collection.
Creates a form with various input fields and a submit button.
Example:
>>> template = FormTemplate(surface_id="form", title="Contact")
>>> template.add_text_field(id="name", label="Name")
>>> template.add_email_field(id="email", label="Email")
>>> template.set_submit_action("submit_form", "Send")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "form",
title: str = "Form",
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize FormTemplate.
Args:
surface_id: Unique ID for the surface
title: Form title
catalog_id: Component catalog to use
"""
self.surface_id = surface_id
self.title = title
self.catalog_id = catalog_id
self.fields: List[Dict[str, Any]] = []
self.submit_action = "submit"
self.submit_label = "Submit"
def add_text_field(
self,
id: str,
label: str,
default: str = "",
required: bool = False,
) -> "FormTemplate":
"""
Add a text input field.
Args:
id: Field ID
label: Field label
default: Default value
required: Whether field is required
Returns:
self for chaining
"""
self.fields.append({
"id": id,
"label": label,
"type": "text",
"default": default,
"required": required,
})
return self
def add_number_field(
self,
id: str,
label: str,
default: float = 0,
required: bool = False,
) -> "FormTemplate":
"""
Add a number input field.
Args:
id: Field ID
label: Field label
default: Default value
required: Whether field is required
Returns:
self for chaining
"""
self.fields.append({
"id": id,
"label": label,
"type": "number",
"default": default,
"required": required,
})
return self
def add_email_field(
self,
id: str,
label: str,
default: str = "",
required: bool = False,
) -> "FormTemplate":
"""
Add an email input field.
Args:
id: Field ID
label: Field label
default: Default value
required: Whether field is required
Returns:
self for chaining
"""
self.fields.append({
"id": id,
"label": label,
"type": "email",
"default": default,
"required": required,
})
return self
def set_submit_action(
self,
action: str,
label: str = "Submit",
) -> "FormTemplate":
"""
Set the submit button action.
Args:
action: Action name
label: Button label
Returns:
self for chaining
"""
self.submit_action = action
self.submit_label = label
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the form.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
# Title
surface.text(id="form-title", text=self.title, usage_hint="h1")
# Fields
field_ids = []
for field in self.fields:
field_id = field["id"]
surface.text_field(
id=field_id,
label=field["label"],
text=PathBinding(path=f"/{field_id}"),
field_type=field["type"],
)
field_ids.append(field_id)
# Set default value
surface.set_data(field_id, field.get("default", ""))
# Submit button
surface.text(id="submit-text", text=self.submit_label)
action_context = [
{"key": f["id"], "value": {"path": f"/{f['id']}"}}
for f in self.fields
]
surface.button(
id="submit-btn",
child="submit-text",
action_name=self.submit_action,
action_context=action_context,
primary=True,
)
field_ids.append("submit-btn")
# Form fields column
surface.column(id="fields", children=field_ids)
# Root column
surface.column(id="root", children=["form-title", "fields"])
return surface.to_messages()
@@ -1,147 +0,0 @@
"""
List Template for A2UI
Provides a list UI template for displaying items.
"""
from typing import Any, Dict, List, Optional
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
class ListTemplate:
"""
List UI template for displaying items.
Creates a list of cards with title, description, and optional image.
Example:
>>> template = ListTemplate(surface_id="list", title="Results")
>>> template.add_item(title="Item 1", description="First item")
>>> template.add_item(title="Item 2", description="Second item")
>>> messages = template.to_messages()
"""
def __init__(
self,
surface_id: str = "list",
title: str = "Items",
catalog_id: str = STANDARD_CATALOG_ID,
):
"""
Initialize ListTemplate.
Args:
surface_id: Unique ID for the surface
title: List title
catalog_id: Component catalog to use
"""
self.surface_id = surface_id
self.title = title
self.catalog_id = catalog_id
self.items: List[Dict[str, Any]] = []
def add_item(
self,
title: str,
description: str = "",
image_url: Optional[str] = None,
action: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> "ListTemplate":
"""
Add an item to the list.
Args:
title: Item title
description: Item description
image_url: Optional image URL
action: Optional action name for item button
metadata: Optional additional metadata
Returns:
self for chaining
"""
item = {
"title": title,
"description": description,
}
if image_url:
item["image_url"] = image_url
if action:
item["action"] = action
if metadata:
item.update(metadata)
self.items.append(item)
return self
def to_messages(self) -> List[Dict[str, Any]]:
"""
Generate A2UI messages for the list.
Returns:
List of A2UI message dictionaries
"""
surface = Surface(
surface_id=self.surface_id,
catalog_id=self.catalog_id,
)
# Title
surface.text(id="list-title", text=self.title, usage_hint="h1")
# Create cards for each item
card_ids = []
for i, item in enumerate(self.items):
card_id = f"card-{i}"
content_id = f"content-{i}"
title_id = f"title-{i}"
desc_id = f"desc-{i}"
# Item title
surface.text(id=title_id, text=item["title"], usage_hint="h3")
# Item description
surface.text(id=desc_id, text=item.get("description", ""), usage_hint="body")
content_children = [title_id, desc_id]
# Optional image
if item.get("image_url"):
img_id = f"img-{i}"
surface.image(id=img_id, url=item["image_url"], usage_hint="smallFeature")
content_children.insert(0, img_id)
# Optional action button
if item.get("action"):
btn_id = f"btn-{i}"
btn_text_id = f"btn-text-{i}"
surface.text(id=btn_text_id, text="Select")
surface.button(
id=btn_id,
child=btn_text_id,
action_name=item["action"],
action_context=[{"key": "index", "value": i}],
primary=True,
)
content_children.append(btn_id)
# Card content
surface.column(id=content_id, children=content_children)
# Card
surface.card(id=card_id, child=content_id)
card_ids.append(card_id)
# Items column
surface.column(id="items", children=card_ids)
# Root column
surface.column(id="root", children=["list-title", "items"])
# Set data
surface.set_data("items", self.items)
return surface.to_messages()
@@ -1,523 +0,0 @@
"""
A2UI Protocol Types
Pydantic-style classes for the A2UI (Agent-to-User Interface) protocol.
Based on the A2UI Specification v0.9.
"""
from dataclasses import dataclass, field
from typing import Any, Dict, List, Optional, Union
from enum import Enum
# =============================================================================
# Data Binding Types
# =============================================================================
@dataclass
class PathBinding:
"""
A reference to a value in the data model.
Used for data binding in A2UI components.
"""
path: str
def to_dict(self) -> Dict[str, str]:
return {"path": self.path}
# Type alias for values that can be literal or path-bound
StringOrPath = Union[str, PathBinding]
NumberOrPath = Union[int, float, PathBinding]
BooleanOrPath = Union[bool, PathBinding]
def resolve_string_or_path(value: StringOrPath) -> Union[str, Dict[str, str]]:
"""Resolve a StringOrPath to its serialized form."""
if isinstance(value, PathBinding):
return value.to_dict()
return value
def resolve_number_or_path(value: NumberOrPath) -> Union[int, float, Dict[str, str]]:
"""Resolve a NumberOrPath to its serialized form."""
if isinstance(value, PathBinding):
return value.to_dict()
return value
def resolve_boolean_or_path(value: BooleanOrPath) -> Union[bool, Dict[str, str]]:
"""Resolve a BooleanOrPath to its serialized form."""
if isinstance(value, PathBinding):
return value.to_dict()
return value
# =============================================================================
# Action Types
# =============================================================================
@dataclass
class ActionContext:
"""
A key-value pair for action context.
Used to pass data from UI to action handlers.
"""
key: str
value: Union[str, int, float, bool, PathBinding]
def to_dict(self) -> Dict[str, Any]:
if isinstance(self.value, PathBinding):
return {"key": self.key, "value": self.value.to_dict()}
return {"key": self.key, "value": self.value}
@dataclass
class Action:
"""
An action triggered by a UI component (e.g., button click).
"""
name: str
context: List[ActionContext] = field(default_factory=list)
def to_dict(self) -> Dict[str, Any]:
return {
"name": self.name,
"context": [c.to_dict() for c in self.context]
}
# =============================================================================
# Children Types
# =============================================================================
@dataclass
class ChildrenTemplate:
"""
A template for generating dynamic children from a data model list.
"""
component_id: str
path: str
def to_dict(self) -> Dict[str, str]:
return {
"componentId": self.component_id,
"path": self.path
}
# Type alias for children - either explicit list or template
ChildrenType = Union[List[str], ChildrenTemplate]
def resolve_children(children: ChildrenType) -> Union[List[str], Dict[str, str]]:
"""Resolve children to serialized form."""
if isinstance(children, ChildrenTemplate):
return children.to_dict()
return children
# =============================================================================
# Component Types
# =============================================================================
class UsageHint(str, Enum):
"""Text usage hints for styling."""
H1 = "h1"
H2 = "h2"
H3 = "h3"
H4 = "h4"
H5 = "h5"
CAPTION = "caption"
BODY = "body"
class ImageFit(str, Enum):
"""Image fit options."""
CONTAIN = "contain"
COVER = "cover"
FILL = "fill"
NONE = "none"
SCALE_DOWN = "scale-down"
class ImageUsageHint(str, Enum):
"""Image usage hints."""
ICON = "icon"
AVATAR = "avatar"
SMALL_FEATURE = "smallFeature"
MEDIUM_FEATURE = "mediumFeature"
LARGE_FEATURE = "largeFeature"
HEADER = "header"
@dataclass
class TextComponent:
"""Text display component."""
id: str
text: StringOrPath
usage_hint: Optional[str] = None
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Text",
"text": resolve_string_or_path(self.text)
}
if self.usage_hint:
result["usageHint"] = self.usage_hint
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ImageComponent:
"""Image display component."""
id: str
url: StringOrPath
fit: Optional[str] = None
usage_hint: Optional[str] = None
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Image",
"url": resolve_string_or_path(self.url)
}
if self.fit:
result["fit"] = self.fit
if self.usage_hint:
result["usageHint"] = self.usage_hint
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class IconComponent:
"""Icon display component."""
id: str
name: StringOrPath
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Icon",
"name": resolve_string_or_path(self.name)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ButtonComponent:
"""Button component with action."""
id: str
child: str
action: Optional[Action] = None
primary: bool = False
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Button",
"child": self.child
}
if self.primary:
result["primary"] = True
if self.action:
result["action"] = self.action.to_dict()
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class CardComponent:
"""Card container component."""
id: str
child: str
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Card",
"child": self.child
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class RowComponent:
"""Horizontal layout component."""
id: str
children: ChildrenType
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Row",
"children": resolve_children(self.children)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ColumnComponent:
"""Vertical layout component."""
id: str
children: ChildrenType
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Column",
"children": resolve_children(self.children)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class ListComponent:
"""List component for repeating items."""
id: str
children: ChildrenType
direction: str = "vertical"
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "List",
"direction": self.direction,
"children": resolve_children(self.children)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class TextFieldComponent:
"""Text input field component."""
id: str
label: StringOrPath
text: StringOrPath
field_type: str = "text"
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "TextField",
"label": resolve_string_or_path(self.label),
"text": resolve_string_or_path(self.text),
"type": self.field_type
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class CheckBoxComponent:
"""Checkbox input component."""
id: str
label: StringOrPath
checked: BooleanOrPath = False
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "CheckBox",
"label": resolve_string_or_path(self.label),
"checked": resolve_boolean_or_path(self.checked)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class SliderComponent:
"""Slider input component."""
id: str
min_value: NumberOrPath
max_value: NumberOrPath
value: NumberOrPath
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "Slider",
"min": resolve_number_or_path(self.min_value),
"max": resolve_number_or_path(self.max_value),
"value": resolve_number_or_path(self.value)
}
if self.weight is not None:
result["weight"] = self.weight
return result
@dataclass
class DateTimeInputComponent:
"""Date/time input component."""
id: str
label: StringOrPath
value: StringOrPath
enable_date: bool = True
enable_time: bool = False
weight: Optional[float] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"id": self.id,
"component": "DateTimeInput",
"label": resolve_string_or_path(self.label),
"value": resolve_string_or_path(self.value),
"enableDate": self.enable_date,
"enableTime": self.enable_time
}
if self.weight is not None:
result["weight"] = self.weight
return result
# Type alias for any component
Component = Union[
TextComponent,
ImageComponent,
IconComponent,
ButtonComponent,
CardComponent,
RowComponent,
ColumnComponent,
ListComponent,
TextFieldComponent,
CheckBoxComponent,
SliderComponent,
DateTimeInputComponent,
]
# =============================================================================
# Message Types
# =============================================================================
@dataclass
class CreateSurfaceMessage:
"""
Message to create a new UI surface.
Signals the client to create a new surface and begin rendering it.
"""
surface_id: str
catalog_id: str
def to_dict(self) -> Dict[str, Any]:
return {
"createSurface": {
"surfaceId": self.surface_id,
"catalogId": self.catalog_id
}
}
@dataclass
class UpdateComponentsMessage:
"""
Message to update surface components.
Updates a surface with a new set of components.
"""
surface_id: str
components: List[Component]
def to_dict(self) -> Dict[str, Any]:
return {
"updateComponents": {
"surfaceId": self.surface_id,
"components": [c.to_dict() for c in self.components]
}
}
@dataclass
class UpdateDataModelMessage:
"""
Message to update the data model.
Updates the data model for an existing surface.
"""
surface_id: str
value: Any
path: str = "/"
op: str = "replace"
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {
"updateDataModel": {
"surfaceId": self.surface_id,
"path": self.path,
"op": self.op,
"value": self.value
}
}
return result
@dataclass
class DeleteSurfaceMessage:
"""
Message to delete a surface.
Signals the client to delete the surface.
"""
surface_id: str
def to_dict(self) -> Dict[str, Any]:
return {
"deleteSurface": {
"surfaceId": self.surface_id
}
}
# =============================================================================
# Data Part Type
# =============================================================================
@dataclass
class A2UIDataPart:
"""
A2UI data part for A2A integration.
Wraps A2UI data with the appropriate MIME type.
"""
data: Dict[str, Any]
metadata: Optional[Dict[str, Any]] = None
def to_dict(self) -> Dict[str, Any]:
result: Dict[str, Any] = {"data": self.data}
if self.metadata:
result["metadata"] = self.metadata
return result
@@ -4,6 +4,8 @@ AG-UI Event Streaming
Provides utilities for streaming PraisonAI agent responses as AG-UI events.
"""
import asyncio
import json
import uuid
from dataclasses import dataclass, field
from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Set
@@ -256,6 +258,47 @@ async def async_stream_response(
yield create_run_error_event(str(e))
def stream_event_to_agui_events(
event: Any,
message_id: str,
buffer: EventBuffer,
) -> List[BaseEvent]:
"""Convert a PraisonAI StreamEvent into zero or more AG-UI events."""
from praisonaiagents.streaming.events import StreamEventType
if event.type == StreamEventType.DELTA_TEXT and event.content:
if not buffer.current_text_message_id:
buffer.start_text_message()
return [
TextMessageStartEvent(message_id=buffer.current_text_message_id, role="assistant"),
TextMessageContentEvent(
message_id=buffer.current_text_message_id, delta=event.content
),
]
return [
TextMessageContentEvent(
message_id=buffer.current_text_message_id, delta=event.content
)
]
if event.type == StreamEventType.TOOL_CALL_START and event.tool_call:
tool_call_id = event.tool_call.get("id") or str(uuid.uuid4())
tool_name = event.tool_call.get("name", "tool")
arguments = event.tool_call.get("arguments", {})
args_str = json.dumps(arguments) if isinstance(arguments, dict) else str(arguments)
parent_id = buffer.get_parent_message_id_for_tool_call() or message_id
buffer.start_tool_call(tool_call_id)
return list(create_tool_call_events(tool_call_id, tool_name, args_str, parent_id))
if event.type == StreamEventType.TOOL_CALL_RESULT and event.tool_call:
tool_call_id = event.tool_call.get("id") or str(uuid.uuid4())
content = event.content if event.content is not None else str(event.tool_call.get("result", ""))
buffer.end_tool_call(tool_call_id)
return [create_tool_result_event(tool_call_id, str(content), message_id)]
return []
async def async_stream_agent_response(
agent,
user_input: str,
@@ -278,37 +321,63 @@ async def async_stream_agent_response(
Yields:
AG-UI events
"""
import asyncio
# Emit run started
yield create_run_started_event(thread_id, run_id)
loop = asyncio.get_running_loop()
queue: asyncio.Queue = asyncio.Queue()
buffer = EventBuffer()
message_id = str(uuid.uuid4())
try:
# Check if agent has async chat method
if hasattr(agent, 'achat'):
response = await agent.achat(user_input)
elif hasattr(agent, 'chat'):
# Run sync chat in executor
loop = asyncio.get_event_loop()
response = await loop.run_in_executor(None, agent.chat, user_input)
else:
text_started = False
text_ended = False
original_emitter = getattr(agent, "stream_emitter", None)
def on_stream_event(event: Any) -> None:
loop.call_soon_threadsafe(queue.put_nowait, ("stream", event))
if original_emitter is not None:
original_emitter.add_callback(on_stream_event)
async def run_chat() -> Any:
try:
if hasattr(agent, "achat"):
return await agent.achat(user_input, stream=True)
if hasattr(agent, "chat"):
return await loop.run_in_executor(
None, lambda: agent.chat(user_input, stream=True)
)
raise ValueError("Agent must have 'chat' or 'achat' method")
# Emit text message events
yield TextMessageStartEvent(message_id=message_id, role="assistant")
if response:
finally:
loop.call_soon_threadsafe(queue.put_nowait, ("done", None))
chat_task = asyncio.create_task(run_chat())
try:
while True:
kind, payload = await queue.get()
if kind == "done":
break
for agui_event in stream_event_to_agui_events(payload, message_id, buffer):
if isinstance(agui_event, TextMessageStartEvent):
text_started = True
yield agui_event
response = await chat_task
if text_started and buffer.current_text_message_id and not text_ended:
yield TextMessageEndEvent(message_id=buffer.current_text_message_id)
text_ended = True
elif not text_started and response:
yield TextMessageStartEvent(message_id=message_id, role="assistant")
yield TextMessageContentEvent(message_id=message_id, delta=str(response))
yield TextMessageEndEvent(message_id=message_id)
# Emit run finished
yield TextMessageEndEvent(message_id=message_id)
yield create_run_finished_event(thread_id, run_id)
except Exception as e:
yield create_run_error_event(str(e))
finally:
if original_emitter is not None:
original_emitter.remove_callback(on_stream_event)
async def async_stream_agents_response(
@@ -331,8 +400,6 @@ async def async_stream_agents_response(
Yields:
AG-UI events
"""
import asyncio
# Emit run started
yield create_run_started_event(thread_id, run_id)
@@ -344,7 +411,7 @@ async def async_stream_agents_response(
result = await agents.astart(user_input)
elif hasattr(agents, 'start'):
# Run sync start in executor
loop = asyncio.get_event_loop()
loop = asyncio.get_running_loop()
result = await loop.run_in_executor(None, agents.start, user_input)
else:
raise ValueError("Agents must have 'start' or 'astart' method")
+20 -2
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.40"
version = "1.6.43"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -101,6 +101,22 @@ crawl = [
"playwright>=1.47.0",
]
# Sandbox dependencies
sandbox = [
"e2b-code-interpreter>=1.0.0",
]
# Declarative agent UI (Google A2UI SDK — lazy loaded)
a2ui = [
"a2ui-agent-sdk>=0.2.1",
"a2a-sdk>=0.3.0,<1.0.0",
]
# Docker sandbox dependencies
sandbox-docker = [
"docker>=7.0.0",
]
# Combined features
all = [
"praisonaiagents[memory]",
@@ -112,10 +128,12 @@ all = [
"praisonaiagents[telemetry]",
"praisonaiagents[mongodb]",
"praisonaiagents[auth]",
"praisonaiagents[sandbox]",
"praisonaiagents[search]",
"praisonaiagents[crawl]",
"praisonaiagents[autonomy]",
"praisonaiagents[os]"
"praisonaiagents[os]",
"praisonaiagents[a2ui]",
]
[tool.setuptools.packages.find]
@@ -1 +0,0 @@
"""A2UI Tests Package."""
@@ -0,0 +1,36 @@
"""Tests for A2UI adapter (optional a2ui-agent-sdk dependency)."""
import pytest
class TestA2UIAdapterImport:
def test_a2ui_class_export(self):
from praisonaiagents.ui.a2ui import A2UI
assert A2UI is not None
assert hasattr(A2UI, "create_part")
def test_ui_lazy_a2ui_export(self):
from praisonaiagents.ui import A2UI
assert A2UI is not None
def test_missing_optional_dep_raises_clear_error(self):
pytest.importorskip("a2ui", reason="a2ui-agent-sdk not installed")
# If installed, adapter imports should work
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part
part = create_a2ui_part({"createSurface": {"surfaceId": "main", "catalogId": "test"}})
assert part is not None
class TestA2UITool:
def test_send_a2ui_messages_requires_sdk(self):
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
if pytest.importorskip("a2ui", reason="skip without sdk"):
result = send_a2ui_messages(
messages=[{"createSurface": {"surfaceId": "x", "catalogId": "c"}}]
)
assert result["mime_type"] == "application/json+a2ui"
assert len(result["messages"]) == 1
@@ -1,202 +0,0 @@
"""
Tests for A2UI Agent Integration
TDD: Write tests first, then implement the A2UIAgent class.
"""
import os
import pytest
# Skip tests if no API key
pytestmark = pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
class TestA2UIAgent:
"""Test A2UIAgent wrapper class."""
def test_a2ui_agent_creation(self):
"""Test A2UIAgent creation with an agent."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(
name="TestAgent",
role="Test assistant",
goal="Help with testing"
)
a2ui_agent = A2UIAgent(agent=agent)
assert a2ui_agent.agent is agent
assert a2ui_agent.surface_id is not None
def test_a2ui_agent_with_custom_surface_id(self):
"""Test A2UIAgent with custom surface ID."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent, surface_id="custom-surface")
assert a2ui_agent.surface_id == "custom-surface"
def test_render_text_response(self):
"""Test rendering a simple text response as A2UI."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
# Render a simple text response
messages = a2ui_agent.render_text("Hello, World!")
assert isinstance(messages, list)
assert len(messages) >= 2 # createSurface + updateComponents
# Check createSurface
assert "createSurface" in messages[0]
# Check updateComponents has a text component
update_msg = next((m for m in messages if "updateComponents" in m), None)
assert update_msg is not None
components = update_msg["updateComponents"]["components"]
assert any(c.get("component") == "Text" for c in components)
def test_render_list_response(self):
"""Test rendering a list of items as A2UI cards."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
items = [
{"title": "Item 1", "description": "First item"},
{"title": "Item 2", "description": "Second item"},
]
messages = a2ui_agent.render_list(
title="My Items",
items=items,
item_title_key="title",
item_description_key="description"
)
assert isinstance(messages, list)
assert len(messages) >= 2
def test_render_form(self):
"""Test rendering a form as A2UI."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
fields = [
{"id": "name", "label": "Name", "type": "text"},
{"id": "email", "label": "Email", "type": "text"},
]
messages = a2ui_agent.render_form(
title="Contact Form",
fields=fields,
submit_action="submit_form"
)
assert isinstance(messages, list)
assert len(messages) >= 2
# Check for TextField components
update_msg = next((m for m in messages if "updateComponents" in m), None)
assert update_msg is not None
components = update_msg["updateComponents"]["components"]
text_fields = [c for c in components if c.get("component") == "TextField"]
assert len(text_fields) >= 2
def test_get_surface(self):
"""Test getting the underlying Surface object."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
from praisonaiagents.ui.a2ui.surface import Surface
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
surface = a2ui_agent.get_surface()
assert isinstance(surface, Surface)
assert surface.surface_id == a2ui_agent.surface_id
class TestA2UIAgentWithLLM:
"""Test A2UIAgent with actual LLM calls."""
@pytest.mark.integration
@pytest.mark.asyncio
async def test_chat_with_ui(self):
"""Test chat that returns A2UI response."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
agent = Agent(
name="UIAgent",
role="UI Generator",
goal="Generate helpful UI responses",
llm="gpt-4o-mini"
)
a2ui_agent = A2UIAgent(agent=agent)
# This should work with the agent's chat
response = await a2ui_agent.chat_async("Say hello")
assert response is not None
# Response should contain text
assert isinstance(response, str) or isinstance(response, dict)
class TestA2UIAgentIntegration:
"""Integration tests for A2UIAgent with A2A."""
def test_get_a2a_data_part(self):
"""Test getting A2UI as A2A DataPart."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
from praisonaiagents.ui.a2ui.extension import is_a2ui_part
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
# Render something
a2ui_agent.render_text("Hello")
# Get as A2A DataPart
part = a2ui_agent.to_a2a_part()
assert part is not None
assert is_a2ui_part(part)
def test_to_json(self):
"""Test getting A2UI as JSON string."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui.agent import A2UIAgent
import json
agent = Agent(name="TestAgent", role="Test", goal="Test")
a2ui_agent = A2UIAgent(agent=agent)
# Render something
a2ui_agent.render_text("Hello")
# Get as JSON
json_str = a2ui_agent.to_json()
# Should be valid JSON
parsed = json.loads(json_str)
assert isinstance(parsed, list)
@@ -1,115 +0,0 @@
"""
Tests for A2UI Extension Module
TDD: Write tests first, then implement the extension.
"""
class TestA2UIExtension:
"""Test A2UI extension helpers for A2A integration."""
def test_a2ui_mime_type(self):
"""Test A2UI MIME type constant."""
from praisonaiagents.ui.a2ui.extension import A2UI_MIME_TYPE
assert A2UI_MIME_TYPE == "application/json+a2ui"
def test_a2ui_extension_uri(self):
"""Test A2UI extension URI constant."""
from praisonaiagents.ui.a2ui.extension import A2UI_EXTENSION_URI
assert "a2ui" in A2UI_EXTENSION_URI.lower()
def test_create_a2ui_part(self):
"""Test create_a2ui_part wraps A2UI data in A2A DataPart."""
from praisonaiagents.ui.a2ui.extension import create_a2ui_part, A2UI_MIME_TYPE
a2ui_data = {
"createSurface": {
"surfaceId": "main",
"catalogId": "https://a2ui.dev/standard"
}
}
part = create_a2ui_part(a2ui_data)
# Should return a Part with DataPart root
assert part is not None
assert hasattr(part, 'data')
assert part.data == a2ui_data
assert part.metadata is not None
assert part.metadata.get("mimeType") == A2UI_MIME_TYPE
def test_is_a2ui_part_true(self):
"""Test is_a2ui_part returns True for A2UI parts."""
from praisonaiagents.ui.a2ui.extension import create_a2ui_part, is_a2ui_part
a2ui_data = {"createSurface": {"surfaceId": "main", "catalogId": "test"}}
part = create_a2ui_part(a2ui_data)
assert is_a2ui_part(part) is True
def test_is_a2ui_part_false(self):
"""Test is_a2ui_part returns False for non-A2UI parts."""
from praisonaiagents.ui.a2ui.extension import is_a2ui_part
from praisonaiagents.ui.a2ui.types import A2UIDataPart
# Create a regular data part without A2UI mime type
part = A2UIDataPart(data={"foo": "bar"}, metadata={"mimeType": "application/json"})
assert is_a2ui_part(part) is False
def test_get_a2ui_agent_extension(self):
"""Test get_a2ui_agent_extension returns AgentExtension config."""
from praisonaiagents.ui.a2ui.extension import get_a2ui_agent_extension
ext = get_a2ui_agent_extension()
assert ext is not None
assert hasattr(ext, 'uri')
assert "a2ui" in ext.uri.lower()
assert hasattr(ext, 'description')
def test_get_a2ui_agent_extension_with_inline_catalog(self):
"""Test get_a2ui_agent_extension with inline catalog support."""
from praisonaiagents.ui.a2ui.extension import get_a2ui_agent_extension
ext = get_a2ui_agent_extension(accepts_inline_custom_catalog=True)
assert ext.params is not None
assert ext.params.get("acceptsInlineCustomCatalog") is True
def test_standard_catalog_id(self):
"""Test standard catalog ID constant."""
from praisonaiagents.ui.a2ui.extension import STANDARD_CATALOG_ID
assert "standard_catalog" in STANDARD_CATALOG_ID.lower() or "a2ui" in STANDARD_CATALOG_ID.lower()
class TestA2UIDataPart:
"""Test A2UI DataPart type."""
def test_a2ui_data_part_creation(self):
"""Test A2UIDataPart creation."""
from praisonaiagents.ui.a2ui.types import A2UIDataPart
part = A2UIDataPart(
data={"test": "value"},
metadata={"mimeType": "application/json+a2ui"}
)
assert part.data == {"test": "value"}
assert part.metadata["mimeType"] == "application/json+a2ui"
def test_a2ui_data_part_to_dict(self):
"""Test A2UIDataPart serialization."""
from praisonaiagents.ui.a2ui.types import A2UIDataPart
part = A2UIDataPart(
data={"createSurface": {"surfaceId": "main"}},
metadata={"mimeType": "application/json+a2ui"}
)
data = part.to_dict()
assert "data" in data
assert "metadata" in data
@@ -1,225 +0,0 @@
"""
End-to-End Integration Tests for A2UI
Tests the complete A2UI workflow from agent to JSON output.
"""
import json
import os
import pytest
class TestA2UIEndToEnd:
"""End-to-end integration tests."""
def test_complete_surface_workflow(self):
"""Test complete workflow: create surface, add components, generate JSON."""
from praisonaiagents.ui.a2ui import (
Surface,
PathBinding,
)
# Create surface
surface = Surface(surface_id="test-surface")
# Add components using fluent API
surface.text(id="title", text="Welcome", usage_hint="h1")
surface.text(id="subtitle", text=PathBinding(path="/subtitle"), usage_hint="h2")
surface.button(
id="action-btn",
child="btn-text",
action_name="do_action",
action_context=[{"key": "id", "value": "123"}],
primary=True
)
surface.text(id="btn-text", text="Click Me")
surface.column(id="root", children=["title", "subtitle", "action-btn"])
# Set data
surface.set_data("subtitle", "Hello World")
# Generate messages
messages = surface.to_messages()
# Verify structure
assert len(messages) == 3 # createSurface, updateComponents, updateDataModel
# Verify createSurface
assert "createSurface" in messages[0]
assert messages[0]["createSurface"]["surfaceId"] == "test-surface"
# Verify updateComponents
assert "updateComponents" in messages[1]
components = messages[1]["updateComponents"]["components"]
assert len(components) == 5 # title, subtitle, btn, btn-text, root
# Verify updateDataModel
assert "updateDataModel" in messages[2]
assert messages[2]["updateDataModel"]["value"]["subtitle"] == "Hello World"
# Verify JSON output
json_str = surface.to_json()
parsed = json.loads(json_str)
assert len(parsed) == 3
def test_a2ui_agent_render_workflow(self):
"""Test A2UIAgent rendering workflow."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui import A2UIAgent
agent = Agent(
name="TestAgent",
role="Test assistant",
goal="Help with testing"
)
a2ui_agent = A2UIAgent(agent=agent, surface_id="agent-surface")
# Render text
messages = a2ui_agent.render_text(
text="This is a test response",
title="Test Title"
)
# Verify structure
assert len(messages) >= 2
assert "createSurface" in messages[0]
# Verify JSON export
json_str = a2ui_agent.to_json()
parsed = json.loads(json_str)
assert isinstance(parsed, list)
def test_template_workflow(self):
"""Test template-based workflow."""
from praisonaiagents.ui.a2ui import (
ChatTemplate,
ListTemplate,
FormTemplate,
DashboardTemplate,
)
# Chat template
chat = ChatTemplate(surface_id="chat")
chat.add_user_message("Hello!")
chat.add_agent_message("Hi there!")
chat_messages = chat.to_messages()
assert len(chat_messages) >= 2
# List template
list_template = ListTemplate(surface_id="list", title="Items")
list_template.add_item(title="Item 1", description="First")
list_template.add_item(title="Item 2", description="Second")
list_messages = list_template.to_messages()
assert len(list_messages) >= 2
# Form template
form = FormTemplate(surface_id="form", title="Contact")
form.add_text_field(id="name", label="Name")
form.add_email_field(id="email", label="Email")
form.set_submit_action("submit", "Send")
form_messages = form.to_messages()
assert len(form_messages) >= 2
# Dashboard template
dashboard = DashboardTemplate(surface_id="dashboard", title="Dashboard")
dashboard.add_panel(id="stats", title="Stats", content="100 items")
dashboard.add_panel(id="chart", title="Chart", content="[Chart]")
dashboard_messages = dashboard.to_messages()
assert len(dashboard_messages) >= 2
def test_a2a_integration(self):
"""Test A2A protocol integration."""
from praisonaiagents.ui.a2ui import (
Surface,
create_a2ui_part,
is_a2ui_part,
A2UI_MIME_TYPE,
)
# Create surface
surface = Surface(surface_id="a2a-test")
surface.text(id="msg", text="Hello from A2A")
surface.column(id="root", children=["msg"])
# Get messages
messages = surface.to_messages()
# Wrap in A2A DataPart
part = create_a2ui_part({"messages": messages})
# Verify A2UI part
assert is_a2ui_part(part)
assert part.metadata["mimeType"] == A2UI_MIME_TYPE
assert "messages" in part.data
assert len(part.data["messages"]) >= 2
def test_data_binding_workflow(self):
"""Test data binding with path references."""
from praisonaiagents.ui.a2ui import (
Surface,
PathBinding,
ChildrenTemplate,
)
surface = Surface(surface_id="data-binding-test")
# Add components with data binding
surface.text(id="title", text=PathBinding(path="/title"), usage_hint="h1")
surface.text(id="item-template", text=PathBinding(path="/name"))
# Add list with template
surface.list(
id="items",
children=ChildrenTemplate(component_id="item-template", path="/items"),
direction="vertical"
)
surface.column(id="root", children=["title", "items"])
# Set data
surface.set_data("title", "My List")
surface.set_data("items", [
{"name": "Item 1"},
{"name": "Item 2"},
{"name": "Item 3"},
])
messages = surface.to_messages()
# Verify data model
data_msg = next((m for m in messages if "updateDataModel" in m), None)
assert data_msg is not None
assert data_msg["updateDataModel"]["value"]["title"] == "My List"
assert len(data_msg["updateDataModel"]["value"]["items"]) == 3
@pytest.mark.skipif(
not os.environ.get("OPENAI_API_KEY"),
reason="OPENAI_API_KEY not set"
)
class TestA2UIWithLLM:
"""Tests that require LLM API calls."""
def test_agent_chat_and_render(self):
"""Test agent chat followed by UI rendering."""
from praisonaiagents import Agent
from praisonaiagents.ui.a2ui import A2UIAgent
agent = Agent(
name="UIAgent",
role="UI Generator",
goal="Generate helpful responses",
llm="gpt-4o-mini"
)
a2ui_agent = A2UIAgent(agent=agent)
# Chat with agent
response = a2ui_agent.chat("What is 2+2?")
# Render response as UI
messages = a2ui_agent.render_text(response, title="Answer")
assert len(messages) >= 2
assert "createSurface" in messages[0]
@@ -1,228 +0,0 @@
"""
Tests for A2UI Surface Builder
TDD: Write tests first, then implement the Surface class.
"""
class TestSurface:
"""Test Surface builder class."""
def test_surface_creation(self):
"""Test Surface creation with ID and catalog."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
assert surface.surface_id == "main"
assert surface.catalog_id is not None # Should have default
def test_surface_with_custom_catalog(self):
"""Test Surface with custom catalog ID."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(
surface_id="main",
catalog_id="https://custom.catalog/v1"
)
assert surface.catalog_id == "https://custom.catalog/v1"
def test_add_component(self):
"""Test adding a component to the surface."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
text = TextComponent(id="title", text="Hello World", usage_hint="h1")
surface.add(text)
assert len(surface.components) == 1
assert surface.components[0].id == "title"
def test_add_multiple_components(self):
"""Test adding multiple components."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent, ButtonComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
surface.add(ButtonComponent(id="btn", child="btn-text"))
assert len(surface.components) == 2
def test_set_data(self):
"""Test setting data model values."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.set_data("title", "Hello World")
surface.set_data("count", 42)
assert surface.data["title"] == "Hello World"
assert surface.data["count"] == 42
def test_set_nested_data(self):
"""Test setting nested data model values."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.set_data("user", {"name": "John", "age": 30})
assert surface.data["user"]["name"] == "John"
def test_to_messages_creates_surface(self):
"""Test to_messages generates CreateSurface message."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
messages = surface.to_messages()
assert len(messages) >= 1
assert "createSurface" in messages[0]
assert messages[0]["createSurface"]["surfaceId"] == "main"
def test_to_messages_includes_components(self):
"""Test to_messages includes UpdateComponents message."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
messages = surface.to_messages()
# Should have createSurface and updateComponents
assert len(messages) >= 2
update_msg = next((m for m in messages if "updateComponents" in m), None)
assert update_msg is not None
assert len(update_msg["updateComponents"]["components"]) == 1
def test_to_messages_includes_data_model(self):
"""Test to_messages includes UpdateDataModel message when data is set."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
surface.set_data("title", "Hello World")
messages = surface.to_messages()
# Should have createSurface, updateComponents, and updateDataModel
assert len(messages) == 3
data_msg = next((m for m in messages if "updateDataModel" in m), None)
assert data_msg is not None
assert data_msg["updateDataModel"]["value"]["title"] == "Hello World"
def test_to_json(self):
"""Test to_json returns JSON string."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
import json
surface = Surface(surface_id="main")
surface.add(TextComponent(id="title", text="Hello"))
json_str = surface.to_json()
# Should be valid JSON
parsed = json.loads(json_str)
assert isinstance(parsed, list)
assert len(parsed) >= 2
class TestSurfaceChaining:
"""Test Surface method chaining."""
def test_add_returns_self(self):
"""Test add() returns self for chaining."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent
surface = Surface(surface_id="main")
result = surface.add(TextComponent(id="title", text="Hello"))
assert result is surface
def test_set_data_returns_self(self):
"""Test set_data() returns self for chaining."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
result = surface.set_data("key", "value")
assert result is surface
def test_fluent_api(self):
"""Test fluent API for building surfaces."""
from praisonaiagents.ui.a2ui.surface import Surface
from praisonaiagents.ui.a2ui.types import TextComponent, ButtonComponent
surface = (
Surface(surface_id="main")
.add(TextComponent(id="title", text="Hello"))
.add(ButtonComponent(id="btn", child="btn-text"))
.set_data("title", "Hello World")
)
assert len(surface.components) == 2
assert surface.data["title"] == "Hello World"
class TestSurfaceHelpers:
"""Test Surface helper methods."""
def test_text_helper(self):
"""Test text() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.text(id="title", text="Hello", usage_hint="h1")
assert len(surface.components) == 1
assert surface.components[0].id == "title"
def test_button_helper(self):
"""Test button() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.button(id="submit", child="submit-text", action_name="submit")
assert len(surface.components) == 1
assert surface.components[0].id == "submit"
def test_card_helper(self):
"""Test card() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.card(id="card-1", child="card-content")
assert len(surface.components) == 1
assert surface.components[0].id == "card-1"
def test_row_helper(self):
"""Test row() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.row(id="row-1", children=["col-1", "col-2"])
assert len(surface.components) == 1
assert surface.components[0].id == "row-1"
def test_column_helper(self):
"""Test column() helper method."""
from praisonaiagents.ui.a2ui.surface import Surface
surface = Surface(surface_id="main")
surface.column(id="col-1", children=["item-1", "item-2"])
assert len(surface.components) == 1
assert surface.components[0].id == "col-1"
@@ -1,210 +0,0 @@
"""
Tests for A2UI Templates
TDD: Write tests first, then implement the templates.
"""
class TestChatTemplate:
"""Test ChatTemplate for conversational UIs."""
def test_chat_template_creation(self):
"""Test ChatTemplate creation."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
assert template.surface_id == "chat"
def test_add_user_message(self):
"""Test adding a user message."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
template.add_user_message("Hello!")
assert len(template.messages) == 1
assert template.messages[0]["role"] == "user"
assert template.messages[0]["content"] == "Hello!"
def test_add_agent_message(self):
"""Test adding an agent message."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
template.add_agent_message("Hi there!")
assert len(template.messages) == 1
assert template.messages[0]["role"] == "agent"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import ChatTemplate
template = ChatTemplate(surface_id="chat")
template.add_user_message("Hello!")
template.add_agent_message("Hi there!")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2 # createSurface + updateComponents
class TestListTemplate:
"""Test ListTemplate for displaying lists of items."""
def test_list_template_creation(self):
"""Test ListTemplate creation."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(
surface_id="list",
title="My Items"
)
assert template.surface_id == "list"
assert template.title == "My Items"
def test_add_item(self):
"""Test adding an item to the list."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(surface_id="list", title="Items")
template.add_item(title="Item 1", description="First item")
assert len(template.items) == 1
assert template.items[0]["title"] == "Item 1"
def test_add_item_with_image(self):
"""Test adding an item with an image."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(surface_id="list", title="Items")
template.add_item(
title="Item 1",
description="First item",
image_url="https://example.com/image.jpg"
)
assert template.items[0]["image_url"] == "https://example.com/image.jpg"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import ListTemplate
template = ListTemplate(surface_id="list", title="Items")
template.add_item(title="Item 1", description="First")
template.add_item(title="Item 2", description="Second")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2
class TestFormTemplate:
"""Test FormTemplate for data collection forms."""
def test_form_template_creation(self):
"""Test FormTemplate creation."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(
surface_id="form",
title="Contact Form"
)
assert template.surface_id == "form"
assert template.title == "Contact Form"
def test_add_text_field(self):
"""Test adding a text field."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_text_field(id="name", label="Name")
assert len(template.fields) == 1
assert template.fields[0]["id"] == "name"
assert template.fields[0]["type"] == "text"
def test_add_number_field(self):
"""Test adding a number field."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_number_field(id="age", label="Age")
assert template.fields[0]["type"] == "number"
def test_add_email_field(self):
"""Test adding an email field."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_email_field(id="email", label="Email")
assert template.fields[0]["type"] == "email"
def test_set_submit_action(self):
"""Test setting submit action."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.set_submit_action("submit_form", "Send")
assert template.submit_action == "submit_form"
assert template.submit_label == "Send"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import FormTemplate
template = FormTemplate(surface_id="form", title="Form")
template.add_text_field(id="name", label="Name")
template.add_email_field(id="email", label="Email")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2
class TestDashboardTemplate:
"""Test DashboardTemplate for multi-panel dashboards."""
def test_dashboard_template_creation(self):
"""Test DashboardTemplate creation."""
from praisonaiagents.ui.a2ui.templates import DashboardTemplate
template = DashboardTemplate(
surface_id="dashboard",
title="My Dashboard"
)
assert template.surface_id == "dashboard"
assert template.title == "My Dashboard"
def test_add_panel(self):
"""Test adding a panel."""
from praisonaiagents.ui.a2ui.templates import DashboardTemplate
template = DashboardTemplate(surface_id="dashboard", title="Dashboard")
template.add_panel(id="panel-1", title="Panel 1", content="Some content")
assert len(template.panels) == 1
assert template.panels[0]["id"] == "panel-1"
def test_to_messages(self):
"""Test generating A2UI messages."""
from praisonaiagents.ui.a2ui.templates import DashboardTemplate
template = DashboardTemplate(surface_id="dashboard", title="Dashboard")
template.add_panel(id="panel-1", title="Panel 1", content="Content 1")
template.add_panel(id="panel-2", title="Panel 2", content="Content 2")
messages = template.to_messages()
assert isinstance(messages, list)
assert len(messages) >= 2
@@ -1,318 +0,0 @@
"""
Tests for A2UI Types Module
TDD: Write tests first, then implement the types.
"""
class TestA2UIMessageTypes:
"""Test A2UI message types: CreateSurface, UpdateComponents, UpdateDataModel, DeleteSurface."""
def test_create_surface_message(self):
"""Test CreateSurface message creation."""
from praisonaiagents.ui.a2ui.types import CreateSurfaceMessage
msg = CreateSurfaceMessage(
surface_id="main",
catalog_id="https://a2ui.dev/standard"
)
assert msg.surface_id == "main"
assert msg.catalog_id == "https://a2ui.dev/standard"
# Test serialization
data = msg.to_dict()
assert "createSurface" in data
assert data["createSurface"]["surfaceId"] == "main"
assert data["createSurface"]["catalogId"] == "https://a2ui.dev/standard"
def test_update_components_message(self):
"""Test UpdateComponents message creation."""
from praisonaiagents.ui.a2ui.types import UpdateComponentsMessage, TextComponent
text = TextComponent(id="title", text="Hello World", usage_hint="h1")
msg = UpdateComponentsMessage(
surface_id="main",
components=[text]
)
assert msg.surface_id == "main"
assert len(msg.components) == 1
# Test serialization
data = msg.to_dict()
assert "updateComponents" in data
assert data["updateComponents"]["surfaceId"] == "main"
assert len(data["updateComponents"]["components"]) == 1
def test_update_data_model_message(self):
"""Test UpdateDataModel message creation."""
from praisonaiagents.ui.a2ui.types import UpdateDataModelMessage
msg = UpdateDataModelMessage(
surface_id="main",
path="/",
op="replace",
value={"title": "Hello", "items": []}
)
assert msg.surface_id == "main"
assert msg.path == "/"
assert msg.op == "replace"
assert msg.value == {"title": "Hello", "items": []}
# Test serialization
data = msg.to_dict()
assert "updateDataModel" in data
assert data["updateDataModel"]["surfaceId"] == "main"
def test_delete_surface_message(self):
"""Test DeleteSurface message creation."""
from praisonaiagents.ui.a2ui.types import DeleteSurfaceMessage
msg = DeleteSurfaceMessage(surface_id="main")
assert msg.surface_id == "main"
# Test serialization
data = msg.to_dict()
assert "deleteSurface" in data
assert data["deleteSurface"]["surfaceId"] == "main"
class TestA2UIComponentTypes:
"""Test A2UI component types."""
def test_text_component(self):
"""Test Text component creation."""
from praisonaiagents.ui.a2ui.types import TextComponent
text = TextComponent(id="title", text="Hello World", usage_hint="h1")
assert text.id == "title"
assert text.text == "Hello World"
assert text.usage_hint == "h1"
# Test serialization
data = text.to_dict()
assert data["id"] == "title"
assert data["component"] == "Text"
assert data["text"] == "Hello World"
assert data["usageHint"] == "h1"
def test_text_component_with_path(self):
"""Test Text component with data binding path."""
from praisonaiagents.ui.a2ui.types import TextComponent, PathBinding
text = TextComponent(id="title", text=PathBinding(path="/title"))
data = text.to_dict()
assert data["text"] == {"path": "/title"}
def test_image_component(self):
"""Test Image component creation."""
from praisonaiagents.ui.a2ui.types import ImageComponent
img = ImageComponent(
id="hero",
url="https://example.com/image.jpg",
fit="cover",
usage_hint="header"
)
assert img.id == "hero"
assert img.url == "https://example.com/image.jpg"
assert img.fit == "cover"
data = img.to_dict()
assert data["component"] == "Image"
assert data["url"] == "https://example.com/image.jpg"
def test_button_component(self):
"""Test Button component creation."""
from praisonaiagents.ui.a2ui.types import ButtonComponent, Action, ActionContext
action = Action(
name="submit",
context=[
ActionContext(key="value", value="test")
]
)
btn = ButtonComponent(
id="submit-btn",
child="submit-text",
primary=True,
action=action
)
assert btn.id == "submit-btn"
assert btn.child == "submit-text"
assert btn.primary is True
data = btn.to_dict()
assert data["component"] == "Button"
assert data["action"]["name"] == "submit"
def test_card_component(self):
"""Test Card component creation."""
from praisonaiagents.ui.a2ui.types import CardComponent
card = CardComponent(id="card-1", child="card-content")
assert card.id == "card-1"
assert card.child == "card-content"
data = card.to_dict()
assert data["component"] == "Card"
assert data["child"] == "card-content"
def test_row_component(self):
"""Test Row component creation."""
from praisonaiagents.ui.a2ui.types import RowComponent
row = RowComponent(id="row-1", children=["col-1", "col-2"])
assert row.id == "row-1"
assert row.children == ["col-1", "col-2"]
data = row.to_dict()
assert data["component"] == "Row"
assert data["children"] == ["col-1", "col-2"]
def test_column_component(self):
"""Test Column component creation."""
from praisonaiagents.ui.a2ui.types import ColumnComponent
col = ColumnComponent(id="col-1", children=["item-1", "item-2"])
assert col.id == "col-1"
assert col.children == ["item-1", "item-2"]
data = col.to_dict()
assert data["component"] == "Column"
def test_list_component(self):
"""Test List component creation."""
from praisonaiagents.ui.a2ui.types import ListComponent, ChildrenTemplate
template = ChildrenTemplate(component_id="item-template", path="/items")
lst = ListComponent(id="item-list", direction="vertical", children=template)
assert lst.id == "item-list"
assert lst.direction == "vertical"
data = lst.to_dict()
assert data["component"] == "List"
assert data["children"]["componentId"] == "item-template"
assert data["children"]["path"] == "/items"
def test_text_field_component(self):
"""Test TextField component creation."""
from praisonaiagents.ui.a2ui.types import TextFieldComponent, PathBinding
field = TextFieldComponent(
id="name-field",
label="Name",
text=PathBinding(path="/name"),
field_type="text"
)
assert field.id == "name-field"
assert field.label == "Name"
data = field.to_dict()
assert data["component"] == "TextField"
assert data["label"] == "Name"
assert data["text"] == {"path": "/name"}
def test_checkbox_component(self):
"""Test CheckBox component creation."""
from praisonaiagents.ui.a2ui.types import CheckBoxComponent, PathBinding
cb = CheckBoxComponent(
id="agree-cb",
label="I agree",
checked=PathBinding(path="/agreed")
)
data = cb.to_dict()
assert data["component"] == "CheckBox"
assert data["label"] == "I agree"
def test_slider_component(self):
"""Test Slider component creation."""
from praisonaiagents.ui.a2ui.types import SliderComponent
slider = SliderComponent(
id="volume",
min_value=0,
max_value=100,
value=50
)
data = slider.to_dict()
assert data["component"] == "Slider"
assert data["min"] == 0
assert data["max"] == 100
class TestDataBindingTypes:
"""Test data binding types."""
def test_path_binding(self):
"""Test PathBinding for data model references."""
from praisonaiagents.ui.a2ui.types import PathBinding
path = PathBinding(path="/user/name")
assert path.path == "/user/name"
assert path.to_dict() == {"path": "/user/name"}
def test_string_or_path_literal(self):
"""Test StringOrPath with literal string."""
from praisonaiagents.ui.a2ui.types import resolve_string_or_path
result = resolve_string_or_path("Hello")
assert result == "Hello"
def test_string_or_path_binding(self):
"""Test StringOrPath with path binding."""
from praisonaiagents.ui.a2ui.types import resolve_string_or_path, PathBinding
result = resolve_string_or_path(PathBinding(path="/title"))
assert result == {"path": "/title"}
class TestActionTypes:
"""Test action types for button events."""
def test_action_creation(self):
"""Test Action creation."""
from praisonaiagents.ui.a2ui.types import Action, ActionContext
action = Action(
name="book_restaurant",
context=[
ActionContext(key="restaurantName", value="The Fancy Place"),
ActionContext(key="address", value="123 Main St")
]
)
assert action.name == "book_restaurant"
assert len(action.context) == 2
data = action.to_dict()
assert data["name"] == "book_restaurant"
assert len(data["context"]) == 2
assert data["context"][0]["key"] == "restaurantName"
def test_action_context_with_path(self):
"""Test ActionContext with path binding."""
from praisonaiagents.ui.a2ui.types import ActionContext, PathBinding
ctx = ActionContext(key="name", value=PathBinding(path="/restaurant/name"))
data = ctx.to_dict()
assert data["key"] == "name"
assert data["value"] == {"path": "/restaurant/name"}
@@ -98,6 +98,38 @@ class TestToolCallStreaming:
assert event.content == "Search results..."
class TestStreamEventBridge:
"""Test PraisonAI stream_emitter → AG-UI event conversion."""
def test_delta_text_to_agui(self):
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
from praisonaiagents.ui.agui.streaming import EventBuffer, stream_event_to_agui_events
buffer = EventBuffer()
event = StreamEvent(type=StreamEventType.DELTA_TEXT, content="Hello")
agui_events = stream_event_to_agui_events(event, "msg-1", buffer)
assert len(agui_events) == 2
assert agui_events[0].type == "TEXT_MESSAGE_START"
assert agui_events[1].type == "TEXT_MESSAGE_CONTENT"
assert agui_events[1].delta == "Hello"
def test_tool_call_start_to_agui(self):
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
from praisonaiagents.ui.agui.streaming import EventBuffer, stream_event_to_agui_events
buffer = EventBuffer()
event = StreamEvent(
type=StreamEventType.TOOL_CALL_START,
tool_call={"id": "tc-1", "name": "search", "arguments": {"q": "test"}},
)
agui_events = stream_event_to_agui_events(event, "msg-1", buffer)
assert len(agui_events) == 3
assert agui_events[0].type == "TOOL_CALL_START"
assert agui_events[0].tool_call_name == "search"
class TestRunLifecycleEvents:
"""Test run lifecycle events."""
@@ -0,0 +1,328 @@
"""Integration tests for the ALLOWED_TOOLS filter module."""
import os
import pytest
from unittest.mock import patch, MagicMock
from praisonai.cli.features.agents import MultiAgentHandler
class TestAllowedToolsFilterRegistryIntegration:
"""Integration tests for ALLOWED_TOOLS filter with the tool registry."""
def test_registry_allowed_filter_unset(self):
"""Test registry filter behavior when ALLOWED_TOOLS is unset."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {}, clear=True):
registry = get_registry()
# Mock some tools in the registry
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should return all tools when unset
assert set(filtered_tools) == {'search_web', 'send_email'}
def test_registry_allowed_filter_with_whitelist(self):
"""Test registry filter with ALLOWED_TOOLS whitelist."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "search_web,send_email,unknown_tool",
"CI": "false",
}):
registry = get_registry()
# Mock some tools in the registry
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email', 'extract_pdf']):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should only include whitelisted tools that are available
assert set(filtered_tools) == {'search_web', 'send_email'}
def test_registry_allowed_filter_empty_result(self):
"""Test registry filter when no tools match whitelist."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "nonexistent_tool1,nonexistent_tool2",
"CI": "false",
}):
registry = get_registry()
# Mock some tools in the registry
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should return empty list when no matches
assert filtered_tools == []
def test_registry_allowed_filter_import_error_fallback(self):
"""Test registry filter fallback when import fails."""
from praisonaiagents.tools.registry import get_registry
registry = get_registry()
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
# Mock import error for the filter
with patch('praisonaiagents.tools.registry.filter_tools_with_allowed_tools', side_effect=ImportError("Filter not available")):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should fallback to all tools
assert set(filtered_tools) == {'search_web', 'send_email'}
def test_registry_allowed_filter_value_error_propagation(self):
"""Test that ValueError from filter is properly propagated."""
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}): # Empty string should cause ValueError
registry = get_registry()
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
with pytest.raises(ValueError, match="cannot be empty"):
registry.list_tools_with_allowed_filter()
def test_registry_allowed_filter_general_error_fallback(self):
"""Test registry filter fallback on general errors."""
from praisonaiagents.tools.registry import get_registry
registry = get_registry()
with patch.object(registry, 'list_tools', return_value=['search_web', 'send_email']):
# Mock a generic error during filtering
with patch('praisonaiagents.tools.registry.filter_tools_with_allowed_tools', side_effect=RuntimeError("Some error")):
filtered_tools = registry.list_tools_with_allowed_filter()
# Should fallback to all tools on general errors
assert set(filtered_tools) == {'search_web', 'send_email'}
class TestAllowedToolsFilterGlobalRegistryPath:
"""Test ALLOWED_TOOLS filter through the global registry function."""
def test_global_list_tools_with_allowed_filter(self):
"""Test the global list_tools_with_allowed_filter function."""
from praisonaiagents.tools import list_tools_with_allowed_filter
from praisonaiagents.tools.registry import get_registry
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search_web,send_email"}):
registry = get_registry()
with patch.object(registry, 'list_tools_with_allowed_filter', return_value=['search_web', 'send_email']) as mock_filter:
result = list_tools_with_allowed_filter()
# Should call the registry method
mock_filter.assert_called_once_with(None)
assert result == ['search_web', 'send_email']
def test_global_backward_compatibility_alias(self):
"""Test the backward compatibility alias for hermes filter."""
from praisonaiagents.tools import list_tools_with_hermes_filter, list_tools_with_allowed_filter
from praisonaiagents.tools.registry import get_registry
registry = get_registry()
with patch.object(registry, 'list_tools_with_allowed_filter', return_value=['search_web']) as mock_filter:
# Both should call the same underlying function
result1 = list_tools_with_allowed_filter()
result2 = list_tools_with_hermes_filter()
assert result1 == result2 == ['search_web']
assert mock_filter.call_count == 2
class TestAllowedToolsFilterCLIIntegration:
"""Integration tests for ALLOWED_TOOLS filter with CLI agent handler."""
def test_cli_agents_load_tools_no_filter(self):
"""Test CLI agent tool loading without ALLOWED_TOOLS filter."""
handler = MultiAgentHandler(verbose=False)
with patch.dict(os.environ, {}, clear=True):
# Mock the tools import to avoid actual import dependencies
with patch('praisonai.cli.features.agents.internet_search'), \
patch('praisonai.cli.features.agents.read_file'):
tools = handler._load_tools(["internet_search", "read_file"])
# Should load all requested tools when no filter
assert len(tools) == 2
def test_cli_agents_load_tools_with_filter(self):
"""Test CLI agent tool loading with ALLOWED_TOOLS filter."""
handler = MultiAgentHandler(verbose=False)
with patch.dict(os.environ, {"ALLOWED_TOOLS": "internet_search"}):
# Mock the tools and filter
mock_internet_search = MagicMock()
mock_read_file = MagicMock()
with patch('praisonai.cli.features.agents.internet_search', mock_internet_search), \
patch('praisonai.cli.features.agents.read_file', mock_read_file):
tools = handler._load_tools(["internet_search", "read_file"])
# Should only load tools allowed by filter
assert len(tools) == 1
assert mock_internet_search in tools
assert mock_read_file not in tools
def test_cli_agents_load_tools_filter_error_handling(self):
"""Test CLI tool loading with ALLOWED_TOOLS filter error handling."""
handler = MultiAgentHandler(verbose=False)
requested = ["internet_search", "read_file"]
with patch('os.environ.get', return_value=None):
with patch('praisonai.cli.features.agents.internet_search'), \
patch('praisonai.cli.features.agents.read_file'):
baseline_tools = handler._load_tools(requested)
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}):
with pytest.raises(ValueError):
handler._load_tools(requested)
def test_cli_agents_load_tools_import_error_fallback(self):
"""Test CLI tool loading fallback when ALLOWED_TOOLS filter import fails."""
handler = MultiAgentHandler(verbose=False)
# Mock ImportError for the filter module
with patch('praisonai.cli.features.agents.filter_tools_with_allowed_tools', side_effect=ImportError("Filter module not available")):
with patch('praisonai.cli.features.agents.internet_search'), \
patch('praisonai.cli.features.agents.read_file'):
tools = handler._load_tools(["internet_search", "read_file"])
# Should load all tools on import error
assert len(tools) == 2
def test_cli_agents_load_tools_filter_value_error_propagation(self):
"""Test that ValueError from filter is properly propagated in CLI."""
handler = MultiAgentHandler(verbose=False)
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}): # Empty string causes ValueError
with pytest.raises(ValueError, match="cannot be empty"):
handler._load_tools(["internet_search"])
class TestAllowedToolsFilterSpecSemantics:
"""Test ALLOWED_TOOLS filter specification semantics."""
def test_spec_semantics_unset_all_tools_visible(self):
"""Test spec: unset ALLOWED_TOOLS means all tools visible with warning."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {}, clear=True):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
import logging
with patch.object(logging.getLogger(), 'warning') as mock_warn:
filtered = filter_instance.filter_tools(available_tools)
# Should return all tools
assert filtered == available_tools
# Should warn about unset whitelist
mock_warn.assert_called_once()
assert "Tool whitelist is unset" in mock_warn.call_args[0][0]
def test_spec_semantics_empty_string_error(self):
"""Test spec: empty ALLOWED_TOOLS string is an error."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}):
with pytest.raises(ValueError, match="ALLOWED_TOOLS cannot be empty"):
AllowedToolsFilter()
def test_spec_semantics_whitelist_only_specified_visible(self):
"""Test spec: with ALLOWED_TOOLS values, only whitelisted tools visible."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf", "analyze_code"}
filtered = filter_instance.filter_tools(available_tools)
# Should only return whitelisted tools
assert filtered == {"search", "send_message"}
def test_spec_semantics_unknown_tools_dev_warn_strip(self):
"""Test spec: unknown tools in dev mode are warned and stripped."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool", "CI": "false"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message"}
import logging
with patch.object(logging.getLogger(), 'warning') as mock_warn:
filtered = filter_instance.filter_tools(available_tools)
# Should return only known tools
assert filtered == {"search"}
# Should warn about unknown tools
mock_warn.assert_called()
assert "unknown_tool" in str(mock_warn.call_args[0][2])
def test_spec_semantics_unknown_tools_ci_strict_fail(self):
"""Test spec: unknown tools in CI mode cause strict failure."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool", "CI": "true"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message"}
with pytest.raises(ValueError, match="Unknown tools in ALLOWED_TOOLS"):
filter_instance.filter_tools(available_tools)
def test_spec_semantics_order_independence(self):
"""Test spec: whitelist order doesn't matter."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
available_tools = {"search", "send_message", "extract_pdf"}
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter1 = AllowedToolsFilter()
result1 = filter1.filter_tools(available_tools)
with patch.dict(os.environ, {"ALLOWED_TOOLS": "send_message,search"}):
filter2 = AllowedToolsFilter()
result2 = filter2.filter_tools(available_tools)
# Results should be the same regardless of order
assert result1 == result2 == {"search", "send_message"}
def test_spec_semantics_duplicate_isolation(self):
"""Test spec: duplicate tool names in whitelist are handled properly."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,search,send_message,search"}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
# Duplicates should be de-duplicated
assert filtered == {"search", "send_message"}
# Whitelist should be a set (no duplicates)
whitelist = filter_instance.get_whitelist()
assert whitelist == {"search", "send_message"}
def test_spec_backward_compatibility_hermes_precedence(self):
"""Test spec: ALLOWED_TOOLS takes precedence over HERMES_ONLY_TOOLS."""
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "search,send_message",
"HERMES_ONLY_TOOLS": "extract_pdf"
}):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
# Should use ALLOWED_TOOLS, not HERMES_ONLY_TOOLS
assert filtered == {"search", "send_message"}
assert filter_instance.env_var_name == "ALLOWED_TOOLS"
@@ -6,6 +6,7 @@ Databases tested (all via local Docker):
2. PostgreSQL — ConversationStore + ManagedAgent roundtrip
3. MySQL — ConversationStore + ManagedAgent roundtrip
4. Redis — StateStore + metadata persistence
4a. Valkey — StateStore + metadata persistence
5. MongoDB — StateStore + metadata persistence
6. ClickHouse — Raw connectivity + data write/read
7. JSON file — DefaultSessionStore + ManagedAgent roundtrip
@@ -27,7 +28,6 @@ from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -562,6 +562,206 @@ class TestRedisStateStore:
r.close()
# ===========================================================================
# 4a. Valkey — StateStore operations + metadata persistence
# ===========================================================================
class TestValkeyStateStore:
"""Valkey StateStore: real operations against a live Valkey instance."""
@pytest.fixture(autouse=True)
def check_valkey(self):
"""Skip if Valkey not available."""
try:
from praisonai.persistence._valkey_client import create_valkey_client
client = create_valkey_client(host="localhost", port=6379)
client.ping()
client.close()
except Exception:
pytest.skip("Valkey not available on localhost:6379")
def test_valkey_state_operations(self):
"""StateStore get/set/delete/exists/hash with real Valkey."""
from praisonai.persistence.state.valkey import ValkeyStateStore
prefix = f"test_{uuid.uuid4().hex[:6]}:"
store = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
try:
# Basic set/get
store.set("agent_id", "agent_valkey_001")
assert store.get("agent_id") == "agent_valkey_001"
# JSON roundtrip
state = {
"agent_id": "agent_valkey_001",
"agent_version": 3,
"total_input_tokens": 500,
"total_output_tokens": 200,
"compute_instance_id": "docker_valkey_test",
"session_history": [{"id": "s1", "status": "idle"}],
}
store.set_json("managed_state", state)
recovered = store.get_json("managed_state")
assert recovered["agent_id"] == "agent_valkey_001"
assert recovered["total_input_tokens"] == 500
assert recovered["compute_instance_id"] == "docker_valkey_test"
assert len(recovered["session_history"]) == 1
# Hash operations
store.hset("agent_meta", "version", "5")
store.hset("agent_meta", "env_id", "env_abc")
assert str(store.hget("agent_meta", "version")) == "5"
all_meta = store.hgetall("agent_meta")
assert str(all_meta["env_id"]) == "env_abc"
# Exists and delete
assert store.exists("agent_id")
store.delete("agent_id")
assert not store.exists("agent_id")
finally:
for k in store.keys():
store.delete(k)
store.close()
def test_valkey_managed_state_persist_restore(self):
"""Simulate managed agent state persist/restore across store instances."""
from praisonai.persistence.state.valkey import ValkeyStateStore
prefix = f"mgd_{uuid.uuid4().hex[:6]}:"
store = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
try:
session_id = f"valkey_session_{uuid.uuid4().hex[:8]}"
state = {
"agent_id": "agent_valkey_mgd",
"agent_version": 2,
"environment_id": "env_valkey_001",
"total_input_tokens": 1000,
"total_output_tokens": 500,
"compute_instance_id": "modal_valkey_xyz",
"session_history": [
{"id": session_id, "status": "idle", "title": "Valkey test"},
],
}
store.set_json(f"managed:{session_id}", state)
# Simulate restart — new store instance, same prefix
store2 = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
recovered = store2.get_json(f"managed:{session_id}")
assert recovered is not None
assert recovered["agent_id"] == "agent_valkey_mgd"
assert recovered["total_input_tokens"] == 1000
assert recovered["compute_instance_id"] == "modal_valkey_xyz"
assert len(recovered["session_history"]) == 1
store.close()
store2.close()
finally:
store3 = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
for k in store3.keys():
store3.delete(k)
store3.close()
# ===========================================================================
# 4b. Valkey Vector Search — ValkeyVectorKnowledgeStore against a live instance
# ===========================================================================
class TestValkeyVectorSearch:
"""Valkey vector search: real operations against a live ValkeySearch instance."""
@pytest.fixture(autouse=True)
def check_valkey_search(self):
"""Skip if Valkey is unreachable or ValkeySearch (FT.*) is not loaded."""
try:
from glide_sync import ft
except ImportError:
pytest.skip("valkey-glide-sync not installed")
try:
from praisonai.persistence._valkey_client import create_valkey_client
except ImportError as e:
pytest.skip(f"praisonai not importable: {e}")
client = None
try:
client = create_valkey_client(host="localhost", port=6379)
client.ping()
except Exception as e:
if client is not None:
client.close()
pytest.skip(f"Valkey not available on localhost:6379: {e}")
try:
# Probe FT.* support; raises if module not loaded
ft.list(client)
except Exception as e:
pytest.skip(f"ValkeySearch (FT.*) module not loaded on localhost:6379: {e}")
finally:
if client is not None:
client.close()
def test_valkey_vector_search_roundtrip(self):
"""Full create → insert → search → delete_collection roundtrip."""
from praisonai.persistence.knowledge.valkey_vector import ValkeyVectorKnowledgeStore
from praisonai.persistence.knowledge.base import KnowledgeDocument
prefix = f"praisontest_{uuid.uuid4().hex[:6]}_"
collection = f"vec_{uuid.uuid4().hex[:6]}"
dimension = 4
store = ValkeyVectorKnowledgeStore(host="localhost", port=6379, prefix=prefix)
try:
# Create index
store.create_collection(collection, dimension=dimension, distance="cosine")
assert store.collection_exists(collection)
# Insert documents with simple, distinguishable embeddings
docs = [
KnowledgeDocument(
id="doc_a",
content="alpha document",
embedding=[1.0, 0.0, 0.0, 0.0],
),
KnowledgeDocument(
id="doc_b",
content="bravo document",
embedding=[0.0, 1.0, 0.0, 0.0],
),
KnowledgeDocument(
id="doc_c",
content="charlie document",
embedding=[0.0, 0.0, 1.0, 0.0],
),
]
inserted = store.insert(collection, docs)
assert set(inserted) == {"doc_a", "doc_b", "doc_c"}
# Vector search: query closest to doc_a
results = store.search(collection, query_embedding=[1.0, 0.0, 0.0, 0.0], limit=2)
assert len(results) >= 1, "Expected at least one search result"
assert results[0].id == "doc_a", f"Expected doc_a as nearest neighbor, got {results[0].id}"
assert results[0].content == "alpha document"
# Verify the bare ID is returned (no prefix leakage)
for r in results:
assert ":" not in r.id or r.id in {"doc_a", "doc_b", "doc_c"}
# get() roundtrip
got = store.get(collection, ["doc_b"])
assert len(got) == 1
assert got[0].id == "doc_b"
assert got[0].content == "bravo document"
# Delete one doc
deleted = store.delete(collection, ids=["doc_c"])
assert deleted == 1
finally:
try:
store.delete_collection(collection)
finally:
store.close()
# ===========================================================================
# 5. MongoDB — StateStore operations + metadata persistence
# ===========================================================================
@@ -673,7 +873,6 @@ class TestClickHouseConnectivity:
def test_clickhouse_write_read(self):
"""Write and read data from ClickHouse."""
import clickhouse_connect
table = f"praison_test_{uuid.uuid4().hex[:8]}"
c = clickhouse_connect.get_client(
host="localhost", port=8123, username="clickhouse", password="clickhouse",
@@ -0,0 +1,84 @@
"""Unit tests for unified achat dispatch (Issue #1703)."""
import asyncio
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from praisonaiagents import Agent
def _fake_completion(content: str = "Mock async reply"):
return SimpleNamespace(
choices=[SimpleNamespace(message=SimpleNamespace(content=content, reasoning_content=None))]
)
@pytest.mark.asyncio
async def test_unified_achat_returns_after_single_dispatch():
"""achat must return after one unified dispatch, not loop forever."""
agent = Agent(name="test", instructions="You are helpful", llm="gpt-4o-mini")
agent._using_custom_llm = False
agent.self_reflect = False
mock_unified = AsyncMock(return_value=_fake_completion())
with patch.object(agent, "_execute_unified_achat_completion", mock_unified), patch.object(
agent, "_apply_guardrail_with_retry", return_value="Mock async reply"
), patch.object(
agent, "_atrigger_after_agent_hook", new_callable=AsyncMock, return_value="Mock async reply"
), patch.object(
agent, "_execute_callback_and_display"
), patch.object(
agent, "_build_messages", return_value=([{"role": "user", "content": "hi"}], "hi")
), patch.object(
agent, "_persist_message"
), patch.object(
agent, "_format_tools_for_completion", return_value=[]
):
result = await asyncio.wait_for(agent.achat("hi", stream=False), timeout=5)
assert result == "Mock async reply"
mock_unified.assert_called_once()
@pytest.mark.asyncio
async def test_unified_achat_completion_uses_execute_tool_async():
"""Async unified dispatch must wire execute_tool_async, not sync execute_tool."""
agent = Agent(name="test", instructions="You are helpful", llm="gpt-4o-mini")
agent._using_custom_llm = False
mock_dispatcher = MagicMock()
mock_dispatcher.achat_completion = AsyncMock(return_value=_fake_completion())
agent._unified_dispatcher = mock_dispatcher
with patch.object(agent, "_apply_guardrail_with_retry", return_value="ok"), patch.object(
agent, "_atrigger_after_agent_hook", new_callable=AsyncMock, return_value="ok"
), patch.object(agent, "_execute_callback_and_display"), patch.object(
agent, "_build_messages", return_value=([{"role": "user", "content": "hi"}], "hi")
), patch.object(agent, "_persist_message"), patch.object(
agent, "_format_tools_for_completion", return_value=[]
):
await agent._achat_impl(
prompt="hi",
temperature=1.0,
tools=None,
output_json=None,
output_pydantic=None,
reasoning_steps=False,
stream=False,
task_name=None,
task_description=None,
task_id=None,
config=None,
force_retrieval=False,
skip_retrieval=True,
attachments=None,
_trace_emitter=MagicMock(),
)
call_kwargs = mock_dispatcher.achat_completion.call_args[1]
tool_fn = call_kwargs.get("execute_tool_fn")
assert tool_fn is not None
assert getattr(tool_fn, "__name__", "") == "execute_tool_async"
@@ -48,6 +48,12 @@ class TestResolveMemory:
result = resolve_memory("redis")
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.REDIS
def test_string_valkey_backend(self):
"""'valkey' string returns MemoryConfig with VALKEY backend."""
result = resolve_memory("valkey")
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.VALKEY
def test_string_custom_backend(self):
"""Custom string backend is preserved."""
@@ -0,0 +1 @@
# Sandbox tests package
@@ -0,0 +1,199 @@
"""
Unit tests for SandboxManager.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from praisonaiagents.sandbox.manager import SandboxManager
from praisonaiagents.sandbox.config import SandboxConfig
from praisonaiagents.sandbox.protocols import SandboxResult, SandboxStatus
class TestSandboxManager:
"""Test SandboxManager functionality."""
def test_init_default_config(self):
"""Test initialization with default config."""
manager = SandboxManager()
assert manager.config is not None
assert manager.config.sandbox_type == "subprocess"
def test_init_with_config(self):
"""Test initialization with custom config."""
config = SandboxConfig.docker("python:3.11")
manager = SandboxManager(config)
assert manager.config == config
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
async def test_context_manager(self, mock_create_sandbox):
"""Test SandboxManager as async context manager."""
mock_sandbox = AsyncMock()
mock_create_sandbox.return_value = mock_sandbox
manager = SandboxManager()
async with manager as sandbox:
assert sandbox == mock_sandbox
mock_create_sandbox.assert_called_once()
# Cleanup should be called
mock_sandbox.stop.assert_called_once()
mock_sandbox.cleanup.assert_called_once()
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
async def test_context_manager_cleanup_error(self, mock_create_sandbox):
"""Test context manager cleanup with error."""
mock_sandbox = AsyncMock()
mock_sandbox.stop.side_effect = Exception("Stop failed")
mock_create_sandbox.return_value = mock_sandbox
manager = SandboxManager()
# Should not raise exception, just log warning
async with manager as sandbox:
assert sandbox == mock_sandbox
mock_sandbox.stop.assert_called_once()
mock_sandbox.cleanup.assert_called_once()
@patch('praisonaiagents.sandbox.manager.SandboxManager._create_sandbox')
async def test_run_code_convenience_method(self, mock_create_sandbox):
"""Test run_code convenience method."""
mock_sandbox = AsyncMock()
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="output")
mock_sandbox.execute.return_value = mock_result
mock_create_sandbox.return_value = mock_sandbox
manager = SandboxManager()
result = await manager.run_code("print('hello')", language="python")
assert result == mock_result
mock_sandbox.execute.assert_called_once_with("print('hello')", language="python")
async def test_create_sandbox_subprocess(self):
"""Test creating subprocess sandbox."""
config = SandboxConfig.subprocess()
manager = SandboxManager(config)
with patch('praisonai.sandbox.subprocess.SubprocessSandbox') as mock_class:
mock_instance = AsyncMock()
mock_class.return_value = mock_instance
mock_instance.is_available = True
sandbox = await manager._create_sandbox()
mock_class.assert_called_once_with(config)
assert sandbox == mock_instance
async def test_create_sandbox_docker(self):
"""Test creating Docker sandbox."""
config = SandboxConfig.docker("python:3.11")
manager = SandboxManager(config)
with patch('praisonai.sandbox.docker.DockerSandbox') as mock_class:
mock_instance = AsyncMock()
mock_class.return_value = mock_instance
mock_instance.is_available = True
sandbox = await manager._create_sandbox()
mock_class.assert_called_once_with(config)
assert sandbox == mock_instance
async def test_create_sandbox_e2b(self):
"""Test creating E2B sandbox."""
config = SandboxConfig.e2b()
manager = SandboxManager(config)
with patch('praisonai.sandbox.e2b.E2BSandbox') as mock_class:
mock_instance = AsyncMock()
mock_class.return_value = mock_instance
mock_instance.is_available = True
sandbox = await manager._create_sandbox()
mock_class.assert_called_once_with(config)
assert sandbox == mock_instance
async def test_create_sandbox_unknown_type(self):
"""Test creating sandbox with unknown type."""
config = SandboxConfig(sandbox_type="unknown")
manager = SandboxManager(config)
with pytest.raises(ValueError, match="Unknown sandbox type"):
await manager._create_sandbox()
async def test_create_sandbox_unavailable(self):
"""Test creating sandbox when not available."""
config = SandboxConfig.docker("python:3.11")
manager = SandboxManager(config)
with patch('praisonai.sandbox.docker.DockerSandbox') as mock_class:
mock_instance = Mock()
mock_instance.is_available = False
mock_class.return_value = mock_instance
with pytest.raises(RuntimeError, match="not available"):
await manager._create_sandbox()
async def test_create_sandbox_import_error(self):
"""Test creating sandbox with import error."""
config = SandboxConfig.e2b()
manager = SandboxManager(config)
with patch('importlib.import_module', side_effect=ImportError("Module not found")):
with pytest.raises(ImportError, match="Module not found"):
await manager._create_sandbox()
def test_get_available_types(self):
"""Test getting available sandbox types."""
manager = SandboxManager()
with patch('praisonaiagents.sandbox.manager.SandboxManager._check_availability') as mock_check:
mock_check.side_effect = lambda t: t in ["subprocess", "docker"]
available = manager.get_available_types()
assert "subprocess" in available
assert available["subprocess"] is True
assert "docker" in available
assert available["docker"] is True
assert "e2b" in available
assert available["e2b"] is False
def test_check_availability_subprocess(self):
"""Test checking subprocess availability."""
manager = SandboxManager()
# Subprocess should always be available
available = manager._check_availability("subprocess")
assert available is True
def test_check_availability_docker(self):
"""Test checking Docker availability."""
manager = SandboxManager()
with patch('importlib.import_module') as mock_import:
with patch.object(mock_import.return_value, 'DockerSandbox') as mock_class:
mock_instance = Mock()
mock_instance.is_available = True
mock_class.return_value = mock_instance
available = manager._check_availability("docker")
assert available is True
def test_check_availability_not_available(self):
"""Test checking availability when not available."""
manager = SandboxManager()
with patch('importlib.import_module', side_effect=ImportError()):
available = manager._check_availability("docker")
assert available is False
def test_check_availability_unknown(self):
"""Test checking availability for unknown type."""
manager = SandboxManager()
available = manager._check_availability("unknown")
assert available is False
@@ -0,0 +1,177 @@
"""
Unit tests for SandboxMixin functionality.
"""
import pytest
from unittest.mock import Mock, AsyncMock, patch
from praisonaiagents.agent.sandbox_mixin import SandboxMixin
from praisonaiagents.sandbox import SandboxConfig, SandboxResult, SandboxStatus
class MockAgent(SandboxMixin):
"""Mock agent class for testing SandboxMixin."""
def __init__(self, sandbox=None, verbose=False):
super().__init__(sandbox=sandbox)
self.verbose = verbose
class TestSandboxMixin:
"""Test SandboxMixin functionality."""
def test_init_no_sandbox(self):
"""Test initialization without sandbox."""
agent = MockAgent()
assert not agent.has_sandbox
assert agent.sandbox_config is None
def test_init_with_boolean_true(self):
"""Test initialization with sandbox=True."""
agent = MockAgent(sandbox=True)
assert agent.has_sandbox
assert agent.sandbox_config is not None
assert agent.sandbox_config.sandbox_type == "subprocess"
def test_init_with_config(self):
"""Test initialization with explicit config."""
config = SandboxConfig.docker("python:3.11")
agent = MockAgent(sandbox=config)
assert agent.has_sandbox
assert agent.sandbox_config == config
def test_get_sandbox_manager_no_config(self):
"""Test get_sandbox_manager without config."""
agent = MockAgent()
manager = agent.get_sandbox_manager()
assert manager is None
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
def test_get_sandbox_manager_with_config(self, mock_manager_class):
"""Test get_sandbox_manager with config."""
config = SandboxConfig.subprocess()
agent = MockAgent(sandbox=config)
manager = agent.get_sandbox_manager()
mock_manager_class.assert_called_once_with(config)
assert agent._sandbox_manager is not None
def test_execute_code_without_sandbox_raises(self):
"""Test that execute_code raises without sandbox."""
agent = MockAgent()
with pytest.raises(RuntimeError, match="No sandbox configured"):
agent.execute_code_sync("print('hello')")
@patch('praisonaiagents.agent.sandbox_mixin.check_code_safety')
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
async def test_execute_code_with_warnings(self, mock_manager_class, mock_check_safety):
"""Test execute_code with security warnings."""
# Setup
config = SandboxConfig.subprocess()
agent = MockAgent(sandbox=config, verbose=True)
mock_warnings = ["Potential security issue"]
mock_check_safety.return_value = mock_warnings
mock_manager = AsyncMock()
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="output")
mock_manager.run_code.return_value = mock_result
mock_manager_class.return_value = mock_manager
# Execute
result = await agent.execute_code("print('test')", check_security=True)
# Verify
mock_check_safety.assert_called_once_with("print('test')", "python")
mock_manager.run_code.assert_called_once()
assert result == mock_result
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
async def test_run_shell_command(self, mock_manager_class):
"""Test run_shell_command functionality."""
config = SandboxConfig.subprocess()
agent = MockAgent(sandbox=config)
# Mock manager as async context manager
mock_manager = AsyncMock()
mock_result = SandboxResult(status=SandboxStatus.COMPLETED, stdout="result")
mock_manager.__aenter__.return_value = mock_manager
mock_manager.__aexit__.return_value = None
mock_manager.run_command.return_value = mock_result
mock_manager_class.return_value = mock_manager
result = await agent.run_shell_command("echo hello")
mock_manager.run_command.assert_called_once_with("echo hello")
assert result == mock_result
def test_get_sandbox_status_no_config(self):
"""Test get_sandbox_status without config."""
agent = MockAgent()
status = agent.get_sandbox_status()
assert status == {"configured": False}
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
def test_get_sandbox_status_with_config(self, mock_manager_class):
"""Test get_sandbox_status with config."""
config = SandboxConfig.subprocess()
agent = MockAgent(sandbox=config)
mock_manager = Mock()
mock_manager.get_available_types.return_value = {"subprocess": True}
mock_manager_class.return_value = mock_manager
status = agent.get_sandbox_status()
assert status["configured"] is True
assert status["config"] is not None
assert status["available_types"] == {"subprocess": True}
assert status["current_type"] == "subprocess"
def test_get_code_execution_tools_no_sandbox(self):
"""Test _get_code_execution_tools without sandbox."""
agent = MockAgent()
tools = agent._get_code_execution_tools()
assert tools == []
@patch('praisonaiagents.agent.sandbox_mixin.SandboxManager')
def test_get_code_execution_tools_with_sandbox(self, mock_manager_class):
"""Test _get_code_execution_tools with sandbox."""
config = SandboxConfig.subprocess()
agent = MockAgent(sandbox=config)
tools = agent._get_code_execution_tools()
assert len(tools) == 2
# Check that tools are functions
assert callable(tools[0])
assert callable(tools[1])
async def test_sandbox_cleanup(self):
"""Test sandbox cleanup functionality."""
agent = MockAgent()
# Mock manager
mock_manager = AsyncMock()
agent._sandbox_manager = mock_manager
await agent.sandbox_cleanup()
mock_manager.__aexit__.assert_called_once_with(None, None, None)
assert agent._sandbox_manager is None
async def test_sandbox_cleanup_with_error(self):
"""Test sandbox cleanup with error handling."""
agent = MockAgent()
# Mock manager that raises exception
mock_manager = AsyncMock()
mock_manager.__aexit__.side_effect = Exception("Cleanup failed")
agent._sandbox_manager = mock_manager
# Should not raise, just log warning
await agent.sandbox_cleanup()
assert agent._sandbox_manager is None
@@ -0,0 +1,203 @@
"""
Unit tests for sandbox security module.
"""
import pytest
from praisonaiagents.sandbox.security import (
check_code_safety,
format_warnings,
SecurityWarning,
SecurityLevel,
SecurityAnalyzer
)
class TestCodeSafety:
"""Test code safety checking functionality."""
def test_empty_code(self):
"""Test empty code returns no warnings."""
warnings = check_code_safety("", "python")
assert warnings == []
def test_safe_code(self):
"""Test safe code returns no warnings."""
safe_code = """
x = 1 + 2
print(f"Result: {x}")
"""
warnings = check_code_safety(safe_code, "python")
assert warnings == []
def test_dangerous_imports(self):
"""Test detection of dangerous imports."""
dangerous_code = """
import os
import subprocess
os.system('rm -rf /')
"""
warnings = check_code_safety(dangerous_code, "python")
assert len(warnings) > 0
assert any("os.system" in str(w) for w in warnings)
def test_eval_exec_usage(self):
"""Test detection of eval/exec usage."""
dangerous_code = """
user_input = input("Enter code: ")
eval(user_input)
exec(user_input)
"""
warnings = check_code_safety(dangerous_code, "python")
assert len(warnings) > 0
assert any("eval" in str(w) or "exec" in str(w) for w in warnings)
def test_network_operations(self):
"""Test detection of network operations."""
network_code = """
import urllib.request
import socket
urllib.request.urlopen('http://example.com')
"""
warnings = check_code_safety(network_code, "python")
assert len(warnings) > 0
def test_file_operations(self):
"""Test detection of file operations."""
file_code = """
with open('/etc/passwd', 'r') as f:
data = f.read()
"""
warnings = check_code_safety(file_code, "python")
assert len(warnings) > 0
def test_bash_commands(self):
"""Test bash command safety checking."""
dangerous_bash = "rm -rf / --no-preserve-root"
warnings = check_code_safety(dangerous_bash, "bash")
assert len(warnings) > 0
def test_sql_injection_patterns(self):
"""Test SQL injection pattern detection."""
sql_code = """
query = f"SELECT * FROM users WHERE id = {user_id}"
cursor.execute(query)
"""
warnings = check_code_safety(sql_code, "python")
assert len(warnings) > 0
def test_format_warnings(self):
"""Test warning formatting."""
warnings = [
SecurityWarning("Test warning", SecurityLevel.HIGH, "eval", 1),
SecurityWarning("Another warning", SecurityLevel.MEDIUM, "import os", 2)
]
formatted = format_warnings(warnings)
assert "Test warning" in formatted
assert "Another warning" in formatted
assert "HIGH" in formatted
assert "MEDIUM" in formatted
def test_format_warnings_empty(self):
"""Test formatting empty warnings list."""
formatted = format_warnings([])
assert formatted == ""
class TestSecurityAnalyzer:
"""Test SecurityAnalyzer class."""
def test_analyzer_initialization(self):
"""Test SecurityAnalyzer initialization."""
analyzer = SecurityAnalyzer()
assert analyzer is not None
def test_analyze_safe_code(self):
"""Test analyzer with safe code."""
analyzer = SecurityAnalyzer()
code = "print('Hello, World!')"
warnings = analyzer.analyze(code, "python")
assert warnings == []
def test_analyze_unsafe_code(self):
"""Test analyzer with unsafe code."""
analyzer = SecurityAnalyzer()
code = "import os; os.system('rm -rf /')"
warnings = analyzer.analyze(code, "python")
assert len(warnings) > 0
assert all(isinstance(w, SecurityWarning) for w in warnings)
def test_different_languages(self):
"""Test analyzer with different languages."""
analyzer = SecurityAnalyzer()
# Python
python_warnings = analyzer.analyze("import os", "python")
assert len(python_warnings) > 0
# Bash
bash_warnings = analyzer.analyze("rm -rf /", "bash")
assert len(bash_warnings) > 0
# JavaScript (should fall back to basic checks)
js_warnings = analyzer.analyze("eval(userInput)", "javascript")
assert len(js_warnings) > 0
def test_security_levels(self):
"""Test different security levels are detected."""
analyzer = SecurityAnalyzer()
# High risk
high_risk_code = "import os; os.system('rm -rf /')"
warnings = analyzer.analyze(high_risk_code, "python")
high_warnings = [w for w in warnings if w.level == SecurityLevel.HIGH]
assert len(high_warnings) > 0
# Medium risk
medium_risk_code = "import subprocess"
warnings = analyzer.analyze(medium_risk_code, "python")
medium_warnings = [w for w in warnings if w.level == SecurityLevel.MEDIUM]
# May or may not have medium warnings depending on implementation
def test_line_numbers(self):
"""Test that line numbers are correctly reported."""
analyzer = SecurityAnalyzer()
code = """
print("Safe line")
import os
os.system('dangerous')
"""
warnings = analyzer.analyze(code, "python")
assert len(warnings) > 0
# Check that line numbers are reasonable
line_numbers = [w.line_number for w in warnings if w.line_number is not None]
assert all(ln > 0 for ln in line_numbers)
assert all(ln <= 4 for ln in line_numbers) # Within the code block
def test_pattern_matching(self):
"""Test that specific patterns are matched correctly."""
analyzer = SecurityAnalyzer()
test_cases = [
("eval(x)", ["eval"]),
("exec('code')", ["exec"]),
("__import__('os')", ["__import__"]),
("open('/etc/passwd')", ["file access"]),
("subprocess.call(['rm', 'file'])", ["subprocess"]),
]
for code, expected_patterns in test_cases:
warnings = analyzer.analyze(code, "python")
assert len(warnings) > 0, f"No warnings for: {code}"
# Check that at least one expected pattern is found
warning_texts = [str(w) for w in warnings]
found = any(
any(pattern.lower() in text.lower() for text in warning_texts)
for pattern in expected_patterns
)
assert found, f"Expected patterns {expected_patterns} not found in warnings for: {code}"
@@ -0,0 +1,300 @@
"""Unit tests for the ALLOWED_TOOLS filter module."""
import os
import logging
from unittest.mock import patch, MagicMock
import pytest
from praisonaiagents.allowed_tools_filter import AllowedToolsFilter, filter_tools_with_allowed_tools
class TestAllowedToolsFilter:
"""Test cases for AllowedToolsFilter class."""
def test_filter_initialization_unset(self):
"""Test filter initialization with no environment variable set."""
with patch.dict(os.environ, {}, clear=True):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value is None
assert not filter_instance.is_enabled()
assert filter_instance.get_whitelist() is None
def test_filter_initialization_primary_var(self):
"""Test filter initialization with ALLOWED_TOOLS set."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value == "search,send_message"
assert filter_instance.env_var_name == "ALLOWED_TOOLS"
assert filter_instance.is_enabled()
assert filter_instance.get_whitelist() == {"search", "send_message"}
def test_filter_initialization_legacy_var(self):
"""Test filter initialization with HERMES_ONLY_TOOLS set (backward compatibility)."""
with patch.dict(os.environ, {"HERMES_ONLY_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value == "search,send_message"
assert filter_instance.env_var_name == "HERMES_ONLY_TOOLS"
assert filter_instance.is_enabled()
assert filter_instance.get_whitelist() == {"search", "send_message"}
def test_filter_initialization_primary_precedence(self):
"""Test that ALLOWED_TOOLS takes precedence over HERMES_ONLY_TOOLS."""
with patch.dict(os.environ, {
"ALLOWED_TOOLS": "search,send_message",
"HERMES_ONLY_TOOLS": "old_tool"
}):
filter_instance = AllowedToolsFilter()
assert filter_instance.env_value == "search,send_message"
assert filter_instance.env_var_name == "ALLOWED_TOOLS"
assert filter_instance.get_whitelist() == {"search", "send_message"}
def test_filter_initialization_empty_string_error(self):
"""Test that empty string raises ValueError."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": ""}):
with pytest.raises(ValueError, match="ALLOWED_TOOLS cannot be empty"):
AllowedToolsFilter()
def test_filter_initialization_whitespace_only_error(self):
"""Test that whitespace-only string raises ValueError."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": " "}):
with pytest.raises(ValueError, match="ALLOWED_TOOLS cannot be empty"):
AllowedToolsFilter()
def test_filter_initialization_empty_after_split_error(self):
"""Test that comma-only string raises ValueError."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": ",,,"}):
with pytest.raises(ValueError, match="contains no valid tool names"):
AllowedToolsFilter()
def test_parse_whitelist_with_spaces(self):
"""Test parsing comma-separated values with spaces."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": " search , send_message , extract_pdf "}):
filter_instance = AllowedToolsFilter()
assert filter_instance.get_whitelist() == {"search", "send_message", "extract_pdf"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool,send_message", "CI": "false"})
def test_filter_tools_with_unknown_tools_dev_mode(self):
"""Test filtering tools with unknown tools in development mode."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with patch.object(logging.getLogger(), 'warning') as mock_warn:
filtered = filter_instance.filter_tools(available_tools)
# Should return intersection of available and whitelisted tools
assert filtered == {"search", "send_message"}
# Should warn about unknown tools
mock_warn.assert_called()
call_args = mock_warn.call_args[0]
assert "Unknown tools" in call_args[0]
assert ["unknown_tool"] == call_args[2]
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,unknown_tool,send_message", "CI": "true"})
def test_filter_tools_with_unknown_tools_ci_mode(self):
"""Test filtering tools with unknown tools in CI mode."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with pytest.raises(ValueError, match="Unknown tools in ALLOWED_TOOLS"):
filter_instance.filter_tools(available_tools)
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_all_available(self):
"""Test filtering when all whitelisted tools are available."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_partial_match(self):
"""Test filtering when only some whitelisted tools are available."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_dict_input(self):
"""Test filtering with dictionary input."""
filter_instance = AllowedToolsFilter()
available_tools = {"search": lambda: None, "send_message": lambda: None, "extract_pdf": lambda: None}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_list_input(self):
"""Test filtering with list input."""
filter_instance = AllowedToolsFilter()
available_tools = ["search", "send_message", "extract_pdf"]
filtered = filter_instance.filter_tools(available_tools)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message,unknown_in_whitelist"})
def test_diagnostics_data(self):
"""Test diagnostics data collection."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
diagnostics = filter_instance.get_diagnostics()
assert diagnostics["env_var_name"] == "ALLOWED_TOOLS"
assert diagnostics["env_value"] == "search,send_message,unknown_in_whitelist"
assert diagnostics["whitelist"] == ["search", "send_message", "unknown_in_whitelist"]
assert set(diagnostics["registered_before_filter"]) == {"search", "send_message", "extract_pdf"}
assert set(diagnostics["registered_after_filter"]) == {"search", "send_message"}
assert set(diagnostics["dropped_tools"]) == {"extract_pdf"}
assert set(diagnostics["unknown_tools"]) == {"unknown_in_whitelist"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_log_diagnostics(self, caplog):
"""Test diagnostics logging."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with caplog.at_level(logging.INFO):
filter_instance.filter_tools(available_tools)
filter_instance.log_diagnostics()
log_text = caplog.text
assert "ALLOWED_TOOLS FILTER DIAGNOSTICS" in log_text
assert "ALLOWED_TOOLS=search,send_message" in log_text
assert "RegisteredBeforeFilter" in log_text
assert "RegisteredAfterFilter" in log_text
assert "DroppedTools" in log_text
assert "UnknownTools" in log_text
def test_log_diagnostics_before_filtering(self, caplog):
"""Test logging diagnostics before calling filter_tools."""
filter_instance = AllowedToolsFilter()
with caplog.at_level(logging.WARNING):
filter_instance.log_diagnostics()
assert "No diagnostics available" in caplog.text
def test_filter_tools_unset_env_var(self, caplog):
"""Test filtering with unset environment variable."""
with patch.dict(os.environ, {}, clear=True):
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
with caplog.at_level(logging.WARNING):
filtered = filter_instance.filter_tools(available_tools)
# Should return all tools
assert filtered == available_tools
assert "Tool whitelist is unset" in caplog.text
@patch.dict(os.environ, {"ALLOWED_TOOLS": "tool1", "CI": "false"})
def test_no_matching_tools(self):
"""Test filtering when no whitelisted tools match available tools."""
filter_instance = AllowedToolsFilter()
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_instance.filter_tools(available_tools)
assert filtered == set() # Empty set when no matches
def test_custom_env_var_name(self):
"""Test using custom environment variable name."""
with patch.dict(os.environ, {"CUSTOM_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter("CUSTOM_TOOLS")
assert filter_instance.env_var_name == "CUSTOM_TOOLS"
assert filter_instance.get_whitelist() == {"search", "send_message"}
class TestConvenienceFunction:
"""Test cases for the convenience function."""
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"})
def test_filter_tools_with_allowed_tools(self):
"""Test the convenience function."""
available_tools = {"search", "send_message", "extract_pdf"}
filtered = filter_tools_with_allowed_tools(available_tools, log_diagnostics=False)
assert filtered == {"search", "send_message"}
@patch.dict(os.environ, {"CUSTOM_TOOLS": "search"})
def test_filter_tools_with_custom_env_var(self):
"""Test the convenience function with custom env var."""
available_tools = {"search", "send_message"}
filtered = filter_tools_with_allowed_tools(available_tools, env_var_name="CUSTOM_TOOLS", log_diagnostics=False)
assert filtered == {"search"}
@patch.dict(os.environ, {"ALLOWED_TOOLS": "search"})
def test_filter_tools_with_diagnostics(self, caplog):
"""Test the convenience function with diagnostics enabled."""
available_tools = {"search", "send_message"}
with caplog.at_level(logging.INFO):
filtered = filter_tools_with_allowed_tools(available_tools, log_diagnostics=True)
assert filtered == {"search"}
assert "ALLOWED_TOOLS FILTER DIAGNOSTICS" in caplog.text
class TestBackwardCompatibility:
"""Test backward compatibility aliases."""
@patch.dict(os.environ, {"HERMES_ONLY_TOOLS": "search,send_message"})
def test_legacy_class_alias(self):
"""Test HermesToolFilter alias works."""
from praisonaiagents.allowed_tools_filter import HermesToolFilter
filter_instance = HermesToolFilter()
assert isinstance(filter_instance, AllowedToolsFilter)
assert filter_instance.env_var_name == "HERMES_ONLY_TOOLS"
assert filter_instance.get_whitelist() == {"search", "send_message"}
@patch.dict(os.environ, {"HERMES_ONLY_TOOLS": "search,send_message"})
def test_legacy_function_aliases(self):
"""Test legacy function aliases work."""
from praisonaiagents.allowed_tools_filter import (
filter_tools_with_hermes, hermes_filter, apply_hermes_filter
)
available_tools = {"search", "send_message", "extract_pdf"}
# All aliases should work and return the same result
result1 = filter_tools_with_hermes(available_tools, log_diagnostics=False)
result2 = hermes_filter(available_tools, log_diagnostics=False)
result3 = apply_hermes_filter(available_tools, log_diagnostics=False)
expected = {"search", "send_message"}
assert result1 == expected
assert result2 == expected
assert result3 == expected
def test_empty_tools_edge_case(self):
"""Test filtering with empty available tools."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
filtered = filter_instance.filter_tools(set())
assert filtered == set()
# Also test with empty list and dict
assert filter_instance.filter_tools([]) == set()
assert filter_instance.filter_tools({}) == set()
def test_whitelist_immutability(self):
"""Test that get_whitelist returns a copy, not the original."""
with patch.dict(os.environ, {"ALLOWED_TOOLS": "search,send_message"}):
filter_instance = AllowedToolsFilter()
whitelist1 = filter_instance.get_whitelist()
whitelist2 = filter_instance.get_whitelist()
# Should be equal but not the same object
assert whitelist1 == whitelist2
assert whitelist1 is not whitelist2
# Modifying one shouldn't affect the other
whitelist1.add("new_tool")
assert whitelist1 != whitelist2
+628 -299
View File
File diff suppressed because it is too large. Load diff
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-19",
"lastUpdated": "2026-05-20",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-19",
"lastUpdated": "2026-05-20",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
+1 -1
View File
@@ -1,6 +1,6 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-05-19
> **Version:** 1.5.87 | **Last Updated:** 2026-05-20
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.40.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.43.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.43.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
@@ -0,0 +1,53 @@
"""Single source of truth for optional-framework availability."""
import importlib.util
import importlib.metadata
import threading
from typing import Callable
_cache: dict[str, bool] = {}
_lock = threading.Lock()
def _ag2_probe() -> bool:
# AG2 ships under the `autogen` namespace; presence of the dist is
# the only reliable signal.
try:
importlib.metadata.distribution('ag2')
except importlib.metadata.PackageNotFoundError:
return False
return importlib.util.find_spec("autogen") is not None
def _autogen_v4_probe() -> bool:
return (importlib.util.find_spec("autogen_agentchat") is not None
and importlib.util.find_spec("autogen_ext") is not None)
_PROBES: dict[str, Callable[[], bool]] = {
"crewai": lambda: importlib.util.find_spec("crewai") is not None,
"autogen": lambda: importlib.util.find_spec("autogen") is not None,
"autogen_v4": _autogen_v4_probe,
"ag2": _ag2_probe,
"praisonaiagents": lambda: importlib.util.find_spec("praisonaiagents") is not None,
"praisonai_tools": lambda: importlib.util.find_spec("praisonai_tools") is not None,
"agentops": lambda: importlib.util.find_spec("agentops") is not None,
"litellm": lambda: importlib.util.find_spec("litellm") is not None,
"openai": lambda: importlib.util.find_spec("openai") is not None,
}
def is_available(name: str) -> bool:
if name not in _PROBES:
raise ValueError(f"unknown framework name: {name!r}")
cached = _cache.get(name)
if cached is not None:
return cached
with _lock:
cached = _cache.get(name)
if cached is None:
cached = bool(_PROBES[name]())
_cache[name] = cached
return cached
def invalidate(name: str | None = None) -> None:
with _lock:
if name is None:
_cache.clear()
else:
_cache.pop(name, None)
+10 -17
View File
@@ -23,12 +23,8 @@ from .framework_adapters.registry import FrameworkAdapterRegistry, get_default_r
from .tool_registry import ToolRegistry
# Import availability flags
try:
from .inbuilt_tools import PRAISONAI_TOOLS_AVAILABLE, CREWAI_AVAILABLE, AUTOGEN_AVAILABLE
except ImportError:
PRAISONAI_TOOLS_AVAILABLE = False
CREWAI_AVAILABLE = False
AUTOGEN_AVAILABLE = False
# Compatibility imports - now handled by centralized detection
# (inbuilt_tools still defines these but they're read-only compatibility)
# Import BaseTool for tools handling
BaseTool = None
@@ -40,17 +36,14 @@ except ImportError:
except ImportError:
pass
# Check for additional framework availability
AG2_AVAILABLE = False
PRAISONAI_AVAILABLE = False
AGENTOPS_AVAILABLE = False
try:
import importlib.util
AG2_AVAILABLE = importlib.util.find_spec("ag2") is not None
PRAISONAI_AVAILABLE = importlib.util.find_spec("praisonaiagents") is not None
AGENTOPS_AVAILABLE = importlib.util.find_spec("agentops") is not None
except ImportError:
pass
# Check for additional framework availability using centralized detection
from ._framework_availability import is_available
PRAISONAI_TOOLS_AVAILABLE = is_available("praisonai_tools")
CREWAI_AVAILABLE = is_available("crewai")
AUTOGEN_AVAILABLE = is_available("autogen")
AG2_AVAILABLE = is_available("ag2")
PRAISONAI_AVAILABLE = is_available("praisonaiagents")
AGENTOPS_AVAILABLE = is_available("agentops")
# Framework adapter registry - now uses proper registry pattern
# This replaces the hardcoded FRAMEWORK_ADAPTERS dict
+34 -53
View File
@@ -61,10 +61,40 @@ def _load_optional(key: str, loader):
# --- CrewAI lazy loading ---
# Availability checks now delegated to centralized module
from ._framework_availability import is_available
def _check_crewai_available() -> bool:
"""Check if crewai is available (cached, thread-safe)."""
result = _load_optional("crewai_check", lambda: __import__("crewai"))
return result is not None
return is_available("crewai")
def _check_autogen_available() -> bool:
"""Check if autogen v0.2 is available (cached, thread-safe)."""
return is_available("autogen")
def _check_autogen_v4_available() -> bool:
"""Check if autogen v0.4 is available (cached, thread-safe)."""
return is_available("autogen_v4")
def _check_ag2_available() -> bool:
"""Check if AG2 (community fork of AutoGen) is available (cached, thread-safe)."""
return is_available("ag2")
def _check_praisonai_available() -> bool:
"""Check if praisonaiagents is available (cached, thread-safe)."""
return is_available("praisonaiagents")
def _check_praisonai_tools_available() -> bool:
"""Check if praisonai_tools is available (cached, thread-safe)."""
return is_available("praisonai_tools")
def _check_litellm_available() -> bool:
"""Check if litellm is available (cached, thread-safe)."""
return is_available("litellm")
def _check_openai_available() -> bool:
"""Check if openai is available (cached, thread-safe)."""
return is_available("openai")
def _get_crewai():
@@ -76,31 +106,6 @@ def _get_crewai():
))
# --- AutoGen lazy loading ---
def _check_autogen_available() -> bool:
"""Check if autogen v0.2 is available (cached, thread-safe)."""
result = _load_optional("autogen_check", lambda: __import__("autogen"))
return result is not None
def _check_autogen_v4_available() -> bool:
"""Check if autogen v0.4 is available (cached, thread-safe)."""
result = _load_optional("autogen_v4_check", lambda: __import__("autogen_agentchat.agents", fromlist=["AssistantAgent"]))
return result is not None
def _check_ag2_available() -> bool:
"""Check if AG2 (community fork of AutoGen) is available (cached, thread-safe)."""
def ag2_loader():
import importlib.metadata
importlib.metadata.distribution('ag2')
from autogen import LLMConfig # AG2-exclusive class
return True
result = _load_optional("ag2_check", ag2_loader)
return result is not None
def _get_autogen():
"""Lazy load autogen module (thread-safe)."""
return _load_optional("autogen_module", lambda: __import__("autogen"))
@@ -116,13 +121,6 @@ def _get_autogen_v4():
return _load_optional("autogen_v4_classes", autogen_v4_loader)
# --- PraisonAI Agents lazy loading ---
def _check_praisonai_available() -> bool:
"""Check if praisonaiagents is available (cached, thread-safe)."""
result = _load_optional("praisonai_check", lambda: __import__("praisonaiagents"))
return result is not None
def _get_praisonai():
"""Lazy load praisonaiagents classes (thread-safe)."""
def praisonai_loader():
@@ -133,12 +131,6 @@ def _get_praisonai():
# --- PraisonAI Tools lazy loading ---
def _check_praisonai_tools_available() -> bool:
"""Check if praisonai_tools is available (cached, thread-safe)."""
result = _load_optional("praisonai_tools_check", lambda: __import__("praisonai_tools"))
return result is not None
def _get_praisonai_tools():
"""Lazy load praisonai_tools classes (thread-safe)."""
def tools_loader():
@@ -172,13 +164,6 @@ def _get_praisonai_tools():
return _load_optional("praisonai_tools_dict", tools_loader)
# --- LiteLLM lazy loading ---
def _check_litellm_available() -> bool:
"""Check if litellm is available (cached)."""
result = _load_optional("litellm_check", lambda: __import__("litellm"))
return result is not None
def _get_litellm():
"""Lazy load litellm module."""
result = _load_optional("litellm", lambda: __import__("litellm"))
@@ -188,10 +173,6 @@ def _get_litellm():
# --- OpenAI lazy loading ---
def _check_openai_available() -> bool:
"""Check if openai is available (cached)."""
result = _load_optional("openai_check", lambda: __import__("openai"))
return result is not None
@@ -970,7 +951,7 @@ Use the recommended tools: {', '.join(recommended_tools)}
# Workflow Auto-Generation (Feature Parity)
# =============================================================================
class TaskDetails(BaseModel):
class WorkflowStepDetails(BaseModel):
"""Details for a workflow step."""
agent: str
action: str
@@ -984,7 +965,7 @@ class WorkflowRouteDetails(BaseModel):
class WorkflowParallelDetails(BaseModel):
"""Details for a parallel step."""
name: str
parallel: List[TaskDetails]
parallel: List[WorkflowStepDetails]
class WorkflowAgentDetails(BaseModel):
"""Details for a workflow agent."""
@@ -119,6 +119,20 @@ def doctor_network(
raise typer.Exit(_run_doctor(args))
@app.command("packaging")
def doctor_packaging(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
):
"""Check packaging and entry point configuration (Windows daemon compatibility)."""
args = ["packaging"]
if deep:
args.append("--deep")
if json_output:
args.append("--json")
raise typer.Exit(_run_doctor(args))
@app.command("performance")
def doctor_performance(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
+37 -34
View File
@@ -73,48 +73,52 @@ def _get_process_using_port(port: int) -> Optional[Dict]:
elif sys.platform == "win32":
# Use netstat on Windows
result = subprocess.run(
["netstat", "-ano", "|", "findstr", f":{port}"],
["netstat", "-ano"],
capture_output=True,
text=True,
shell=True,
timeout=5
timeout=10
)
if result.returncode != 0 or not result.stdout:
return None
# Parse netstat output
# Parse netstat output and filter by exact port
lines = result.stdout.strip().split("\n")
for line in lines:
if f":{port}" in line and "LISTENING" in line:
parts = line.split()
if len(parts) >= 5:
pid = parts[-1]
# Get process name from PID
try:
proc_result = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}"],
capture_output=True,
text=True,
timeout=5
)
name = "unknown"
if proc_result.returncode == 0:
proc_lines = proc_result.stdout.strip().split("\n")
if len(proc_lines) >= 2:
name = proc_lines[1].split()[0]
return {
"name": name,
"pid": pid,
"user": "unknown",
"type": "IPv4",
"protocol": "TCP",
"port": port,
"raw": line
}
except Exception:
pass
parts = line.split()
if len(parts) >= 5 and "LISTENING" in line:
local_addr = parts[1]
# Extract port from address, handle IPv6 format [::]:port and IPv4 format host:port
port_match = re.search(r":(\d+)$", local_addr)
if not port_match or int(port_match.group(1)) != port:
continue
pid = parts[-1]
# Get process name from PID
try:
proc_result = subprocess.run(
["tasklist", "/FI", f"PID eq {pid}"],
capture_output=True,
text=True,
timeout=5
)
name = "unknown"
if proc_result.returncode == 0:
proc_lines = proc_result.stdout.strip().split("\n")
if len(proc_lines) >= 2:
name = proc_lines[1].split()[0]
return {
"name": name,
"pid": pid,
"user": "unknown",
"type": "IPv4",
"protocol": "TCP",
"port": port,
"raw": line
}
except Exception:
pass
except subprocess.TimeoutExpired:
pass
@@ -166,7 +170,6 @@ def _get_all_ports() -> List[Dict]:
["netstat", "-ano"],
capture_output=True,
text=True,
shell=True,
timeout=10
)
@@ -123,6 +123,8 @@ class MultiAgentHandler:
def _load_tools(self, tool_names: List[str]) -> List:
"""Load tool functions by name.
Applies ALLOWED_TOOLS filter if enabled to prevent tool name collisions.
Args:
tool_names: List of tool names to load
@@ -148,6 +150,26 @@ class MultiAgentHandler:
'analyze_csv': analyze_csv,
}
# Apply ALLOWED_TOOLS filter if enabled
try:
from praisonaiagents.allowed_tools_filter import filter_tools_with_allowed_tools
available_tools = set(tool_map.keys())
filtered_tools = filter_tools_with_allowed_tools(available_tools, log_diagnostics=False)
# Filter tool_map to only include whitelisted tools
tool_map = {name: func for name, func in tool_map.items() if name in filtered_tools}
except ImportError:
# ALLOWED_TOOLS filter not available, proceed normally
pass
except ValueError:
# Preserve strict filter semantics (invalid config / CI unknown tools)
raise
except Exception as e:
if self.verbose:
print(f"⚠ ALLOWED_TOOLS filter error: {e}")
raise
for name in tool_names:
if name in tool_map:
tools.append(tool_map[name])
@@ -23,3 +23,4 @@ def register_all_checks():
from . import lsp_checks
from . import acp_checks
from . import bot_checks
from . import packaging_checks
@@ -0,0 +1,422 @@
"""
Packaging checks for the Doctor CLI module.
Validates PraisonAI packaging and entry point configurations to diagnose
Windows daemon invocation issues (python -m praisonai vs praisonai console script).
"""
import os
import subprocess
import sys
import platform
import shutil
from ..models import (
CheckCategory,
CheckResult,
CheckStatus,
CheckSeverity,
DoctorConfig,
)
from ..registry import register_check
@register_check(
id="praisonai_package_structure",
title="PraisonAI Package Structure",
description="Check if praisonai package has correct structure for both -m and script execution",
category=CheckCategory.PACKAGING,
severity=CheckSeverity.HIGH,
)
def check_praisonai_package_structure(config: DoctorConfig) -> CheckResult:
"""Check PraisonAI package structure and entry points."""
issues = []
metadata = {}
try:
import praisonai
package_path = praisonai.__file__
if package_path:
package_dir = os.path.dirname(package_path)
metadata["package_path"] = package_path
metadata["package_dir"] = package_dir
# Check for __main__.py
main_py_path = os.path.join(package_dir, "__main__.py")
if os.path.exists(main_py_path):
metadata["has_main_py"] = True
metadata["main_py_path"] = main_py_path
else:
issues.append("Missing __main__.py - python -m praisonai will not work")
metadata["has_main_py"] = False
else:
issues.append("praisonai.__file__ is None - namespace package detected")
except ImportError as e:
issues.append(f"Cannot import praisonai: {e}")
metadata["import_error"] = str(e)
# Check console script availability
praisonai_script = shutil.which("praisonai")
if praisonai_script:
metadata["console_script_path"] = praisonai_script
metadata["has_console_script"] = True
else:
issues.append("praisonai console script not found in PATH")
metadata["has_console_script"] = False
if not issues:
return CheckResult(
id="praisonai_package_structure",
title="PraisonAI Package Structure",
category=CheckCategory.PACKAGING,
status=CheckStatus.PASS,
message="Package structure is correct for both -m and script execution",
metadata=metadata,
)
else:
return CheckResult(
id="praisonai_package_structure",
title="PraisonAI Package Structure",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"Package structure issues: {'; '.join(issues)}",
metadata=metadata,
)
@register_check(
id="python_module_execution",
title="Python Module Execution",
description="Test if python -m praisonai works correctly",
category=CheckCategory.PACKAGING,
severity=CheckSeverity.HIGH,
)
def check_python_module_execution(config: DoctorConfig) -> CheckResult:
"""Test python -m praisonai execution."""
metadata = {"test_command": f"{sys.executable} -m praisonai --version"}
try:
result = subprocess.run(
[sys.executable, "-m", "praisonai", "--version"],
capture_output=True,
text=True,
timeout=config.timeout,
)
metadata["return_code"] = result.returncode
metadata["stdout"] = result.stdout.strip()
metadata["stderr"] = result.stderr.strip()
if result.returncode == 0:
return CheckResult(
id="python_module_execution",
title="Python Module Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.PASS,
message=f"python -m praisonai works: {result.stdout.strip() or result.stderr.strip()}",
metadata=metadata,
)
else:
return CheckResult(
id="python_module_execution",
title="Python Module Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"python -m praisonai failed with exit code {result.returncode}",
metadata=metadata,
)
except subprocess.TimeoutExpired:
return CheckResult(
id="python_module_execution",
title="Python Module Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"python -m praisonai timed out (>{config.timeout}s)",
metadata=metadata,
)
except Exception as e:
metadata["exception"] = str(e)
return CheckResult(
id="python_module_execution",
title="Python Module Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"python -m praisonai execution failed: {e}",
metadata=metadata,
)
@register_check(
id="console_script_execution",
title="Console Script Execution",
description="Test if praisonai console script works correctly",
category=CheckCategory.PACKAGING,
severity=CheckSeverity.HIGH,
)
def check_console_script_execution(config: DoctorConfig) -> CheckResult:
"""Test praisonai console script execution."""
praisonai_script = shutil.which("praisonai")
if not praisonai_script:
return CheckResult(
id="console_script_execution",
title="Console Script Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message="praisonai console script not found in PATH",
metadata={"script_path": None},
)
metadata = {
"script_path": praisonai_script,
"test_command": f"{praisonai_script} --version"
}
try:
result = subprocess.run(
[praisonai_script, "--version"],
capture_output=True,
text=True,
timeout=config.timeout,
)
metadata["return_code"] = result.returncode
metadata["stdout"] = result.stdout.strip()
metadata["stderr"] = result.stderr.strip()
if result.returncode == 0:
return CheckResult(
id="console_script_execution",
title="Console Script Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.PASS,
message=f"praisonai console script works: {result.stdout.strip() or result.stderr.strip()}",
metadata=metadata,
)
else:
return CheckResult(
id="console_script_execution",
title="Console Script Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"praisonai console script failed with exit code {result.returncode}",
metadata=metadata,
)
except subprocess.TimeoutExpired:
return CheckResult(
id="console_script_execution",
title="Console Script Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"praisonai console script timed out (>{config.timeout}s)",
metadata=metadata,
)
except Exception as e:
metadata["exception"] = str(e)
return CheckResult(
id="console_script_execution",
title="Console Script Execution",
category=CheckCategory.PACKAGING,
status=CheckStatus.FAIL,
message=f"praisonai console script execution failed: {e}",
metadata=metadata,
)
@register_check(
id="windows_daemon_context",
title="Windows Daemon Context",
description="Check Windows-specific daemon execution context",
category=CheckCategory.PACKAGING,
severity=CheckSeverity.MEDIUM,
)
def check_windows_daemon_context(config: DoctorConfig) -> CheckResult:
"""Check Windows daemon execution context and provide recommendations."""
metadata = {
"platform": platform.system(),
"is_windows": platform.system().lower() == "windows"
}
if not metadata["is_windows"]:
return CheckResult(
id="windows_daemon_context",
title="Windows Daemon Context",
category=CheckCategory.PACKAGING,
status=CheckStatus.SKIP,
message="Not Windows - check skipped",
metadata=metadata,
)
recommendations = []
warnings = []
# Check if in virtual environment
in_venv = hasattr(sys, 'real_prefix') or (hasattr(sys, 'base_prefix') and sys.base_prefix != sys.prefix)
metadata["in_venv"] = in_venv
metadata["sys_prefix"] = sys.prefix
metadata["sys_executable"] = sys.executable
if in_venv:
venv_scripts_dir = os.path.join(sys.prefix, "Scripts")
metadata["venv_scripts_dir"] = venv_scripts_dir
# Check if Scripts dir exists and has praisonai.exe
if os.path.exists(venv_scripts_dir):
praisonai_exe = os.path.join(venv_scripts_dir, "praisonai.exe")
metadata["venv_praisonai_exe_exists"] = os.path.exists(praisonai_exe)
if os.path.exists(praisonai_exe):
recommendations.append(f"For daemons, use absolute path: {praisonai_exe}")
else:
warnings.append("praisonai.exe not found in venv Scripts directory")
else:
warnings.append("Virtual environment Scripts directory not found")
else:
warnings.append("Not in virtual environment - daemon may use global Python")
# Check Python executable type
if "WindowsApps" in sys.executable:
warnings.append("Using Microsoft Store Python - may cause daemon issues")
recommendations.append("Consider using python.org Python or conda for daemons")
# Check encoding environment
pythonioencoding = os.environ.get("PYTHONIOENCODING")
metadata["pythonioencoding"] = pythonioencoding
if not pythonioencoding:
recommendations.append("Set PYTHONIOENCODING=utf-8 for daemon encoding safety")
# Generate canonical daemon command
praisonai_script = shutil.which("praisonai")
if praisonai_script:
canonical_command = f'"{praisonai_script}"'
else:
canonical_command = f'"{sys.executable}" -m praisonai'
metadata["canonical_daemon_command"] = canonical_command
recommendations.append(f"Recommended daemon command: {canonical_command}")
if warnings:
status = CheckStatus.WARN
message = f"Windows daemon context has warnings: {'; '.join(warnings[:2])}"
else:
status = CheckStatus.PASS
message = "Windows daemon context is good"
if recommendations:
metadata["recommendations"] = recommendations
return CheckResult(
id="windows_daemon_context",
title="Windows Daemon Context",
category=CheckCategory.PACKAGING,
status=status,
message=message,
metadata=metadata,
)
@register_check(
id="packaging_metadata",
title="Packaging Metadata",
description="Check PraisonAI package installation metadata",
category=CheckCategory.PACKAGING,
severity=CheckSeverity.LOW,
)
def check_packaging_metadata(config: DoctorConfig) -> CheckResult:
"""Check PraisonAI package installation metadata."""
metadata = {}
issues = []
try:
import praisonai
metadata["praisonai_file"] = getattr(praisonai, "__file__", None)
metadata["praisonai_version"] = getattr(praisonai, "__version__", "unknown")
# Check if installed via pip (using modern importlib.metadata)
try:
try:
from importlib.metadata import distribution, PackageNotFoundError
except ImportError:
# Python < 3.8 fallback
from importlib_metadata import distribution, PackageNotFoundError
try:
dist = distribution("praisonai")
metadata["pip_version"] = dist.version
metadata["pip_location"] = str(dist.locate_file(""))
metadata["pip_installed"] = True
except PackageNotFoundError:
metadata["pip_installed"] = False
issues.append("Package not found in pip registry (editable install?)")
except ImportError:
metadata["importlib_metadata_available"] = False
# Check for editable install markers
if praisonai.__file__:
package_dir = os.path.dirname(praisonai.__file__)
# Look for .egg-link or similar editable install markers
site_packages = []
try:
import site
site_packages.extend(site.getsitepackages())
if hasattr(site, 'getusersitepackages'):
site_packages.append(site.getusersitepackages())
except Exception as e:
metadata["site_packages_probe_error"] = str(e)
is_editable = False
for sp in site_packages:
if sp and os.path.exists(sp):
# Check for both case variations of egg-link files
for egg_link_name in ("praisonai.egg-link", "PraisonAI.egg-link"):
egg_link = os.path.join(sp, egg_link_name)
if os.path.exists(egg_link):
is_editable = True
metadata["editable_install"] = True
metadata["egg_link_path"] = egg_link
break
if is_editable:
break
# Check for modern editable install markers (PEP 660)
try:
for file in os.listdir(sp):
if file.startswith("__editable__") and "praisonai" in file.lower() and file.endswith(".pth"):
is_editable = True
metadata["editable_install"] = True
metadata["editable_pth_path"] = os.path.join(sp, file)
break
except OSError:
pass
if is_editable:
break
if not is_editable:
metadata["editable_install"] = False
except ImportError as e:
issues.append(f"Cannot import praisonai: {e}")
metadata["import_error"] = str(e)
if not issues:
return CheckResult(
id="packaging_metadata",
title="Packaging Metadata",
category=CheckCategory.PACKAGING,
status=CheckStatus.PASS,
message="Package metadata is accessible",
metadata=metadata,
)
else:
return CheckResult(
id="packaging_metadata",
title="Packaging Metadata",
category=CheckCategory.PACKAGING,
status=CheckStatus.WARN,
message=f"Package metadata issues: {'; '.join(issues)}",
metadata=metadata,
)
@@ -40,6 +40,7 @@ class DoctorHandler(CommandHandler):
"permissions",
"network",
"performance",
"packaging",
"ci",
"selftest",
]
@@ -290,6 +291,7 @@ class DoctorHandler(CommandHandler):
"permissions": [CheckCategory.PERMISSIONS],
"network": [CheckCategory.NETWORK],
"performance": [CheckCategory.PERFORMANCE],
"packaging": [CheckCategory.PACKAGING],
"selftest": [CheckCategory.SELFTEST],
"ci": None, # CI runs all checks
}
@@ -32,6 +32,7 @@ class CheckCategory(Enum):
PERMISSIONS = "permissions"
NETWORK = "network"
PERFORMANCE = "performance"
PACKAGING = "packaging"
SELFTEST = "selftest"
BOTS = "bots"
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.6.40 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.6.43 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -117,12 +117,8 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
def is_available(self) -> bool:
"""Check if AutoGen v0.4 is available for import."""
try:
from autogen_agentchat.agents import AssistantAgent # noqa: F401
from autogen_ext.models.openai import OpenAIChatCompletionClient # noqa: F401
return True
except ImportError:
return False
from .._framework_availability import is_available
return is_available("autogen_v4")
def run(
self,
@@ -153,10 +149,148 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
# Availability already validated at CLI entry
logger.info("Starting AutoGen v0.4 execution...")
# For now, return a proper error message instead of delegating
# TODO: Implement full AutoGen v0.4 adapter logic
logger.warning("AutoGen v0.4 adapter is not yet fully implemented")
raise NotImplementedError("AutoGen v0.4 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen v0.2 support.")
import asyncio
import os
import re
async def run_autogen_v4_async():
from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_agentchat.conditions import TextMentionTermination, MaxMessageTermination
# Create model client for v0.4
model_config = llm_config[0] if llm_config else {}
model_client = OpenAIChatCompletionClient(
model=model_config.get('model', 'gpt-4o-mini'),
api_key=model_config.get('api_key', os.environ.get("OPENAI_API_KEY")),
base_url=model_config.get('base_url', "https://api.openai.com/v1")
)
agents = []
combined_tasks = []
try:
# Create agents from config
for role, details in config['roles'].items():
# For AutoGen v0.4, ensure agent name is a valid Python identifier
agent_name = self._format_template(details['role'], topic=topic)
agent_name = self._sanitize_agent_name_for_autogen_v4(agent_name)
backstory = self._format_template(details['backstory'], topic=topic)
# Convert tools for v0.4 - simplified tool passing
agent_tools = []
for tool_name in details.get('tools', []):
if tools_dict and tool_name in tools_dict:
tool_instance = tools_dict[tool_name]
# For v0.4, we can pass the tool's run method directly if it's callable
if hasattr(tool_instance, 'run') and callable(tool_instance.run):
agent_tools.append(tool_instance.run)
# Create v0.4 AssistantAgent
assistant = AssistantAgent(
name=agent_name,
system_message=backstory + ". Must reply with 'TERMINATE' when the task is complete.",
model_client=model_client,
tools=agent_tools,
reflect_on_tool_use=True
)
agents.append(assistant)
# Collect all task descriptions for sequential execution
for task_name, task_details in details.get('tasks', {}).items():
description_filled = self._format_template(task_details['description'], topic=topic)
combined_tasks.append(description_filled)
if not agents:
return "No agents created from configuration"
# Create termination conditions
text_termination = TextMentionTermination("TERMINATE")
max_messages_termination = MaxMessageTermination(max_messages=20)
termination_condition = text_termination | max_messages_termination
# Create RoundRobinGroupChat for parallel/sequential execution
group_chat = RoundRobinGroupChat(
agents,
termination_condition=termination_condition,
max_turns=len(agents) * 3 # Allow multiple rounds
)
# Combine all tasks into a single task description
task_description = f"Topic: {topic}\n\nTasks to complete:\n" + "\n".join(
f"{i+1}. {task}" for i, task in enumerate(combined_tasks)
)
# Run the group chat
result = await group_chat.run(task=task_description)
# Extract the final message content
if result.messages:
final_message = result.messages[-1]
if hasattr(final_message, 'content'):
return f"### AutoGen v0.4 Output ###\n{final_message.content}"
else:
return f"### AutoGen v0.4 Output ###\n{str(final_message)}"
else:
return "### AutoGen v0.4 Output ###\nNo messages generated"
except Exception as e:
logger.error(f"Error in AutoGen v0.4 execution: {str(e)}")
return f"### AutoGen v0.4 Error ###\n{str(e)}"
finally:
# Close the model client
await model_client.close()
# Run the async function using safe bridge
try:
from .._async_bridge import run_sync
return run_sync(run_autogen_v4_async())
except ImportError:
# Fallback if _async_bridge is not available
return asyncio.run(run_autogen_v4_async())
except Exception as e:
logger.error(f"Error running AutoGen v0.4: {str(e)}")
return f"### AutoGen v0.4 Error ###\n{str(e)}"
def _sanitize_agent_name_for_autogen_v4(self, name):
"""
Sanitize agent name to be a valid Python identifier for AutoGen v0.4.
Args:
name (str): The original agent name
Returns:
str: A valid Python identifier
"""
import re
import keyword
# Convert to string and replace invalid characters with underscores
sanitized = re.sub(r'[^a-zA-Z0-9_]', '_', str(name))
# Collapse only very excessive underscores (5 or more) to reduce extreme cases
sanitized = re.sub(r'_{5,}', '_', sanitized)
# Remove trailing underscores only if not part of a dunder pattern and only if singular
if sanitized.endswith('_') and not sanitized.endswith('__') and sanitized != '_':
sanitized = sanitized.rstrip('_')
# Ensure it doesn't start with a digit
if sanitized and sanitized[0].isdigit():
sanitized = 'agent_' + sanitized
# If it's empty or just underscores, use a default
if not sanitized or sanitized == '_':
sanitized = 'agent'
# Check if it's a Python keyword and append underscore if so
if keyword.iskeyword(sanitized):
sanitized += '_'
return sanitized
class AG2Adapter(BaseFrameworkAdapter):
@@ -168,13 +302,8 @@ class AG2Adapter(BaseFrameworkAdapter):
def is_available(self) -> bool:
"""Check if AG2 is available for import."""
try:
import importlib.metadata as _importlib_metadata
_importlib_metadata.distribution('ag2')
from autogen import LLMConfig # noqa: F401 — AG2-exclusive class
return True
except Exception:
return False
from .._framework_availability import is_available
return is_available("ag2")
def run(
self,
@@ -205,7 +334,135 @@ class AG2Adapter(BaseFrameworkAdapter):
# Availability already validated at CLI entry
logger.info("Starting AG2 execution...")
# For now, return a proper error message instead of delegating
# TODO: Implement full AG2 adapter logic
logger.warning("AG2 adapter is not yet fully implemented")
raise NotImplementedError("AG2 adapter is not yet fully implemented. Please use 'autogen' framework for AutoGen/AG2 support.")
import re
import os
from autogen import (
AssistantAgent, UserProxyAgent, GroupChat, GroupChatManager, LLMConfig
)
model_config = llm_config[0] if llm_config else {}
# Allow YAML top-level llm block to override config_list values
yaml_llm = config.get("llm", {}) or {}
# Also check first role's llm block as a fallback
first_role_llm = {}
for role_details in config.get("roles", {}).values():
first_role_llm = role_details.get("llm", {}) or {}
break
# Priority: YAML top-level llm > first role llm > config_list > env vars
def _resolve(key, env_var=None, default=None):
return (yaml_llm.get(key) or first_role_llm.get(key)
or model_config.get(key)
or (os.environ.get(env_var) if env_var else None)
or default)
api_type = _resolve("api_type", default="openai").lower()
model_name = _resolve("model", default="gpt-4o-mini")
api_key = _resolve("api_key", env_var="OPENAI_API_KEY")
# Use resolver for consistent env-var precedence as fallback
try:
from praisonai.llm.env import resolve_llm_endpoint
ep = resolve_llm_endpoint()
base_url = (model_config.get("base_url")
or yaml_llm.get("base_url")
or ep.base_url)
except ImportError:
base_url = model_config.get("base_url") or yaml_llm.get("base_url")
# Build LLMConfig — Bedrock needs no api_key
if api_type == "bedrock":
llm_config_entry = {"api_type": "bedrock", "model": model_name}
else:
llm_config_entry = {"model": model_name}
if api_key:
llm_config_entry["api_key"] = api_key
if base_url and base_url not in ("https://api.openai.com/v1", "https://api.openai.com/v1/"):
llm_config_entry["base_url"] = base_url
llm_config = LLMConfig(llm_config_entry)
user_proxy = UserProxyAgent(
name="User",
human_input_mode="NEVER",
is_termination_msg=lambda x: "TERMINATE" in (x.get("content") or ""),
code_execution_config=False,
)
# Create one AssistantAgent per role
ag2_agent_entries = []
for role, details in config["roles"].items():
agent_name = self._format_template(details.get("role", role), topic=topic)
backstory = self._format_template(details.get("backstory", ""), topic=topic)
agent_name_safe = re.sub(r"[^a-zA-Z0-9_\-]", "_", agent_name)
assistant = AssistantAgent(
name=agent_name_safe,
system_message=backstory + "\nWhen the task is done, reply 'TERMINATE'.",
llm_config=llm_config,
)
ag2_agent_entries.append((role, details, assistant))
# Register tools via AG2 decorator pattern
for role, details, assistant in ag2_agent_entries:
for tool_name in details.get("tools", []):
tool = tools_dict.get(tool_name) if tools_dict else None
if tool is None:
continue
func = tool if callable(tool) else getattr(tool, "run", None)
if func is None:
continue
def make_tool_fn(f, name):
def tool_fn(**kwargs):
return f(**kwargs) if callable(f) else str(f)
tool_fn.__name__ = name
return tool_fn
wrapped = make_tool_fn(func, tool_name)
assistant.register_for_llm(description=f"Tool: {tool_name}")(wrapped)
user_proxy.register_for_execution()(wrapped)
all_assistants = [a for _, _, a in ag2_agent_entries]
if not all_assistants:
return "### AG2 Output ###\nNo agents created from configuration."
# Build initial message from all task descriptions
task_lines = []
for role, details, _ in ag2_agent_entries:
for task_name, task_details in details.get("tasks", {}).items():
desc = self._format_template(task_details.get("description", ""), topic=topic)
if desc:
task_lines.append(desc)
initial_message = "\n".join(task_lines) if task_lines else topic
groupchat = GroupChat(
agents=[user_proxy] + all_assistants,
messages=[],
max_round=12,
)
manager = GroupChatManager(groupchat=groupchat, llm_config=llm_config)
try:
chat_result = user_proxy.initiate_chat(manager, message=initial_message)
except Exception as e:
return f"### AG2 Error ###\n{str(e)}"
# Prefer ChatResult.summary if available, otherwise scan messages
result_content = ""
summary = getattr(chat_result, "summary", None)
if summary and isinstance(summary, str) and summary.strip():
result_content = re.sub(r'[\s\.\,]*TERMINATE[\s\.\,]*$', '', summary, flags=re.IGNORECASE).strip().rstrip('.')
if not result_content:
for msg in reversed(groupchat.messages):
if msg.get("name") == "User":
continue
content = (msg.get("content") or "").strip()
if content:
result_content = re.sub(r'[\s\.\,]*TERMINATE[\s\.\,]*$', '', content, flags=re.IGNORECASE).strip().rstrip('.')
if result_content:
break
if not result_content:
result_content = "Task completed."
return f"### AG2 Output ###\n{result_content}"
@@ -20,11 +20,8 @@ class CrewAIAdapter(BaseFrameworkAdapter):
def is_available(self) -> bool:
"""Check if CrewAI is available for import."""
try:
import crewai # noqa: F401
return True
except ImportError:
return False
from .._framework_availability import is_available
return is_available("crewai")
def run(
self,
@@ -53,60 +50,150 @@ class CrewAIAdapter(BaseFrameworkAdapter):
Execution result as string
"""
# Import CrewAI only when needed (availability already validated at CLI entry)
import os
from crewai import Agent, Task, Crew
from crewai.telemetry import Telemetry
from ..inc import PraisonAIModel
from .._framework_availability import is_available
# Suppress crewai.cli.config logger (scoped to when CrewAI is actually used)
logging.getLogger('crewai.cli.config').setLevel(logging.ERROR)
# Use scoped telemetry disabling instead of global patching
with scoped_telemetry_disable(Telemetry):
# For now, use simplified CrewAI execution
agents = {}
tasks = []
# Create agents
for agent_name, agent_details in config.get('roles', {}).items():
# Resolve tools for this agent from tools_dict
agent_tool_list = []
if tools_dict:
agent_tools = agent_details.get('tools', [])
agent_tool_list = [tools_dict[t] for t in agent_tools if t in tools_dict]
tasks_dict = {}
# Create agents from config
for role, details in config['roles'].items():
role_filled = self._format_template(details['role'], topic=topic)
goal_filled = self._format_template(details['goal'], topic=topic)
backstory_filled = self._format_template(details['backstory'], topic=topic)
# Get agent tools
agent_tools = [tools_dict[tool] for tool in details.get('tools', [])
if tools_dict and tool in tools_dict]
# Configure LLM
llm_model = details.get('llm')
if llm_model:
llm = PraisonAIModel(
model=llm_model.get("model") or os.environ.get("MODEL_NAME") or "openai/gpt-4o-mini",
base_url=llm_config[0].get('base_url') if llm_config else None,
api_key=llm_config[0].get('api_key') if llm_config else None
).get_model()
else:
llm = PraisonAIModel(
base_url=llm_config[0].get('base_url') if llm_config else None,
api_key=llm_config[0].get('api_key') if llm_config else None
).get_model()
# Configure function calling LLM
function_calling_llm_model = details.get('function_calling_llm')
if function_calling_llm_model:
function_calling_llm = PraisonAIModel(
model=function_calling_llm_model.get("model") or os.environ.get("MODEL_NAME") or "openai/gpt-4o-mini",
base_url=llm_config[0].get('base_url') if llm_config else None,
api_key=llm_config[0].get('api_key') if llm_config else None
).get_model()
else:
function_calling_llm = PraisonAIModel(
base_url=llm_config[0].get('base_url') if llm_config else None,
api_key=llm_config[0].get('api_key') if llm_config else None
).get_model()
# Create CrewAI agent with full feature set
agent = Agent(
role=agent_details.get('role', agent_name),
goal=self._format_template(agent_details.get('goal', ''), topic=topic),
backstory=self._format_template(agent_details.get('backstory', ''), topic=topic),
tools=agent_tool_list,
verbose=True,
allow_delegation=agent_details.get('allow_delegation', False)
role=role_filled,
goal=goal_filled,
backstory=backstory_filled,
tools=agent_tools,
allow_delegation=details.get('allow_delegation', False),
llm=llm,
function_calling_llm=function_calling_llm,
max_iter=details.get('max_iter') or 15,
max_rpm=details.get('max_rpm') or None,
max_execution_time=details.get('max_execution_time') or None,
verbose=details.get('verbose', True),
cache=details.get('cache', True),
system_template=details.get('system_template') or None,
prompt_template=details.get('prompt_template') or None,
response_template=details.get('response_template') or None,
)
# Set agent callback if provided
if agent_callback:
agent.step_callback = agent_callback
agents[agent_name] = agent
# Create tasks
for agent_name, agent_details in config.get('roles', {}).items():
for task_name, task_details in agent_details.get('tasks', {}).items():
agents[role] = agent
# Create tasks for the agent
for task_name, task_details in details.get('tasks', {}).items():
description_filled = self._format_template(task_details['description'], topic=topic)
expected_output_filled = self._format_template(task_details['expected_output'], topic=topic)
# Resolve task tools from tools_dict
task_tools = []
for tool_name in task_details.get('tools', []):
if isinstance(tool_name, str) and tools_dict and tool_name in tools_dict:
task_tools.append(tools_dict[tool_name])
elif callable(tool_name):
# Already a callable tool object
task_tools.append(tool_name)
task = Task(
description=self._format_template(task_details['description'], topic=topic),
expected_output=self._format_template(task_details['expected_output'], topic=topic),
agent=agents[agent_name]
description=description_filled,
expected_output=expected_output_filled,
agent=agent,
tools=task_tools,
async_execution=task_details.get('async_execution', False),
context=[],
config=task_details.get('config', {}),
output_json=task_details.get('output_json'),
output_pydantic=task_details.get('output_pydantic'),
output_file=task_details.get('output_file', ""),
callback=task_details.get('callback'),
human_input=task_details.get('human_input', False),
create_directory=task_details.get('create_directory', False)
)
# Set task callback if provided
if task_callback:
task.callback = task_callback
tasks.append(task)
# Create and run crew
tasks_dict[task_name] = task
# Set up task contexts
for details in config['roles'].values():
for task_name, task_details in details.get('tasks', {}).items():
task = tasks_dict[task_name]
context_tasks = [tasks_dict[ctx] for ctx in task_details.get('context', [])
if ctx in tasks_dict]
task.context = context_tasks
# Create and run the crew
crew = Crew(
agents=list(agents.values()),
tasks=tasks,
verbose=True
)
logger.info("Starting CrewAI execution...")
result = crew.kickoff()
logger.info("CrewAI execution completed")
logger.debug("Final Crew Configuration:")
logger.debug(f"Agents: {crew.agents}")
logger.debug(f"Tasks: {crew.tasks}")
response = crew.kickoff()
result = f"### Task Output ###\n{response}"
return str(result)
# AgentOps integration if available
if is_available("agentops"):
import agentops
try:
agentops.end_session("Success")
except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller
logger.warning(f"agentops.end_session failed: {e}")
return result
@@ -20,11 +20,8 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
def is_available(self) -> bool:
"""Check if PraisonAI agents is available for import."""
try:
from praisonaiagents import Agent, Task, AgentTeam # noqa: F401
return True
except ImportError:
return False
from .._framework_availability import is_available
return is_available("praisonaiagents")
def run(
self,
@@ -56,10 +53,11 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
# Import PraisonAI components only when needed
from praisonaiagents import Agent as PraisonAgent, Task as PraisonTask, AgentTeam
from .._framework_availability import is_available
import os
logger.info("Starting PraisonAI execution...")
# Basic implementation - create agents and tasks from config
agents = {}
tasks = []
@@ -68,89 +66,150 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
if llm_config and llm_config[0].get('model'):
model_name = llm_config[0]['model']
# Create agents from roles
for role, details in config.get('roles', {}).items():
role_filled = self._format_template(details.get('role', role), topic=topic)
goal_filled = self._format_template(details.get('goal', ''), topic=topic)
backstory_filled = self._format_template(details.get('backstory', ''), topic=topic)
# Resolve tools for this agent from tools_dict
agent_tool_list = []
if tools_dict:
agent_tools = details.get('tools', [])
agent_tool_list = [tools_dict[t] for t in agent_tools if t in tools_dict]
# Create basic agent
agent = PraisonAgent(
name=role_filled,
role=role_filled,
goal=goal_filled,
backstory=backstory_filled,
instructions=details.get('instructions'),
llm=model_name,
allow_delegation=details.get('allow_delegation', False),
tools=agent_tool_list,
)
if agent_callback:
agent.step_callback = agent_callback
# Initialize InteractiveRuntime for ACP/LSP if enabled globally
global_config = config.get('config', {})
acp_enabled = global_config.get('acp', False)
lsp_enabled = global_config.get('lsp', False)
interactive_runtime = None
interactive_loop = None
if acp_enabled or lsp_enabled:
try:
import asyncio
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
from praisonai.cli.features.agent_tools import create_agent_centric_tools
agents[role] = agent
# Create tasks for the agent
agent_tasks = details.get('tasks', {})
if not agent_tasks:
# Auto-generate a task
task_description = details.get('instructions') or backstory_filled
task = PraisonTask(
description=task_description,
expected_output="Complete the assigned task successfully.",
agent=agent,
# Use scoped event loop instead of process-global mutations
runtime_config = RuntimeConfig(
workspace=os.getcwd(),
acp_enabled=acp_enabled,
lsp_enabled=lsp_enabled,
approval_mode=os.environ.get("PRAISONAI_APPROVAL_MODE", "prompt")
)
if task_callback:
task.callback = task_callback
tasks.append(task)
else:
for task_name, task_details in agent_tasks.items():
description_filled = self._format_template(
task_details['description'], topic=topic
)
expected_output_filled = self._format_template(
task_details['expected_output'], topic=topic
)
interactive_runtime = InteractiveRuntime(runtime_config)
logger.info(f"Starting InteractiveRuntime (ACP: {acp_enabled}, LSP: {lsp_enabled})")
# Create a scoped event loop instead of modifying process globals
interactive_loop = asyncio.new_event_loop()
# Start the runtime but keep it alive for agent execution
interactive_loop.run_until_complete(interactive_runtime.start())
centric_tools = create_agent_centric_tools(interactive_runtime)
logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
# Merge with tools_dict
if tools_dict:
tools_dict.update(centric_tools)
else:
tools_dict = centric_tools
except ImportError as e:
logger.warning(f"InteractiveRuntime not available: {e}")
except Exception as e:
logger.error(f"Error setting up InteractiveRuntime: {e}")
try:
# Create agents from roles
for role, details in config.get('roles', {}).items():
role_filled = self._format_template(details.get('role', role), topic=topic)
goal_filled = self._format_template(details.get('goal', ''), topic=topic)
backstory_filled = self._format_template(details.get('backstory', ''), topic=topic)
# Resolve tools for this agent from tools_dict
agent_tool_list = []
if tools_dict:
agent_tools = details.get('tools', [])
agent_tool_list = [tools_dict[t] for t in agent_tools if t in tools_dict]
# Create basic agent
agent = PraisonAgent(
name=role_filled,
role=role_filled,
goal=goal_filled,
backstory=backstory_filled,
instructions=details.get('instructions'),
llm=model_name,
allow_delegation=details.get('allow_delegation', False),
tools=agent_tool_list,
)
if agent_callback:
agent.step_callback = agent_callback
agents[role] = agent
# Create tasks for the agent
agent_tasks = details.get('tasks', {})
if not agent_tasks:
# Auto-generate a task
task_description = details.get('instructions') or backstory_filled
task = PraisonTask(
description=description_filled,
expected_output=expected_output_filled,
description=task_description,
expected_output="Complete the assigned task successfully.",
agent=agent,
)
if task_callback:
task.callback = task_callback
tasks.append(task)
# Create and run the team
memory = config.get('memory', False)
if config.get('process') == 'hierarchical':
team = AgentTeam(
agents=list(agents.values()),
tasks=tasks,
process="hierarchical",
manager_llm=config.get('manager_llm') or model_name,
memory=memory
)
else:
team = AgentTeam(
agents=list(agents.values()),
tasks=tasks,
memory=memory
)
response = team.start()
result = f"### PraisonAI Output ###\n{response}" if response else "### PraisonAI Output ###\nTask completed."
logger.info("PraisonAI execution completed")
return result
else:
for task_name, task_details in agent_tasks.items():
description_filled = self._format_template(
task_details['description'], topic=topic
)
expected_output_filled = self._format_template(
task_details['expected_output'], topic=topic
)
task = PraisonTask(
description=description_filled,
expected_output=expected_output_filled,
agent=agent,
)
if task_callback:
task.callback = task_callback
tasks.append(task)
# Create and run the team
memory = config.get('memory', False)
if config.get('process') == 'hierarchical':
team = AgentTeam(
agents=list(agents.values()),
tasks=tasks,
process="hierarchical",
manager_llm=config.get('manager_llm') or model_name,
memory=memory
)
else:
team = AgentTeam(
agents=list(agents.values()),
tasks=tasks,
memory=memory
)
response = team.start()
result = f"### PraisonAI Output ###\n{response}" if response else "### PraisonAI Output ###\nTask completed."
# AgentOps integration if available
if is_available("agentops"):
import agentops
try:
agentops.end_session("Success")
except Exception as e: # noqa: BLE001 -- agentops errors must not crash the caller
logger.warning(f"agentops.end_session failed: {e}")
logger.info("PraisonAI execution completed")
return result
finally:
# Cleanup InteractiveRuntime if it was started
if interactive_runtime and interactive_loop:
try:
logger.info("Stopping InteractiveRuntime")
interactive_loop.run_until_complete(interactive_runtime.stop())
except Exception as e:
logger.error(f"Error stopping InteractiveRuntime: {e}")
finally:
interactive_loop.close()
@@ -2,8 +2,9 @@
import importlib.util
from praisonai.auto import _load_optional
CREWAI_AVAILABLE = importlib.util.find_spec("crewai") is not None
AUTOGEN_AVAILABLE = importlib.util.find_spec("autogen") is not None
from .._framework_availability import is_available
CREWAI_AVAILABLE = is_available("crewai")
AUTOGEN_AVAILABLE = is_available("autogen")
PRAISONAI_TOOLS_PACKAGE_AVAILABLE = importlib.util.find_spec("praisonai_tools") is not None
@@ -30,4 +31,4 @@ def __getattr__(name):
tools_module = _get_autogen_tools()
if tools_module is not None and hasattr(tools_module, name):
return getattr(tools_module, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,44 @@
"""
Shared Valkey client factory.
Centralises GlideClientSync creation so that ValkeyStateStore,
ValkeyVectorKnowledgeStore, and ValkeyStorageAdapter all use the
same connection logic.
"""
from typing import Optional
try:
from glide_sync import (
GlideClient as GlideClientSync,
GlideClientConfiguration,
NodeAddress,
ServerCredentials,
)
except ImportError:
GlideClientSync = None
GlideClientConfiguration = NodeAddress = ServerCredentials = None
_MISSING_MSG = (
"valkey-glide-sync is required for Valkey support. "
"Install with: pip install 'praisonai[valkey]'"
)
def create_valkey_client(
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
db: int = 0,
):
"""Create and return a GlideClientSync instance."""
if GlideClientSync is None:
raise ImportError(_MISSING_MSG)
addresses = [NodeAddress(host, port)]
creds = ServerCredentials(password=password) if password else None
config = GlideClientConfiguration(
addresses=addresses,
credentials=creds,
database_id=db,
)
return GlideClientSync.create(config)
@@ -8,7 +8,7 @@ Install: pip install clickhouse-connect
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
from .base import KnowledgeStore, KnowledgeDocument, validate_identifier
logger = logging.getLogger(__name__)
@@ -40,6 +40,7 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
"clickhouse-connect is required for ClickHouse support. "
"Install with: pip install clickhouse-connect"
)
validate_identifier(database, "database")
self._client = clickhouse_connect.get_client(
host=host,
@@ -56,6 +57,8 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
logger.info(f"Connected to ClickHouse database: {database}")
def _table_name(self, collection: str) -> str:
# Validate collection name to prevent SQL injection
validate_identifier(collection, "collection")
return f"{self.database}.praison_vec_{collection}"
def create_collection(
@@ -195,7 +198,8 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
"""Get documents by IDs."""
table = self._table_name(collection)
id_list = ", ".join([f"'{i}'" for i in ids])
# Escape single quotes to prevent SQL injection
id_list = ", ".join([f"'{i.replace(chr(39), chr(39)+chr(39))}'" for i in ids])
result = self._client.query(f"""
SELECT id, content, content_hash, created_at, embedding
FROM {table} WHERE id IN ({id_list})
@@ -225,7 +229,8 @@ class ClickHouseKnowledgeStore(KnowledgeStore):
table = self._table_name(collection)
if ids:
id_list = ", ".join([f"'{i}'" for i in ids])
# Escape single quotes to prevent SQL injection
id_list = ", ".join([f"'{i.replace(chr(39), chr(39)+chr(39))}'" for i in ids])
self._client.command(f"ALTER TABLE {table} DELETE WHERE id IN ({id_list})")
return len(ids)
return 0
@@ -8,7 +8,7 @@ Install: pip install surrealdb
import logging
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument
from .base import KnowledgeStore, KnowledgeDocument, validate_identifier
from ..._async_bridge import run_sync
logger = logging.getLogger(__name__)
@@ -33,8 +33,8 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
url: str = "ws://localhost:8000/rpc",
namespace: str = "praisonai",
database: str = "vectors",
username: str = "root",
password: str = "root",
username: str | None = None,
password: str | None = None,
embedding_dim: int = 1536,
):
"""
@@ -44,10 +44,19 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
url: SurrealDB WebSocket URL
namespace: SurrealDB namespace
database: SurrealDB database
username: Authentication username
password: Authentication password
username: Authentication username (required for security)
password: Authentication password (required for security)
embedding_dim: Embedding dimension
Raises:
ValueError: If username or password is None (default credentials not allowed)
"""
if username is None or password is None:
raise ValueError(
"SurrealDB username and password must be provided explicitly. "
"Default credentials are not allowed for security reasons."
)
self.url = url
self.namespace = namespace
self.database = database
@@ -96,12 +105,20 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
) -> None:
"""Create a collection (SurrealDB creates tables on insert)."""
self._init_client()
# Validate collection name to prevent SQL injection
validate_identifier(name, "collection name")
# SurrealDB creates tables automatically
logger.info(f"Collection '{name}' will be created on first insert")
def delete_collection(self, name: str) -> bool:
"""Delete a collection."""
self._init_client()
# Validate collection name to prevent SQL injection
validate_identifier(name, "collection name")
try:
self._run_async(self._client.query(f"REMOVE TABLE {name}"))
return True
@@ -112,6 +129,10 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
def collection_exists(self, name: str) -> bool:
"""Check if collection exists."""
self._init_client()
# Validate collection name to prevent SQL injection
validate_identifier(name, "collection name")
try:
result = self._run_async(self._client.query(f"INFO FOR TABLE {name}"))
return bool(result)
@@ -139,6 +160,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
"""Insert documents."""
self._init_client()
# Validate collection name to prevent SQL injection
validate_identifier(collection, "collection")
ids = []
for doc in documents:
record = {
@@ -167,6 +191,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
"""Upsert documents."""
self._init_client()
# Validate collection name to prevent SQL injection
validate_identifier(collection, "collection")
ids = []
for doc in documents:
record = {
@@ -198,6 +225,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
"""Search for similar documents using vector search."""
self._init_client()
# Validate collection name to prevent SQL injection
validate_identifier(collection, "collection")
# SurrealDB vector search query
embedding_str = str(query_embedding)
query = f"""
@@ -238,6 +268,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
ids: List[str]
) -> List[KnowledgeDocument]:
"""Get documents by IDs."""
# Validate collection name to prevent SQL injection
validate_identifier(collection, "collection")
self._init_client()
documents = []
@@ -268,6 +301,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
"""Delete documents."""
self._init_client()
# Validate collection name to prevent SQL injection
validate_identifier(collection, "collection")
count = 0
if ids:
for doc_id in ids:
@@ -289,6 +325,9 @@ class SurrealDBVectorKnowledgeStore(KnowledgeStore):
"""Count documents."""
self._init_client()
# Validate collection name to prevent SQL injection
validate_identifier(collection, "collection")
try:
result = self._run_async(
self._client.query(f"SELECT count() FROM {collection} GROUP ALL")
@@ -0,0 +1,397 @@
"""
Valkey Vector implementation of KnowledgeStore.
Requires: valkey-glide-sync
Install: pip install 'praisonai[valkey]'
"""
import re
import struct
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument, validate_identifier
from .._valkey_client import create_valkey_client
try:
from glide_sync import ft
from glide_sync import (
TextField, NumericField, VectorField, VectorFieldAttributesHnsw,
VectorAlgorithm, DistanceMetricType, VectorType,
FtCreateOptions, DataType, FtSearchOptions, ReturnField,
)
from glide_sync import Batch
except ImportError:
ft = None
Batch = None
TextField = NumericField = VectorField = VectorFieldAttributesHnsw = None
VectorAlgorithm = DistanceMetricType = VectorType = None
FtCreateOptions = DataType = FtSearchOptions = ReturnField = None
_SEARCH_SPECIAL_RE = re.compile(r'([,.<>{}\[\]"\'\\:;!@#$%^&*()\-+=~?/| \t])')
_FIELD_NAME_RE = re.compile(r'^[a-zA-Z0-9_]+$')
def _escape_search_value(value: str) -> str:
return _SEARCH_SPECIAL_RE.sub(r'\\\1', str(value))
def _decode(v) -> str:
return v.decode() if isinstance(v, bytes) else v
class ValkeyVectorKnowledgeStore(KnowledgeStore):
"""
Valkey-based knowledge store using ValkeySearch FT commands for vector search.
Requires Valkey with ValkeySearch module.
Example:
store = ValkeyVectorKnowledgeStore(
host="localhost",
port=6379,
)
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
prefix: str = "praison:vec:",
):
self.host = host
self.port = port
self.password = password
self.prefix = prefix
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
)
return self._client
def _index_name(self, collection: str) -> str:
return f"{self.prefix}{collection}:idx"
def _doc_key(self, collection: str, doc_id: str) -> str:
return f"{self.prefix}{collection}:{doc_id}"
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Create a new index."""
validate_identifier(name, "collection name")
client = self._get_client()
index_name = self._index_name(name)
try:
dist_enum = {"cosine": DistanceMetricType.COSINE, "euclidean": DistanceMetricType.L2, "dot": DistanceMetricType.IP}.get(distance, DistanceMetricType.COSINE)
schema = [
TextField("content"),
TextField("content_hash"),
NumericField("created_at"),
VectorField("embedding", VectorAlgorithm.HNSW, VectorFieldAttributesHnsw(dimension, dist_enum, VectorType.FLOAT32)),
]
options = FtCreateOptions(DataType.HASH, prefixes=[f"{self.prefix}{name}:"])
ft.create(client, index_name, schema, options)
except Exception as e:
err = str(e)
if "already exists" not in err.lower():
raise RuntimeError(f"Failed to create collection in Valkey: {e}") from e
def delete_collection(self, name: str) -> bool:
"""Delete an index and all documents."""
client = self._get_client()
index_name = self._index_name(name)
try:
ft.dropindex(client, index_name)
except Exception as e:
# Only return False for index not found, re-raise other errors
err_msg = str(e).lower()
if "index not found" in err_msg or "unknown index" in err_msg:
return False
raise RuntimeError(f"Failed to delete collection: {e}") from e
# Manually remove orphaned document hashes left behind (no DD flag in ValkeySearch)
pattern = f"{self.prefix}{name}:*"
cursor: bytes = b"0"
while True:
result = client.scan(cursor, match=pattern, count=100)
cursor = result[0]
keys = result[1] or []
if keys:
client.delete(keys)
if not cursor or cursor in (b"0", "0", 0):
break
return True
def collection_exists(self, name: str) -> bool:
"""Check if an index exists."""
client = self._get_client()
index_name = self._index_name(name)
try:
ft.info(client, index_name)
return True
except Exception as e:
# Only return False for index not found, re-raise other errors
err_msg = str(e).lower()
if "index not found" in err_msg or "unknown index" in err_msg:
return False
raise RuntimeError(f"Failed to check collection existence: {e}") from e
def list_collections(self) -> List[str]:
"""List all collections."""
client = self._get_client()
try:
indexes = ft.list(client)
if not indexes:
return []
result = []
for idx in indexes:
idx_str = _decode(idx)
if idx_str.startswith(self.prefix):
name = idx_str[len(self.prefix):]
if name.endswith(":idx"):
name = name[:-4]
result.append(name)
return result
except Exception as e:
# Only return empty list for connection/auth issues in some contexts
# but generally we should propagate errors
raise RuntimeError(f"Failed to list collections: {e}") from e
def insert(
self,
collection: str,
documents: List[KnowledgeDocument],
) -> List[str]:
"""Insert documents using a non-atomic batch (pipeline)."""
client = self._get_client()
ids = []
batch = Batch(is_atomic=False)
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
key = self._doc_key(collection, doc.id)
embedding_bytes = struct.pack(f"{len(doc.embedding)}f", *doc.embedding)
batch.hset(key, {
"content": doc.content,
"content_hash": doc.content_hash or "",
"created_at": str(doc.created_at),
"metadata": json.dumps(doc.metadata or {}),
"embedding": embedding_bytes,
})
ids.append(doc.id)
try:
client.exec(batch, raise_on_error=True)
except Exception as e:
raise RuntimeError(f"Failed to insert documents into Valkey: {e}") from e
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument],
) -> List[str]:
"""Insert or update documents."""
return self.insert(collection, documents)
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None,
) -> List[KnowledgeDocument]:
"""Search for similar documents.
Args:
collection: Collection name.
query_embedding: Query vector.
limit: Max results.
filters: Optional dict of field-value equality filters applied as
a ValkeySearch pre-filter (e.g. {"content_hash": "abc"}).
score_threshold: Minimum similarity (1 - distance) to include.
NOTE: Currently only accurate for cosine distance. For euclidean/L2
and inner-product distances, the threshold behavior may be incorrect.
"""
client = self._get_client()
index_name = self._index_name(collection)
packed = struct.pack(f"{len(query_embedding)}f", *query_embedding)
# Build pre-filter expression
if filters:
filter_parts = []
for field, value in filters.items():
if not _FIELD_NAME_RE.match(field):
raise ValueError(f"Invalid filter field name: {field!r}")
if not isinstance(value, str):
raise TypeError(
f"Filter value for {field!r} must be a string, got {type(value).__name__}"
)
escaped = _escape_search_value(value)
filter_parts.append(f"@{field}:({escaped})")
pre_filter = " ".join(filter_parts)
query = f"({pre_filter})=>[KNN {limit} @embedding $vec AS score]"
else:
query = f"*=>[KNN {limit} @embedding $vec AS score]"
try:
options = FtSearchOptions(
return_fields=[
ReturnField("content"),
ReturnField("content_hash"),
ReturnField("created_at"),
ReturnField("metadata"),
ReturnField("score"),
],
params={"vec": packed},
)
raw = ft.search(client, index_name, query, options)
except Exception as e:
raise RuntimeError(f"Failed to search Valkey: {e}") from e
# ft.search returns [count, {doc_id: {field: value, ...}, ...}]
if not raw or len(raw) < 2:
return []
documents = []
key_prefix = f"{self.prefix}{collection}:"
for doc_id_raw, fields in raw[1].items():
doc_id_str = _decode(doc_id_raw)
bare_id = doc_id_str.removeprefix(key_prefix)
field_map: Dict[str, str] = {}
for fname, fval in fields.items():
fname = _decode(fname)
fval = _decode(fval)
field_map[fname] = fval
score = float(field_map.get("score", 0))
similarity = 1 - score
if score_threshold is not None and similarity < score_threshold:
continue
documents.append(KnowledgeDocument(
id=bare_id,
content=field_map.get("content", ""),
embedding=None,
metadata=json.loads(field_map.get("metadata", "{}") or "{}"),
content_hash=field_map.get("content_hash", ""),
created_at=float(field_map.get("created_at", 0)),
))
return documents
def get(
self,
collection: str,
ids: List[str],
) -> List[KnowledgeDocument]:
"""Get documents by IDs using a non-atomic batch (pipeline)."""
client = self._get_client()
keys = [self._doc_key(collection, doc_id) for doc_id in ids]
batch = Batch(is_atomic=False)
for key in keys:
batch.hgetall(key)
try:
results = client.exec(batch, raise_on_error=True)
except Exception as e:
raise RuntimeError(f"Failed to get documents from Valkey: {e}") from e
documents = []
for doc_id, data in zip(ids, results):
if data:
embedding = None
emb_key = b"embedding" if b"embedding" in data else "embedding"
if emb_key in data:
raw_bytes = data[emb_key]
count = len(raw_bytes) // 4
embedding = list(struct.unpack(f"{count}f", raw_bytes))
content = _decode(data.get(b"content", data.get("content", b"")))
content_hash = _decode(data.get(b"content_hash", data.get("content_hash", b"")))
created_at_raw = data.get(b"created_at", data.get("created_at", b"0"))
created_at = float(_decode(created_at_raw))
metadata_raw = _decode(data.get(b"metadata", data.get("metadata", b"{}")))
metadata = json.loads(metadata_raw or "{}")
doc = KnowledgeDocument(
id=doc_id,
content=content,
embedding=embedding,
metadata=metadata,
content_hash=content_hash,
created_at=created_at,
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None,
) -> int:
"""Delete documents by IDs. Filter-based deletion is not supported."""
if filters and not ids:
raise NotImplementedError("Filter-based deletion is not supported by the Valkey backend")
if not ids:
return 0
client = self._get_client()
keys = [self._doc_key(collection, doc_id) for doc_id in ids]
try:
deleted = client.delete(keys)
except Exception as e:
raise RuntimeError(f"Failed to delete documents from Valkey: {e}") from e
return deleted
def count(self, collection: str) -> int:
"""Count documents in a collection."""
client = self._get_client()
index_name = self._index_name(collection)
try:
info = ft.info(client, index_name)
# ft.info returns a Mapping; num_docs may be bytes key
for k in (b"num_docs", "num_docs"):
if k in info:
return int(info[k])
return 0
except Exception as e:
# Only return 0 for index not found, re-raise other errors
err_msg = str(e).lower()
if "index not found" in err_msg or "unknown index" in err_msg:
return 0
raise RuntimeError(f"Failed to count documents: {e}") from e
def close(self) -> None:
"""Close the store."""
if self._client:
try:
self._client.close()
finally:
self._client = None
@@ -198,7 +198,16 @@ def _register_builtin_knowledge_stores(registry: StoreRegistry):
def _redis_vector(url=None, **kwargs):
from .knowledge.redis_vector import RedisVectorKnowledgeStore
return RedisVectorKnowledgeStore(url=url, **kwargs)
def _valkey_vector(url=None, **kwargs):
from .knowledge.valkey_vector import ValkeyVectorKnowledgeStore
if url is not None:
raise ValueError(
"Valkey vector store does not accept 'url' parameter. "
"Use host/port/password kwargs instead."
)
return ValkeyVectorKnowledgeStore(**kwargs)
def _cassandra(url=None, **kwargs):
kwargs.pop("url", None) # Cassandra doesn't use url parameter
from .knowledge.cassandra import CassandraKnowledgeStore
@@ -258,9 +267,10 @@ def _register_builtin_knowledge_stores(registry: StoreRegistry):
registry.register("milvus", _milvus)
registry.register("pgvector", _pgvector)
registry.register("redis", _redis_vector)
registry.register("valkey", _valkey_vector)
registry.register("cassandra", _cassandra)
registry.register("clickhouse", _clickhouse)
registry.register("mongodb_vector", _mongodb_vector,
registry.register("mongodb_vector", _mongodb_vector,
aliases=("mongodb_atlas", "mongo_vector"))
# Register missing backends that were supported in old factory
@@ -286,7 +296,16 @@ def _register_builtin_state_stores(registry: StoreRegistry):
def _redis(url=None, **kwargs):
from .state.redis import RedisStateStore
return RedisStateStore(url=url, **kwargs)
def _valkey(url=None, **kwargs):
from .state.valkey import ValkeyStateStore
if url is not None:
raise ValueError(
"Valkey state store does not accept 'url' parameter. "
"Use host/port/password kwargs instead."
)
return ValkeyStateStore(**kwargs)
def _dynamodb(url=None, **kwargs):
kwargs.pop("url", None) # DynamoDB doesn't use url parameter
from .state.dynamodb import DynamoDBStateStore
@@ -322,10 +341,11 @@ def _register_builtin_state_stores(registry: StoreRegistry):
return GCSStateStore(bucket_name=bucket, **kwargs)
registry.register("redis", _redis)
registry.register("valkey", _valkey)
registry.register("dynamodb", _dynamodb)
registry.register("firestore", _firestore)
registry.register("mongodb", _mongodb)
registry.register("async_mongodb", _async_mongodb,
registry.register("async_mongodb", _async_mongodb,
aliases=("motor", "mongodb_async"))
registry.register("upstash", _upstash)
registry.register("memory", _memory)
@@ -0,0 +1,233 @@
"""
Valkey implementation of StateStore.
Requires: valkey-glide-sync
Install: pip install 'praisonai[valkey]'
"""
import json
from typing import Any, Dict, List, Optional
from .base import StateStore
from .._valkey_client import create_valkey_client
try:
from glide_sync import ExpirySet, ExpiryType
except ImportError:
ExpirySet = None
ExpiryType = None
class ValkeyStateStore(StateStore):
"""
Valkey-based state store for fast key-value operations.
Example:
store = ValkeyStateStore(
host="localhost",
port=6379,
)
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
prefix: str = "praison:",
db: int = 0,
):
self.host = host
self.port = port
self.password = password
self.prefix = prefix
self.db = db
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
db=self.db,
)
return self._client
def _key(self, key: str) -> str:
"""Add prefix to key."""
return f"{self.prefix}{key}"
def get(self, key: str) -> Optional[Any]:
"""Get a value by key."""
client = self._get_client()
try:
value = client.get(self._key(key))
except Exception as e:
raise RuntimeError(f"Failed to get key from Valkey: {e}") from e
if value is None:
return None
if isinstance(value, bytes):
value = value.decode()
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
"""Set a value with optional TTL."""
client = self._get_client()
value = json.dumps(value)
try:
if ttl is not None:
if ExpirySet is None or ExpiryType is None:
from .._valkey_client import _MISSING_MSG
raise ImportError(_MISSING_MSG)
client.set(self._key(key), value, expiry=ExpirySet(ExpiryType.SEC, ttl))
else:
client.set(self._key(key), value)
except ImportError:
raise
except Exception as e:
raise RuntimeError(f"Failed to set key in Valkey: {e}") from e
def delete(self, key: str) -> bool:
"""Delete a key."""
client = self._get_client()
try:
return client.delete([self._key(key)]) > 0
except Exception as e:
raise RuntimeError(f"Failed to delete key from Valkey: {e}") from e
def exists(self, key: str) -> bool:
"""Check if a key exists."""
client = self._get_client()
try:
return client.exists([self._key(key)]) > 0
except Exception as e:
raise RuntimeError(f"Failed to check key existence in Valkey: {e}") from e
def keys(self, pattern: str = "*") -> List[str]:
"""List keys matching pattern."""
client = self._get_client()
full_pattern = self._key(pattern)
try:
cursor: bytes = b"0"
all_keys = []
while True:
result = client.scan(cursor, match=full_pattern, count=100)
cursor = result[0]
all_keys.extend(result[1] or [])
if not cursor or cursor in (b"0", "0"):
break
prefix_len = len(self.prefix)
result_keys = []
for k in all_keys:
k_str = k.decode() if isinstance(k, bytes) else k
result_keys.append(k_str[prefix_len:] if k_str.startswith(self.prefix) else k_str)
return result_keys
except Exception as e:
raise RuntimeError(f"Failed to list keys from Valkey: {e}") from e
def ttl(self, key: str) -> Optional[int]:
"""Get remaining TTL in seconds."""
client = self._get_client()
try:
result = client.ttl(self._key(key))
except Exception as e:
raise RuntimeError(f"Failed to get TTL from Valkey: {e}") from e
if result is None or result < 0:
return None
return int(result)
def expire(self, key: str, ttl: int) -> bool:
"""Set TTL on existing key."""
client = self._get_client()
try:
return bool(client.expire(self._key(key), ttl))
except Exception as e:
raise RuntimeError(f"Failed to set expire in Valkey: {e}") from e
def hget(self, key: str, field: str) -> Optional[Any]:
"""Get a field from a hash."""
client = self._get_client()
try:
value = client.hget(self._key(key), field)
except Exception as e:
raise RuntimeError(f"Failed to hget from Valkey: {e}") from e
if value is None:
return None
if isinstance(value, bytes):
value = value.decode()
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def hset(self, key: str, field: str, value: Any) -> None:
"""Set a field in a hash."""
client = self._get_client()
value = json.dumps(value)
try:
client.hset(self._key(key), {field: value})
except Exception as e:
raise RuntimeError(f"Failed to hset in Valkey: {e}") from e
def hgetall(self, key: str) -> Dict[str, Any]:
"""Get all fields from a hash."""
client = self._get_client()
try:
data = client.hgetall(self._key(key))
except Exception as e:
raise RuntimeError(f"Failed to hgetall from Valkey: {e}") from e
result = {}
if not data:
return result
for k, v in data.items():
k_str = k.decode() if isinstance(k, bytes) else k
v_str = v.decode() if isinstance(v, bytes) else v
try:
result[k_str] = json.loads(v_str)
except (json.JSONDecodeError, TypeError):
result[k_str] = v_str
return result
def hdel(self, key: str, *fields: str) -> int:
"""Delete fields from a hash."""
if not fields:
return 0
client = self._get_client()
try:
return client.hdel(self._key(key), list(fields))
except Exception as e:
raise RuntimeError(f"Failed to hdel from Valkey: {e}") from e
def incr(self, key: str, amount: int = 1) -> int:
"""Increment a counter."""
client = self._get_client()
try:
return client.incrby(self._key(key), amount)
except Exception as e:
raise RuntimeError(f"Failed to increment key in Valkey: {e}") from e
def decr(self, key: str, amount: int = 1) -> int:
"""Decrement a counter."""
client = self._get_client()
try:
return client.decrby(self._key(key), amount)
except Exception as e:
raise RuntimeError(f"Failed to decrement key in Valkey: {e}") from e
def close(self) -> None:
"""Close the store."""
if self._client:
try:
self._client.close()
finally:
self._client = None
@@ -0,0 +1,492 @@
"""
Tests for ValkeyVectorKnowledgeStore, ValkeyStateStore, and registry integration.
Tests cover:
- ValkeyVectorKnowledgeStore collection and document operations (mocked glide client)
- ValkeyStateStore key-value operations (mocked glide client)
- KNOWLEDGE_STORES and STATE_STORES registry entries for "valkey"
"""
import json
import struct
from unittest.mock import MagicMock, patch
from praisonai.persistence.knowledge.base import KnowledgeDocument
from praisonai.persistence.registry import KNOWLEDGE_STORES, STATE_STORES
def _make_knowledge_store():
"""Return a ValkeyVectorKnowledgeStore with a pre-injected MagicMock client."""
from praisonai.persistence.knowledge.valkey_vector import ValkeyVectorKnowledgeStore
store = ValkeyVectorKnowledgeStore.__new__(ValkeyVectorKnowledgeStore)
store.host = "localhost"
store.port = 6379
store.password = None
store.prefix = "praison:vec:"
mock_client = MagicMock()
store._client = mock_client
store._get_client = lambda: mock_client
return store, mock_client
def _make_state_store():
"""Return a ValkeyStateStore with a pre-injected MagicMock client."""
from praisonai.persistence.state.valkey import ValkeyStateStore
store = ValkeyStateStore.__new__(ValkeyStateStore)
store.host = "localhost"
store.port = 6379
store.password = None
store.prefix = "praison:"
store.db = 0
mock_client = MagicMock()
store._client = mock_client
store._get_client = lambda: mock_client
return store, mock_client
class TestValkeyVectorKnowledgeStore:
"""Tests for ValkeyVectorKnowledgeStore."""
_FT_MODULE = "praisonai.persistence.knowledge.valkey_vector.ft"
def _mock_ft(self, **method_returns):
"""Return a mock ft module with configured method returns."""
mock_ft = MagicMock()
for name, val in method_returns.items():
if isinstance(val, Exception):
getattr(mock_ft, name).side_effect = val
else:
getattr(mock_ft, name).return_value = val
return mock_ft
def test_create_collection(self):
"""Test create_collection calls ft.create with HNSW schema."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(create=b"OK")
with patch(self._FT_MODULE, mock_ft):
store.create_collection("docs", dimension=4)
mock_ft.create.assert_called_once()
assert mock_ft.create.call_args[0][1] == "praison:vec:docs:idx"
def test_create_collection_already_exists(self):
"""Test create_collection silently ignores 'already exists' error."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(create=Exception("already exists"))
with patch(self._FT_MODULE, mock_ft):
store.create_collection("docs", dimension=4) # should not raise
def test_delete_collection(self):
"""Test delete_collection calls ft.dropindex and returns True on success."""
store, mock_client = _make_knowledge_store()
mock_ft = self._mock_ft(dropindex=b"OK")
mock_client.scan.return_value = [b"0", []]
with patch(self._FT_MODULE, mock_ft):
result = store.delete_collection("docs")
assert result is True
mock_ft.dropindex.assert_called_once()
def test_collection_exists_true(self):
"""Test collection_exists returns True when ft.info succeeds."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(info={b"index_name": b"praison:vec:docs:idx"})
with patch(self._FT_MODULE, mock_ft):
result = store.collection_exists("docs")
assert result is True
def test_collection_exists_false(self):
"""Test collection_exists returns False when ft.info raises an exception."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(info=Exception("Unknown index name"))
with patch(self._FT_MODULE, mock_ft):
result = store.collection_exists("docs")
assert result is False
def test_list_collections(self):
"""Test list_collections returns only names matching the prefix, stripped."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(list=[b"praison:vec:docs:idx", b"other:idx"])
with patch(self._FT_MODULE, mock_ft):
result = store.list_collections()
assert "docs" in result
assert all("other" not in name for name in result)
def test_insert(self):
"""Test insert uses Batch pipeline and calls client.exec."""
store, mock_client = _make_knowledge_store()
mock_client.exec.return_value = [4]
embedding = [0.1, 0.2, 0.3, 0.4]
doc = KnowledgeDocument(id="doc1", content="hello", embedding=embedding)
ids = store.insert("docs", [doc])
mock_client.exec.assert_called_once()
assert ids == ["doc1"]
def test_upsert_delegates_to_insert(self):
"""Test upsert calls insert (same path)."""
store, mock_client = _make_knowledge_store()
mock_client.exec.return_value = [4]
doc = KnowledgeDocument(id="doc1", content="hello", embedding=[0.1, 0.2, 0.3, 0.4])
store.upsert("docs", [doc])
mock_client.exec.assert_called_once()
def test_search(self):
"""Test search calls ft.search and returns a list of KnowledgeDocument."""
store, _ = _make_knowledge_store()
ft_result = [1, {
b"praison:vec:docs:doc1": {
b"content": b"hello",
b"content_hash": b"abc",
b"created_at": b"1234.0",
b"score": b"0.1",
}
}]
mock_ft = self._mock_ft(search=ft_result)
with patch(self._FT_MODULE, mock_ft):
results = store.search("docs", query_embedding=[0.1, 0.2, 0.3, 0.4], limit=5)
assert isinstance(results, list)
assert len(results) == 1
assert isinstance(results[0], KnowledgeDocument)
assert results[0].id == "doc1"
mock_ft.search.assert_called_once()
def test_search_with_filters(self):
"""Test search with filters builds a pre-filter KNN query."""
store, _ = _make_knowledge_store()
ft_result = [1, {
b"praison:vec:docs:doc1": {
b"content": b"hello",
b"content_hash": b"abc",
b"created_at": b"1234.0",
b"score": b"0.1",
}
}]
mock_ft = self._mock_ft(search=ft_result)
with patch(self._FT_MODULE, mock_ft):
results = store.search(
"docs",
query_embedding=[0.1, 0.2, 0.3, 0.4],
limit=5,
filters={"content_hash": "abc"},
)
assert len(results) == 1
query_arg = mock_ft.search.call_args[0][2]
assert "KNN" in query_arg
assert query_arg.startswith("(")
def test_search_score_threshold_excludes_low_similarity(self):
"""Test search excludes documents whose similarity is below score_threshold."""
store, _ = _make_knowledge_store()
ft_result = [2, {
b"praison:vec:docs:doc1": {
b"content": b"close",
b"content_hash": b"",
b"created_at": b"1.0",
b"score": b"0.05", # similarity = 0.95 — above threshold
},
b"praison:vec:docs:doc2": {
b"content": b"far",
b"content_hash": b"",
b"created_at": b"2.0",
b"score": b"0.8", # similarity = 0.2 — below threshold
},
}]
mock_ft = self._mock_ft(search=ft_result)
with patch(self._FT_MODULE, mock_ft):
results = store.search(
"docs",
query_embedding=[0.1, 0.2, 0.3, 0.4],
limit=5,
score_threshold=0.5,
)
assert len(results) == 1
assert results[0].id == "doc1"
def test_search_empty_result(self):
"""Test search returns an empty list when ft.search returns no documents."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(search=[])
with patch(self._FT_MODULE, mock_ft):
results = store.search("docs", query_embedding=[0.1, 0.2, 0.3, 0.4])
assert results == []
def test_get(self):
"""Test get uses Batch pipeline and returns a KnowledgeDocument."""
store, mock_client = _make_knowledge_store()
embedding = [0.1, 0.2, 0.3, 0.4]
embedding_bytes = struct.pack(f"{len(embedding)}f", *embedding)
mock_client.exec.return_value = [{
b"content": b"hello",
b"content_hash": b"abc",
b"created_at": b"1234.0",
b"embedding": embedding_bytes,
}]
results = store.get("docs", ["doc1"])
assert len(results) == 1
assert isinstance(results[0], KnowledgeDocument)
assert results[0].id == "doc1"
mock_client.exec.assert_called_once()
def test_delete(self):
"""Test delete calls client.delete and returns count deleted."""
store, mock_client = _make_knowledge_store()
mock_client.delete.return_value = 2
result = store.delete("docs", ids=["doc1", "doc2"])
assert result == 2
def test_count(self):
"""Test count calls ft.info and returns integer document count."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(info={b"num_docs": 7, b"index_name": b"praison:vec:docs:idx"})
with patch(self._FT_MODULE, mock_ft):
result = store.count("docs")
assert isinstance(result, int)
assert result == 7
def test_close_closes_client(self):
"""Test close() calls client.close() and clears the reference."""
store, mock_client = _make_knowledge_store()
store.close()
mock_client.close.assert_called_once()
assert store._client is None
class TestValkeyStateStore:
"""Tests for ValkeyStateStore."""
def test_get_existing(self):
"""Test get returns a dict when client.get returns JSON bytes."""
store, mock_client = _make_state_store()
data = {"result": "ok"}
mock_client.get.return_value = json.dumps(data).encode("utf-8")
result = store.get("mykey")
mock_client.get.assert_called_once_with("praison:mykey")
assert result == data
def test_get_missing(self):
"""Test get returns None when client.get returns None."""
store, mock_client = _make_state_store()
mock_client.get.return_value = None
result = store.get("missing")
assert result is None
def test_set_no_ttl(self):
"""Test set calls client.set with prefixed key when no TTL is given."""
store, mock_client = _make_state_store()
mock_client.set.return_value = b"OK"
store.set("mykey", {"x": 1})
mock_client.set.assert_called_once()
call_args = mock_client.set.call_args[0]
assert call_args[0] == "praison:mykey"
def test_set_with_ttl(self):
"""Test set with TTL passes SetOptions to client.set (ImportError/RuntimeError tolerated)."""
store, mock_client = _make_state_store()
mock_client.set.return_value = b"OK"
# glide's ExpirySet/ExpiryType are lazy-imported inside set(); without a live
# glide install this raises ImportError — both paths are valid in CI.
try:
store.set("ttlkey", {"x": 1}, ttl=300)
except (ImportError, RuntimeError):
pass
def test_delete_existing(self):
"""Test delete returns True when client.delete returns 1."""
store, mock_client = _make_state_store()
mock_client.delete.return_value = 1
result = store.delete("mykey")
mock_client.delete.assert_called_once_with(["praison:mykey"])
assert result is True
def test_delete_missing(self):
"""Test delete returns False when client.delete returns 0."""
store, mock_client = _make_state_store()
mock_client.delete.return_value = 0
result = store.delete("absent")
assert result is False
def test_exists_true(self):
"""Test exists returns True when client.exists returns 1."""
store, mock_client = _make_state_store()
mock_client.exists.return_value = 1
assert store.exists("mykey") is True
mock_client.exists.assert_called_once_with(["praison:mykey"])
def test_exists_false(self):
"""Test exists returns False when client.exists returns 0."""
store, mock_client = _make_state_store()
mock_client.exists.return_value = 0
assert store.exists("absent") is False
def test_keys(self):
"""Test keys returns stripped key names via client.scan."""
store, mock_client = _make_state_store()
# scan() returns [cursor, [keys]]; cursor b"0" signals end of iteration
mock_client.scan.return_value = [b"0", [b"praison:key1", b"praison:key2"]]
result = store.keys()
mock_client.scan.assert_called_once()
assert result == ["key1", "key2"]
def test_ttl_with_value(self):
"""Test ttl returns seconds remaining when client.ttl returns a positive value."""
store, mock_client = _make_state_store()
mock_client.ttl.return_value = 30
result = store.ttl("mykey")
assert result == 30
def test_ttl_no_ttl(self):
"""Test ttl returns None when client.ttl returns -1 (no expiry)."""
store, mock_client = _make_state_store()
mock_client.ttl.return_value = -1
result = store.ttl("mykey")
assert result is None
def test_expire(self):
"""Test expire calls client.expire and returns its result."""
store, mock_client = _make_state_store()
mock_client.expire.return_value = True
result = store.expire("mykey", 600)
mock_client.expire.assert_called_once_with("praison:mykey", 600)
assert result is True
def test_hget(self):
"""Test hget returns a parsed dict when client.hget returns JSON bytes."""
store, mock_client = _make_state_store()
data = {"field_val": 42}
mock_client.hget.return_value = json.dumps(data).encode("utf-8")
result = store.hget("myhash", "field1")
mock_client.hget.assert_called_once_with("praison:myhash", "field1")
assert result == data
def test_hset(self):
"""Test hset calls client.hset with the prefixed key and a mapping dict."""
store, mock_client = _make_state_store()
mock_client.hset.return_value = 1
store.hset("myhash", "field1", "value1")
mock_client.hset.assert_called_once()
call_args = mock_client.hset.call_args[0]
assert call_args[0] == "praison:myhash"
assert isinstance(call_args[1], dict)
assert "field1" in call_args[1]
def test_hgetall(self):
"""Test hgetall returns a decoded dict when client.hgetall returns bytes."""
store, mock_client = _make_state_store()
mock_client.hgetall.return_value = {
b"field1": b'"value1"',
b"field2": b"42",
}
result = store.hgetall("myhash")
mock_client.hgetall.assert_called_once_with("praison:myhash")
assert "field1" in result
assert "field2" in result
def test_hdel(self):
"""Test hdel calls client.hdel and returns the count of deleted fields."""
store, mock_client = _make_state_store()
mock_client.hdel.return_value = 2
result = store.hdel("myhash", "field1", "field2")
assert result == 2
def test_incr(self):
"""Test incr calls client.incrby and returns the new value."""
store, mock_client = _make_state_store()
mock_client.incrby.return_value = 5
result = store.incr("counter")
assert result == 5
mock_client.incrby.assert_called_once_with("praison:counter", 1)
def test_decr(self):
"""Test decr calls client.decrby and returns the new value."""
store, mock_client = _make_state_store()
mock_client.decrby.return_value = 3
result = store.decr("counter")
assert result == 3
mock_client.decrby.assert_called_once_with("praison:counter", 1)
def test_close_closes_client(self):
"""Test close() calls client.close() and clears the reference."""
store, mock_client = _make_state_store()
store.close()
mock_client.close.assert_called_once()
assert store._client is None
class TestRegistryValkey:
"""Tests for Valkey backend registration in store registries."""
def test_knowledge_store_valkey_registered(self):
"""Test KNOWLEDGE_STORES registry contains 'valkey'."""
assert "valkey" in KNOWLEDGE_STORES.list_registered()
def test_state_store_valkey_registered(self):
"""Test STATE_STORES registry contains 'valkey'."""
assert "valkey" in STATE_STORES.list_registered()
+11 -149
View File
@@ -63,6 +63,7 @@ import cProfile
import pstats
import io
import statistics
from collections import OrderedDict
from collections import deque
from contextvars import ContextVar
from dataclasses import dataclass, field, asdict
@@ -266,7 +267,8 @@ class _ProfilerImpl:
self._memory = deque(maxlen=max_records)
self._enabled: bool = False
self._flow_step: int = 0
self._files_accessed: Dict[str, int] = {}
self._files_accessed: "OrderedDict[str, int]" = OrderedDict()
self._files_accessed_max = max_records # reuse the existing bound
self._line_profile_data: Dict[str, Any] = {}
self._cprofile_stats = deque(maxlen=max_records)
self._lock = threading.Lock()
@@ -312,9 +314,15 @@ class _ProfilerImpl:
line=line
))
# Track file access
# Track file access with LRU eviction
if file:
self._files_accessed[file] = self._files_accessed.get(file, 0) + 1
if file in self._files_accessed:
self._files_accessed[file] += 1
self._files_accessed.move_to_end(file)
else:
self._files_accessed[file] = 1
if len(self._files_accessed) > self._files_accessed_max:
self._files_accessed.popitem(last=False)
def record_import(self, module: str, duration_ms: float, parent: str = "") -> None:
"""Record an import timing."""
@@ -951,152 +959,6 @@ def check_module_available(module_name: str) -> bool:
return importlib.util.find_spec(module_name) is not None
# ============================================================================
# Context-Aware Default Profiler
# ============================================================================
# Context variable for current profiler (enables per-agent isolation)
_current_profiler: ContextVar[Optional[_ProfilerImpl]] = ContextVar("current_profiler", default=None)
# Module-level default for CLI and backward compatibility
_default_profiler = None
_default_lock = threading.Lock()
def get_profiler() -> _ProfilerImpl:
"""Get the current profiler (context-aware or default)."""
# Check context variable first (for per-agent use)
profiler = _current_profiler.get()
if profiler is not None:
return profiler
# Fall back to module-level default
global _default_profiler
if _default_profiler is None:
with _default_lock:
if _default_profiler is None:
_default_profiler = _ProfilerImpl()
return _default_profiler
def set_profiler(profiler: _ProfilerImpl) -> None:
"""Set the current profiler in context."""
_current_profiler.set(profiler)
class ProfilerCompat:
"""
Compatibility wrapper for old classmethod-based Profiler usage.
Delegates all calls to the current profiler instance via get_profiler().
This maintains backward compatibility while enabling per-agent isolation.
"""
@staticmethod
def enable() -> None:
"""Enable profiling."""
get_profiler().enable()
@staticmethod
def disable() -> None:
"""Disable profiling."""
get_profiler().disable()
@staticmethod
def is_enabled() -> bool:
"""Check if profiling is enabled."""
return get_profiler().is_enabled()
@staticmethod
def clear() -> None:
"""Clear all profiling data."""
get_profiler().clear()
@staticmethod
def record_timing(name: str, duration_ms: float, category: str = "function",
file: str = "", line: int = 0) -> None:
"""Record a timing measurement."""
get_profiler().record_timing(name, duration_ms, category, file, line)
@staticmethod
def record_import(module: str, duration_ms: float, parent: str = "") -> None:
"""Record an import timing."""
get_profiler().record_import(module, duration_ms, parent)
@staticmethod
def record_flow(name: str, duration_ms: float, file: str = "", line: int = 0) -> None:
"""Record a flow step."""
get_profiler().record_flow(name, duration_ms, file, line)
@staticmethod
def block(name: str, category: str = "block"):
"""Context manager for profiling a block of code."""
return get_profiler().block(name, category)
@staticmethod
def get_timings(category: Optional[str] = None):
"""Get timing records."""
return get_profiler().get_timings(category)
@staticmethod
def get_imports(min_duration_ms: float = 0):
"""Get import records."""
return get_profiler().get_imports(min_duration_ms)
@staticmethod
def get_flow():
"""Get flow records."""
return get_profiler().get_flow()
@staticmethod
def get_files_accessed():
"""Get files accessed."""
return get_profiler().get_files_accessed()
@staticmethod
def get_summary():
"""Get profiling summary."""
return get_profiler().get_summary()
@staticmethod
def report(output: str = "console") -> str:
"""Generate and output profiling report."""
return get_profiler().report(output)
@staticmethod
def record_flow(name: str, duration_ms: float, file: str = "", line: int = 0) -> None:
"""Record a flow step."""
get_profiler().record_flow(name, duration_ms, file, line)
@staticmethod
def record_api_call(endpoint: str, method: str, duration_ms: float,
status_code: int = 0, request_size: int = 0,
response_size: int = 0) -> None:
"""Record an API/HTTP call timing."""
get_profiler().record_api_call(endpoint, method, duration_ms,
status_code, request_size, response_size)
@staticmethod
def block(name: str, category: str = "block"):
"""Context manager for profiling a block of code."""
return get_profiler().block(name, category)
@staticmethod
def streaming(name: str):
"""Context manager for profiling streaming operations."""
return get_profiler().streaming(name)
@staticmethod
def api_call(endpoint: str, method: str = "GET"):
"""Context manager for profiling API calls."""
return get_profiler().api_call(endpoint, method)
# Create compatibility instance that acts like old singleton
# This allows existing code to work: Profiler.enable(), etc.
# But it actually delegates to the context-aware profiler
ProfilerSingleton = ProfilerCompat()
# Replace the class-based Profiler with the compatibility wrapper
# This ensures all existing code continues to work
Profiler = ProfilerCompat
# ============================================================================
# Exports
+6 -1
View File
@@ -13,6 +13,7 @@ if TYPE_CHECKING:
from .ssh import SSHSandbox
from .modal import ModalSandbox
from .daytona import DaytonaSandbox
from .e2b import E2BSandbox
def __getattr__(name: str):
"""Lazy loading of sandbox components."""
@@ -34,6 +35,9 @@ def __getattr__(name: str):
if name == "DaytonaSandbox":
from .daytona import DaytonaSandbox
return DaytonaSandbox
if name == "E2BSandbox":
from .e2b import E2BSandbox
return E2BSandbox
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
@@ -42,5 +46,6 @@ __all__ = [
"SandlockSandbox",
"SSHSandbox",
"ModalSandbox",
"DaytonaSandbox"
"DaytonaSandbox",
"E2BSandbox",
]
+388
View File
@@ -0,0 +1,388 @@
"""
E2B Sandbox implementation for PraisonAI.
Provides isolated code execution using E2B cloud VMs.
"""
from __future__ import annotations
import asyncio
import logging
import os
import time
import uuid
from typing import Any, Dict, List, Optional, Union
from praisonaiagents.sandbox import (
SandboxConfig,
SandboxResult,
SandboxStatus,
ResourceLimits,
)
logger = logging.getLogger(__name__)
class E2BSandbox:
"""E2B cloud sandbox for safe code execution.
Executes code in isolated E2B cloud VMs with full filesystem access.
Example:
import os
from praisonai.sandbox import E2BSandbox
os.environ["E2B_API_KEY"] = "your-api-key"
sandbox = E2BSandbox()
result = await sandbox.execute("print('Hello, World!')")
print(result.stdout) # Hello, World!
Requires:
- pip install e2b-code-interpreter
- E2B_API_KEY environment variable
"""
def __init__(self, config: Optional[SandboxConfig] = None):
"""Initialize the E2B sandbox.
Args:
config: Optional sandbox configuration
"""
self.config = config or SandboxConfig.e2b()
self._sandbox = None
self._is_running = False
@property
def is_available(self) -> bool:
"""Check if E2B is available."""
try:
import e2b_code_interpreter
api_key = os.getenv("E2B_API_KEY")
return api_key is not None and api_key.strip() != ""
except ImportError:
return False
@property
def sandbox_type(self) -> str:
return "e2b"
async def start(self) -> None:
"""Start/initialize the sandbox environment."""
if self._is_running:
return
if not self.is_available:
raise RuntimeError(
"E2B is not available. Please install e2b-code-interpreter and set E2B_API_KEY"
)
try:
from e2b_code_interpreter import Sandbox
except ImportError:
raise ImportError(
"e2b-code-interpreter not installed. "
"Install with: pip install e2b-code-interpreter"
)
api_key = os.getenv("E2B_API_KEY")
if not api_key:
raise ValueError("E2B_API_KEY environment variable not set")
# Create E2B sandbox (this starts a cloud VM)
self._sandbox = Sandbox(api_key=api_key)
self._is_running = True
logger.info("E2B sandbox initialized")
async def stop(self) -> None:
"""Stop/cleanup the sandbox environment."""
if not self._is_running:
return
if self._sandbox:
try:
await asyncio.get_event_loop().run_in_executor(
None, self._sandbox.kill
)
except Exception as e:
logger.warning(f"Failed to kill E2B sandbox: {e}")
self._sandbox = None
self._is_running = False
logger.info("E2B sandbox stopped")
async def execute(
self,
code: str,
language: str = "python",
limits: Optional[ResourceLimits] = None,
env: Optional[Dict[str, str]] = None,
working_dir: Optional[str] = None,
) -> SandboxResult:
"""Execute code in the sandbox."""
if not self._is_running:
await self.start()
limits = limits or self.config.resource_limits
execution_id = str(uuid.uuid4())
started_at = time.time()
try:
if language == "python":
result = await self._execute_python_code(code, limits, env)
elif language == "bash":
result = await self._execute_bash_command(code, limits, env, working_dir)
else:
# Default to Python for other languages
result = await self._execute_python_code(code, limits, env)
result.execution_id = execution_id
result.started_at = started_at
result.completed_at = time.time()
result.duration_seconds = result.completed_at - started_at
return result
except Exception as e:
return SandboxResult(
execution_id=execution_id,
status=SandboxStatus.FAILED,
error=str(e),
duration_seconds=time.time() - started_at,
started_at=started_at,
completed_at=time.time(),
)
async def _execute_python_code(
self,
code: str,
limits: ResourceLimits,
env: Optional[Dict[str, str]],
) -> SandboxResult:
"""Execute Python code using E2B code interpreter."""
try:
# Run in executor to avoid blocking the event loop
execution = await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._sandbox.run_code(code, timeout=limits.timeout_seconds)
)
# Extract results from E2B execution
stdout_parts = []
stderr_parts = []
for result in execution.results:
if hasattr(result, 'text') and result.text:
stdout_parts.append(str(result.text))
elif hasattr(result, 'logs'):
# Handle different result types from E2B
for log in result.logs:
if hasattr(log, 'line'):
stdout_parts.append(str(log.line))
if execution.error:
stderr_parts.append(execution.error.traceback if execution.error.traceback else str(execution.error))
exit_code = 1
status = SandboxStatus.FAILED
else:
exit_code = 0
status = SandboxStatus.COMPLETED
return SandboxResult(
status=status,
exit_code=exit_code,
stdout="\n".join(stdout_parts) if stdout_parts else "",
stderr="\n".join(stderr_parts) if stderr_parts else "",
)
except Exception as e:
if "timeout" in str(e).lower():
return SandboxResult(
status=SandboxStatus.TIMEOUT,
error=f"Execution timed out after {limits.timeout_seconds}s",
)
else:
return SandboxResult(
status=SandboxStatus.FAILED,
error=str(e),
)
async def _execute_bash_command(
self,
command: str,
limits: ResourceLimits,
env: Optional[Dict[str, str]],
working_dir: Optional[str],
) -> SandboxResult:
"""Execute bash command using E2B terminal."""
try:
# Set environment variables
if env:
import shlex
for key, value in env.items():
await asyncio.get_event_loop().run_in_executor(
None,
lambda k=key, v=value: self._sandbox.commands.run(f"export {shlex.quote(k)}={shlex.quote(v)}", timeout=5)
)
# Change directory if needed
if working_dir:
import shlex
await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._sandbox.commands.run(f"cd {shlex.quote(working_dir)}", timeout=5)
)
# Execute the command
result = await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._sandbox.commands.run(command, timeout=limits.timeout_seconds)
)
return SandboxResult(
status=SandboxStatus.COMPLETED if result.exit_code == 0 else SandboxStatus.FAILED,
exit_code=result.exit_code,
stdout=result.stdout or "",
stderr=result.stderr or "",
)
except Exception as e:
if "timeout" in str(e).lower():
return SandboxResult(
status=SandboxStatus.TIMEOUT,
error=f"Command timed out after {limits.timeout_seconds}s",
)
else:
return SandboxResult(
status=SandboxStatus.FAILED,
error=str(e),
)
async def execute_file(
self,
file_path: str,
args: Optional[List[str]] = None,
limits: Optional[ResourceLimits] = None,
env: Optional[Dict[str, str]] = None,
) -> SandboxResult:
"""Execute a file in the sandbox."""
try:
# Read file content and execute
with open(file_path, "r") as f:
code = f.read()
if file_path.endswith(".py"):
return await self.execute(code, language="python", limits=limits, env=env)
elif file_path.endswith((".sh", ".bash")):
return await self.execute(code, language="bash", limits=limits, env=env)
else:
# Try to execute as Python
return await self.execute(code, language="python", limits=limits, env=env)
except FileNotFoundError:
return SandboxResult(
status=SandboxStatus.FAILED,
error=f"File not found: {file_path}",
)
except Exception as e:
return SandboxResult(
status=SandboxStatus.FAILED,
error=str(e),
)
async def run_command(
self,
command: Union[str, List[str]],
limits: Optional[ResourceLimits] = None,
env: Optional[Dict[str, str]] = None,
working_dir: Optional[str] = None,
) -> SandboxResult:
"""Run a shell command in the sandbox."""
if not self._is_running:
await self.start()
if isinstance(command, list):
command = " ".join(command)
return await self._execute_bash_command(command, limits or self.config.resource_limits, env, working_dir)
async def write_file(
self,
path: str,
content: Union[str, bytes],
) -> bool:
"""Write a file to the sandbox."""
if not self._is_running:
await self.start()
try:
if isinstance(content, bytes):
content = content.decode("utf-8")
await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._sandbox.files.write(path, content)
)
return True
except Exception as e:
logger.error(f"Failed to write file {path}: {e}")
return False
async def read_file(
self,
path: str,
) -> Optional[Union[str, bytes]]:
"""Read a file from the sandbox."""
if not self._is_running:
await self.start()
try:
content = await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._sandbox.files.read(path)
)
return content
except Exception as e:
logger.warning(f"Failed to read file {path}: {e}")
return None
async def list_files(
self,
path: str = "/",
) -> List[str]:
"""List files in a sandbox directory."""
if not self._is_running:
await self.start()
try:
# Use ls command to list files
result = await self.run_command(f"find {path} -type f")
if result.success:
files = [f.strip() for f in result.stdout.split("\n") if f.strip()]
return files
return []
except Exception:
return []
def get_status(self) -> Dict[str, Any]:
"""Get sandbox status information."""
return {
"available": self.is_available,
"type": self.sandbox_type,
"running": self._is_running,
"api_key_set": bool(os.getenv("E2B_API_KEY")),
}
async def cleanup(self) -> None:
"""Clean up sandbox resources."""
if self._sandbox:
try:
# E2B sandbox cleanup is handled by the platform
# We can optionally clear files or reset state here
pass
except Exception as e:
logger.warning(f"Cleanup error: {e}")
async def reset(self) -> None:
"""Reset sandbox to initial state."""
await self.stop()
await self.start()
+41 -2
View File
@@ -6,6 +6,7 @@ Heavy implementations that follow StorageBackendProtocol for:
- MongoDB (praisonai[mongodb])
- PostgreSQL (praisonai[postgresql])
- DynamoDB (praisonai[dynamodb])
- Valkey (praisonai[valkey])
These implementations are kept in the wrapper to avoid bloating the core SDK.
"""
@@ -13,9 +14,12 @@ These implementations are kept in the wrapper to avoid bloating the core SDK.
# Lazy imports - only import when needed
__all__ = [
"RedisStorageAdapter",
"MongoDBStorageAdapter",
"MongoDBStorageAdapter",
"PostgreSQLStorageAdapter",
"DynamoDBStorageAdapter",
"ValkeyStorageAdapter",
"ValkeySearchBackend",
"ValkeyBackend",
]
def __getattr__(name: str):
@@ -32,5 +36,40 @@ def __getattr__(name: str):
elif name == "DynamoDBStorageAdapter":
from .dynamodb_adapter import DynamoDBStorageAdapter
return DynamoDBStorageAdapter
elif name == "ValkeyStorageAdapter":
from .valkey_adapter import ValkeyStorageAdapter
return ValkeyStorageAdapter
elif name == "ValkeySearchBackend":
from .valkey_adapter import ValkeySearchBackend
return ValkeySearchBackend
elif name == "ValkeyBackend":
cls = _make_valkey_backend_class()
globals()["ValkeyBackend"] = cls
return cls
else:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
def _make_valkey_backend_class():
"""Return ValkeyBackend — ValkeyStorageAdapter pre-configured from env vars."""
import os
from .valkey_adapter import ValkeyStorageAdapter
class ValkeyBackend(ValkeyStorageAdapter):
def __init__(
self,
host: str = None,
port: int = None,
prefix: str = None,
ttl=None,
password: str = None,
):
super().__init__(
host=host if host is not None else os.environ.get("VALKEY_HOST", "localhost"),
port=int(port if port is not None else os.environ.get("VALKEY_PORT", 6379)),
prefix=prefix if prefix is not None else os.environ.get("VALKEY_PREFIX", "praisonai:"),
ttl=ttl,
password=password if password is not None else os.environ.get("VALKEY_PASSWORD"),
)
return ValkeyBackend
@@ -0,0 +1,348 @@
"""
Valkey Storage Adapter for PraisonAI.
Implements StorageBackendProtocol using Valkey for high-speed storage.
This is the wrapper implementation that contains the heavy valkey-glide-sync dependency.
"""
import json
import struct
from typing import Dict, Any, List, Optional
from praisonai.persistence._valkey_client import create_valkey_client
try:
from glide_sync import ExpirySet, ExpiryType
except ImportError:
ExpirySet = None
ExpiryType = None
try:
from glide_sync import ft
from glide_sync import (
VectorField, VectorFieldAttributesHnsw, VectorAlgorithm,
DistanceMetricType, VectorType, FtCreateOptions, DataType,
FtSearchOptions, ReturnField,
)
except ImportError:
ft = None
VectorField = VectorFieldAttributesHnsw = VectorAlgorithm = None
DistanceMetricType = VectorType = FtCreateOptions = DataType = None
FtSearchOptions = ReturnField = None
class ValkeyStorageAdapter:
"""
Valkey-based storage backend adapter.
Uses Valkey for high-speed caching and ephemeral data storage.
Implements StorageBackendProtocol from praisonaiagents.storage.protocols.
Example:
```python
from praisonai.storage import ValkeyStorageAdapter
adapter = ValkeyStorageAdapter(host="localhost", port=6379)
adapter.save("session_123", {"messages": []})
data = adapter.load("session_123")
```
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
prefix: str = "praisonai:",
ttl: Optional[int] = None,
password: Optional[str] = None,
):
"""
Initialize the Valkey storage adapter.
Args:
host: Valkey server host
port: Valkey server port
prefix: Key prefix for all stored data
ttl: Optional TTL in seconds for all keys
password: Optional Valkey password
"""
self.host = host
self.port = port
self.prefix = prefix
self.ttl = ttl
self.password = password
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
)
return self._client
def _make_key(self, key: str) -> str:
"""Create prefixed key."""
return f"{self.prefix}{key}"
def _scan_keys(self, pattern: str) -> List[bytes]:
"""Collect all keys matching pattern via SCAN."""
client = self._get_client()
cursor: bytes = b"0"
all_keys: List[bytes] = []
while True:
result = client.scan(cursor, match=pattern, count=100)
cursor = result[0]
all_keys.extend(result[1] or [])
if not cursor or cursor in (b"0", "0", 0):
break
return all_keys
def save(self, key: str, data: Dict[str, Any]) -> None:
"""Save data with the given key."""
client = self._get_client()
full_key = self._make_key(key)
json_data = json.dumps(data, default=str, ensure_ascii=False).encode('utf-8')
try:
if self.ttl is not None:
if ExpirySet is None or ExpiryType is None:
from praisonai.persistence._valkey_client import _MISSING_MSG
raise ImportError(_MISSING_MSG)
client.set(full_key, json_data, expiry=ExpirySet(ExpiryType.SEC, self.ttl))
else:
client.set(full_key, json_data)
except ImportError:
raise
except Exception as e:
raise RuntimeError(f"Failed to save data to Valkey: {e}") from e
def load(self, key: str) -> Optional[Dict[str, Any]]:
"""Load data by key."""
client = self._get_client()
full_key = self._make_key(key)
try:
value = client.get(full_key)
if value is not None:
try:
if isinstance(value, bytes):
value = value.decode('utf-8')
return json.loads(value)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON data for key '{key}': {e}") from e
return None
except ValueError:
raise
except Exception as e:
raise RuntimeError(f"Failed to load data from Valkey: {e}") from e
def delete(self, key: str) -> bool:
"""Delete data by key."""
client = self._get_client()
full_key = self._make_key(key)
try:
return client.delete([full_key]) > 0
except Exception as e:
raise RuntimeError(f"Failed to delete data from Valkey: {e}") from e
def list_keys(self, prefix: str = "") -> List[str]:
"""List all keys, optionally filtered by prefix."""
pattern = self._make_key(f"{prefix}*")
try:
raw_keys = self._scan_keys(pattern)
prefix_len = len(self.prefix)
return sorted(
(k.decode('utf-8') if isinstance(k, bytes) else k)[prefix_len:]
for k in raw_keys
)
except Exception as e:
raise RuntimeError(f"Failed to list keys from Valkey: {e}") from e
def exists(self, key: str) -> bool:
"""Check if a key exists."""
client = self._get_client()
full_key = self._make_key(key)
try:
return client.exists([full_key]) > 0
except Exception as e:
raise RuntimeError(f"Failed to check key existence in Valkey: {e}") from e
def clear(self) -> int:
"""Clear all data with our prefix. Returns number of items deleted."""
client = self._get_client()
pattern = self._make_key("*")
batch_size = 500
try:
all_keys = self._scan_keys(pattern)
if not all_keys:
return 0
total = 0
for i in range(0, len(all_keys), batch_size):
total += client.delete(all_keys[i:i + batch_size])
return total
except Exception as e:
raise RuntimeError(f"Failed to clear data from Valkey: {e}") from e
def set_ttl(self, key: str, ttl: int) -> bool:
"""Set TTL on a specific key."""
client = self._get_client()
full_key = self._make_key(key)
try:
return client.expire(full_key, ttl)
except Exception as e:
raise RuntimeError(f"Failed to set TTL in Valkey: {e}") from e
def ping(self) -> bool:
"""Test connection to Valkey."""
try:
client = self._get_client()
result = client.ping()
return result is not None
except Exception:
return False
def close(self) -> None:
"""Close the Valkey connection."""
if self._client:
try:
self._client.close()
finally:
self._client = None
class ValkeySearchBackend:
"""
Valkey vector search backend using RediSearch / ValkeySearch FT commands.
Example:
```python
from praisonai.storage import ValkeySearchBackend
backend = ValkeySearchBackend(host="localhost", port=6379, vector_dim=1536)
backend.create_index()
backend.add_document("doc1", "hello world", embedding)
results = backend.search(query_embedding, k=5)
```
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
index_name: str = "praisonai_vectors",
password: Optional[str] = None,
vector_dim: int = 1536,
):
"""
Initialize the Valkey search backend.
Args:
host: Valkey server host
port: Valkey server port
index_name: Name of the FT index
password: Optional Valkey password
vector_dim: Dimension of embedding vectors
"""
self.host = host
self.port = port
self.index_name = index_name
self.password = password
self.vector_dim = vector_dim
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
)
return self._client
def create_index(self, vector_dim: int = None) -> None:
"""Create the vector search index (no-op if already exists)."""
client = self._get_client()
dim = vector_dim or self.vector_dim
try:
schema = [VectorField(
"embedding",
VectorAlgorithm.HNSW,
VectorFieldAttributesHnsw(dim, DistanceMetricType.COSINE, VectorType.FLOAT32),
)]
options = FtCreateOptions(DataType.HASH, prefixes=[f"{self.index_name}:"])
ft.create(client, self.index_name, schema, options)
except Exception as e:
err = str(e)
if "already exists" not in err.lower() and "index already exists" not in err.lower():
raise RuntimeError(f"Failed to create Valkey search index: {e}") from e
def add_document(self, doc_id: str, text: str, embedding: List[float]) -> None:
"""Add a document with its embedding to the index."""
if len(embedding) != self.vector_dim:
raise ValueError(
f"Embedding dimension mismatch: expected {self.vector_dim}, got {len(embedding)}"
)
client = self._get_client()
full_id = f"{self.index_name}:{doc_id}"
packed = struct.pack(f"{len(embedding)}f", *embedding)
try:
client.hset(full_id, {"text": text, "embedding": packed})
except Exception as e:
raise RuntimeError(f"Failed to add document to Valkey: {e}") from e
def search(self, query_embedding: List[float], k: int = 5) -> List[Dict[str, Any]]:
"""Search for nearest neighbors by embedding."""
if k <= 0:
raise ValueError("k must be > 0")
if len(query_embedding) != self.vector_dim:
raise ValueError(
f"Query embedding dimension mismatch: expected {self.vector_dim}, got {len(query_embedding)}"
)
client = self._get_client()
packed = struct.pack(f"{len(query_embedding)}f", *query_embedding)
try:
options = FtSearchOptions(
return_fields=[ReturnField("text"), ReturnField("score")],
params={"vec": packed},
)
raw = ft.search(client, self.index_name, f"*=>[KNN {k} @embedding $vec AS score]", options)
except Exception as e:
raise RuntimeError(f"Failed to search Valkey: {e}") from e
# ft.search returns [count, {doc_id: {field: value, ...}, ...}]
if not raw or len(raw) < 2:
return []
results = []
key_prefix = f"{self.index_name}:"
for doc_id, fields in raw[1].items():
doc: Dict[str, Any] = {}
raw_id = doc_id.decode('utf-8') if isinstance(doc_id, bytes) else doc_id
doc["id"] = raw_id.removeprefix(key_prefix)
for fname, fval in fields.items():
fname = fname.decode('utf-8') if isinstance(fname, bytes) else fname
fval = fval.decode('utf-8') if isinstance(fval, bytes) else fval
doc[fname] = fval
results.append(doc)
return results
def close(self) -> None:
"""Close the Valkey connection."""
if self._client:
try:
self._client.close()
finally:
self._client = None
+38 -11
View File
@@ -421,18 +421,45 @@ class ToolResolver:
return {}
# Convenience functions that construct resolver explicitly (no global singleton)
# Process-level lazy singleton for performance (matches profiler.py pattern)
_default_resolver: Optional[ToolResolver] = None
_default_resolver_lock = threading.Lock()
def _get_default_resolver() -> ToolResolver:
"""Process-default ToolResolver (double-checked lazy init).
Returns cached ToolResolver that is anchored to the working directory
at first call. Local tools.py resolution is CWD-dependent and cached
for the lifetime of the process.
For test isolation or multi-project CLIs, create explicit resolver
instances instead of using this cached default.
"""
global _default_resolver
if _default_resolver is None:
with _default_resolver_lock:
if _default_resolver is None:
_default_resolver = ToolResolver()
return _default_resolver
# Convenience functions that use cached default resolver for performance
def resolve_tool(name: str, resolver: Optional[ToolResolver] = None) -> Optional[Callable]:
"""Resolve a tool name to a callable.
Args:
name: Tool name to resolve
resolver: Optional resolver instance. If None, creates a new one.
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
Callable if found, None otherwise
Note:
When resolver=None, uses a process-level cached resolver that is anchored
to the working directory at first call. For test isolation or multi-project
CLIs, pass an explicit resolver instance.
"""
return (resolver or ToolResolver()).resolve(name)
return (resolver or _get_default_resolver()).resolve(name)
def resolve_tools(names: List[str], resolver: Optional[ToolResolver] = None) -> List[Callable]:
@@ -440,24 +467,24 @@ def resolve_tools(names: List[str], resolver: Optional[ToolResolver] = None) ->
Args:
names: List of tool names
resolver: Optional resolver instance. If None, creates a new one.
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
List of resolved callables
"""
return (resolver or ToolResolver()).resolve_many(names)
return (resolver or _get_default_resolver()).resolve_many(names)
def list_available_tools(resolver: Optional[ToolResolver] = None) -> Dict[str, str]:
"""List all available tools with descriptions.
Args:
resolver: Optional resolver instance. If None, creates a new one.
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
Dict mapping tool names to descriptions
"""
return (resolver or ToolResolver()).list_available()
return (resolver or _get_default_resolver()).list_available()
def has_tool(name: str, resolver: Optional[ToolResolver] = None) -> bool:
@@ -465,12 +492,12 @@ def has_tool(name: str, resolver: Optional[ToolResolver] = None) -> bool:
Args:
name: Tool name to check
resolver: Optional resolver instance. If None, creates a new one.
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
True if tool exists, False otherwise
"""
return (resolver or ToolResolver()).has_tool(name)
return (resolver or _get_default_resolver()).has_tool(name)
def validate_yaml_tools(yaml_config: Dict[str, Any], resolver: Optional[ToolResolver] = None) -> List[str]:
@@ -478,9 +505,9 @@ def validate_yaml_tools(yaml_config: Dict[str, Any], resolver: Optional[ToolReso
Args:
yaml_config: Parsed YAML configuration
resolver: Optional resolver instance. If None, creates a new one.
resolver: Optional resolver instance. If None, uses cached default resolver.
Returns:
List of missing tool names
"""
return (resolver or ToolResolver()).validate_yaml_tools(yaml_config)
return (resolver or _get_default_resolver()).validate_yaml_tools(yaml_config)
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.6.40"
__version__ = "4.6.43"
+4 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.6.40",
"praisonaiagents>=1.6.43",
"python-dotenv>=0.19.0",
"litellm>=1.83.14,<2",
"PyYAML>=6.0",
@@ -180,6 +180,9 @@ postgresql = [
dynamodb = [
"boto3>=1.35.0",
]
valkey = [
"valkey-glide-sync>=2.3.1",
]
# Cloud-native serverless databases
neon = [
"psycopg2-binary>=2.9.0",
@@ -20,13 +20,23 @@ if [[ -z "$EXPECTED_VERSION" ]]; then
fi
fi
echo "==> Running official installer one-liner"
if [[ -n "$EXTRAS" ]]; then
PRAISONAI_EXTRAS="$EXTRAS" curl -fsSL "$INSTALL_URL" | bash
else
curl -fsSL "$INSTALL_URL" | bash
echo "==> Running official installer"
INSTALLER_SCRIPT="/tmp/praisonai-install.sh"
curl -fsSL "$INSTALL_URL" -o "$INSTALLER_SCRIPT"
if [[ ! -f "$INSTALLER_SCRIPT" ]]; then
echo "ERROR: Failed to download installer from $INSTALL_URL"
exit 1
fi
if [[ -n "$EXTRAS" ]]; then
PRAISONAI_EXTRAS="$EXTRAS" bash "$INSTALLER_SCRIPT"
else
bash "$INSTALLER_SCRIPT"
fi
rm -f "$INSTALLER_SCRIPT"
echo "==> Verify installation"
# Source the venv if it exists
+10 -1
View File
@@ -173,7 +173,16 @@ install_python_macos() {
if [[ "$DRY_RUN" == "1" ]]; then
log_info "[DRY RUN] Would install Homebrew"
else
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
# Download and verify Homebrew installer
HOMEBREW_INSTALLER="/tmp/homebrew-install.sh"
curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh -o "$HOMEBREW_INSTALLER"
if [[ -f "$HOMEBREW_INSTALLER" ]]; then
/bin/bash "$HOMEBREW_INSTALLER"
rm -f "$HOMEBREW_INSTALLER"
else
log_error "Failed to download Homebrew installer"
exit 1
fi
# Add Homebrew to PATH for this session
if [[ -f "/opt/homebrew/bin/brew" ]]; then
@@ -151,3 +151,24 @@ def test_cassandra_rejects_malicious_collection(monkeypatch):
store.delete_collection("x; DROP TABLE users; --")
with pytest.raises(ValueError):
store.count("x; DROP TABLE users; --")
def test_clickhouse_rejects_malicious_database(monkeypatch):
clickhouse_connect = types.ModuleType("clickhouse_connect")
clickhouse_connect.get_client = lambda **kwargs: object()
monkeypatch.setitem(sys.modules, "clickhouse_connect", clickhouse_connect)
from praisonai.persistence.knowledge.clickhouse import ClickHouseKnowledgeStore
with pytest.raises(ValueError):
ClickHouseKnowledgeStore(database="db; DROP DATABASE prod; --")
def test_surrealdb_rejects_malicious_collection_on_get():
from praisonai.persistence.knowledge.surrealdb_vector import (
SurrealDBVectorKnowledgeStore,
)
store = SurrealDBVectorKnowledgeStore(username="user", password="pass")
with pytest.raises(ValueError):
store.get("x; DROP TABLE users; --", ["doc-1"])
@@ -0,0 +1 @@
# Sandbox tests package
@@ -0,0 +1,327 @@
"""
Unit tests for E2B sandbox implementation.
"""
import pytest
import os
from unittest.mock import Mock, AsyncMock, patch
from praisonai.sandbox.e2b import E2BSandbox
from praisonaiagents.sandbox import SandboxConfig, SandboxResult, SandboxStatus
class TestE2BSandbox:
"""Test E2B sandbox implementation."""
def test_init_default(self):
"""Test initialization with defaults."""
sandbox = E2BSandbox()
assert sandbox.config is not None
assert sandbox.sandbox_type == "e2b"
assert not sandbox._is_running
def test_init_with_config(self):
"""Test initialization with custom config."""
config = SandboxConfig.e2b()
sandbox = E2BSandbox(config)
assert sandbox.config == config
@patch.dict(os.environ, {"E2B_API_KEY": "test-key"})
@patch('importlib.import_module')
def test_is_available_true(self, mock_import):
"""Test is_available returns True when E2B is properly set up."""
sandbox = E2BSandbox()
assert sandbox.is_available is True
def test_is_available_no_api_key(self):
"""Test is_available returns False without API key."""
with patch.dict(os.environ, {}, clear=True):
sandbox = E2BSandbox()
assert sandbox.is_available is False
@patch('importlib.import_module', side_effect=ImportError())
def test_is_available_no_module(self, mock_import):
"""Test is_available returns False without module."""
sandbox = E2BSandbox()
assert sandbox.is_available is False
@patch.dict(os.environ, {"E2B_API_KEY": "test-key"})
@patch('e2b_code_interpreter.Sandbox')
async def test_start_success(self, mock_sandbox_class):
"""Test successful sandbox start."""
mock_instance = Mock()
mock_sandbox_class.return_value = mock_instance
sandbox = E2BSandbox()
with patch.object(sandbox, 'is_available', True):
await sandbox.start()
assert sandbox._is_running
assert sandbox._sandbox == mock_instance
mock_sandbox_class.assert_called_once_with(api_key="test-key")
async def test_start_not_available(self):
"""Test start when not available."""
sandbox = E2BSandbox()
with patch.object(sandbox, 'is_available', False):
with pytest.raises(RuntimeError, match="E2B is not available"):
await sandbox.start()
async def test_start_no_api_key(self):
"""Test start without API key."""
sandbox = E2BSandbox()
with patch.dict(os.environ, {}, clear=True):
with patch.object(sandbox, 'is_available', True):
with pytest.raises(ValueError, match="E2B_API_KEY"):
await sandbox.start()
async def test_start_already_running(self):
"""Test start when already running."""
sandbox = E2BSandbox()
sandbox._is_running = True
await sandbox.start() # Should not raise or do anything
async def test_stop_success(self):
"""Test successful sandbox stop."""
mock_sandbox = Mock()
sandbox = E2BSandbox()
sandbox._sandbox = mock_sandbox
sandbox._is_running = True
with patch('asyncio.get_event_loop') as mock_loop:
mock_executor = AsyncMock()
mock_loop.return_value.run_in_executor.return_value = mock_executor
await sandbox.stop()
assert not sandbox._is_running
assert sandbox._sandbox is None
async def test_stop_with_error(self):
"""Test stop with cleanup error."""
mock_sandbox = Mock()
mock_sandbox.kill.side_effect = Exception("Kill failed")
sandbox = E2BSandbox()
sandbox._sandbox = mock_sandbox
sandbox._is_running = True
with patch('asyncio.get_event_loop') as mock_loop:
mock_loop.return_value.run_in_executor.side_effect = Exception("Kill failed")
await sandbox.stop() # Should not raise
assert not sandbox._is_running
async def test_execute_python_code(self):
"""Test Python code execution."""
sandbox = E2BSandbox()
sandbox._is_running = False
mock_execution = Mock()
mock_result = Mock()
mock_result.text = "Hello, World!"
mock_execution.results = [mock_result]
mock_execution.error = None
with patch.object(sandbox, 'start') as mock_start:
with patch('asyncio.get_event_loop') as mock_loop:
mock_loop.return_value.run_in_executor.return_value = mock_execution
result = await sandbox.execute("print('Hello, World!')", language="python")
mock_start.assert_called_once()
assert result.status == SandboxStatus.COMPLETED
assert result.exit_code == 0
assert "Hello, World!" in result.stdout
async def test_execute_python_code_with_error(self):
"""Test Python code execution with error."""
sandbox = E2BSandbox()
sandbox._is_running = True
mock_execution = Mock()
mock_execution.results = []
mock_error = Mock()
mock_error.traceback = "Traceback: error"
mock_execution.error = mock_error
with patch('asyncio.get_event_loop') as mock_loop:
mock_loop.return_value.run_in_executor.return_value = mock_execution
result = await sandbox.execute("invalid code", language="python")
assert result.status == SandboxStatus.FAILED
assert result.exit_code == 1
assert "Traceback: error" in result.stderr
async def test_execute_bash_command(self):
"""Test bash command execution."""
sandbox = E2BSandbox()
sandbox._is_running = True
sandbox._sandbox = Mock()
mock_command_result = Mock()
mock_command_result.exit_code = 0
mock_command_result.stdout = "command output"
mock_command_result.stderr = ""
with patch('asyncio.get_event_loop') as mock_loop:
mock_loop.return_value.run_in_executor.return_value = mock_command_result
result = await sandbox.execute("echo hello", language="bash")
assert result.status == SandboxStatus.COMPLETED
assert result.exit_code == 0
assert result.stdout == "command output"
async def test_execute_bash_command_with_env_and_workdir(self):
"""Test bash command with environment and working directory."""
sandbox = E2BSandbox()
sandbox._is_running = True
sandbox._sandbox = Mock()
mock_command_result = Mock()
mock_command_result.exit_code = 0
mock_command_result.stdout = "output"
mock_command_result.stderr = ""
with patch('asyncio.get_event_loop') as mock_loop:
mock_loop.return_value.run_in_executor.return_value = mock_command_result
# Test that shlex.quote is used for security
env = {"TEST_VAR": "test value with spaces"}
working_dir = "/path with spaces"
result = await sandbox.execute(
"echo $TEST_VAR",
language="bash",
env=env,
working_dir=working_dir
)
# Verify the command was executed
assert result.status == SandboxStatus.COMPLETED
async def test_execute_timeout(self):
"""Test execution timeout handling."""
sandbox = E2BSandbox()
sandbox._is_running = True
with patch('asyncio.get_event_loop') as mock_loop:
mock_loop.return_value.run_in_executor.side_effect = Exception("timeout occurred")
result = await sandbox.execute("sleep 100", language="bash")
assert result.status == SandboxStatus.FAILED
async def test_write_file(self):
"""Test writing file to sandbox."""
sandbox = E2BSandbox()
sandbox._is_running = True
sandbox._sandbox = Mock()
with patch('asyncio.get_event_loop') as mock_loop:
mock_loop.return_value.run_in_executor.return_value = None
success = await sandbox.write_file("/test.txt", "content")
assert success is True
async def test_write_file_error(self):
"""Test writing file with error."""
sandbox = E2BSandbox()
sandbox._is_running = True
sandbox._sandbox = Mock()
with patch('asyncio.get_event_loop') as mock_loop:
mock_loop.return_value.run_in_executor.side_effect = Exception("Write failed")
success = await sandbox.write_file("/test.txt", "content")
assert success is False
async def test_read_file(self):
"""Test reading file from sandbox."""
sandbox = E2BSandbox()
sandbox._is_running = True
sandbox._sandbox = Mock()
with patch('asyncio.get_event_loop') as mock_loop:
mock_loop.return_value.run_in_executor.return_value = "file content"
content = await sandbox.read_file("/test.txt")
assert content == "file content"
async def test_read_file_error(self):
"""Test reading file with error."""
sandbox = E2BSandbox()
sandbox._is_running = True
sandbox._sandbox = Mock()
with patch('asyncio.get_event_loop') as mock_loop:
mock_loop.return_value.run_in_executor.side_effect = Exception("Read failed")
content = await sandbox.read_file("/test.txt")
assert content is None
async def test_list_files(self):
"""Test listing files in sandbox."""
sandbox = E2BSandbox()
mock_result = SandboxResult(
status=SandboxStatus.COMPLETED,
stdout="/file1.txt\n/file2.py\n",
success=True
)
with patch.object(sandbox, 'run_command', return_value=mock_result):
files = await sandbox.list_files("/")
assert files == ["/file1.txt", "/file2.py"]
async def test_list_files_error(self):
"""Test listing files with error."""
sandbox = E2BSandbox()
with patch.object(sandbox, 'run_command', side_effect=Exception("List failed")):
files = await sandbox.list_files("/")
assert files == []
def test_get_status(self):
"""Test getting sandbox status."""
sandbox = E2BSandbox()
with patch.dict(os.environ, {"E2B_API_KEY": "test-key"}):
with patch.object(sandbox, 'is_available', True):
status = sandbox.get_status()
assert status["available"] is True
assert status["type"] == "e2b"
assert status["running"] is False
assert status["api_key_set"] is True
async def test_cleanup(self):
"""Test sandbox cleanup."""
sandbox = E2BSandbox()
# Should not raise
await sandbox.cleanup()
async def test_reset(self):
"""Test sandbox reset."""
sandbox = E2BSandbox()
with patch.object(sandbox, 'stop') as mock_stop:
with patch.object(sandbox, 'start') as mock_start:
await sandbox.reset()
mock_stop.assert_called_once()
mock_start.assert_called_once()
Whitespace-only changes.
@@ -0,0 +1,249 @@
"""
Tests for ValkeyStorageAdapter and ValkeySearchBackend.
Tests cover:
- ValkeyStorageAdapter CRUD operations (mocked glide client)
- ValkeySearchBackend index and search operations (mocked)
"""
import json
from unittest.mock import MagicMock, patch
def _make_adapter(prefix="praisonai:", ttl=None):
"""Return a ValkeyStorageAdapter with a pre-injected MagicMock client."""
from praisonai.storage.valkey_adapter import ValkeyStorageAdapter
adapter = ValkeyStorageAdapter.__new__(ValkeyStorageAdapter)
adapter.host = "localhost"
adapter.port = 6379
adapter.prefix = prefix
adapter.ttl = ttl
adapter.password = None
mock_client = MagicMock()
adapter._client = mock_client
adapter._get_client = lambda: mock_client
return adapter, mock_client
def _make_search_backend(index_name="praisonai_vectors", vector_dim=4):
"""Return a ValkeySearchBackend with a pre-injected MagicMock client."""
from praisonai.storage.valkey_adapter import ValkeySearchBackend
backend = ValkeySearchBackend.__new__(ValkeySearchBackend)
backend.host = "localhost"
backend.port = 6379
backend.index_name = index_name
backend.vector_dim = vector_dim
backend.password = None
mock_client = MagicMock()
backend._client = mock_client
backend._get_client = lambda: mock_client
return backend, mock_client
class TestValkeyStorageAdapter:
"""Tests for ValkeyStorageAdapter."""
def test_valkey_adapter_save(self):
"""Test save calls client.set with prefixed key and JSON bytes."""
adapter, mock_client = _make_adapter()
data = {"name": "test", "value": 42}
adapter.save("mykey", data)
mock_client.set.assert_called_once()
call_args = mock_client.set.call_args[0]
assert call_args[0] == "praisonai:mykey"
assert json.loads(call_args[1]) == data
def test_valkey_adapter_save_with_ttl(self):
"""Test save with TTL calls client.set with expiry kwarg (ImportError tolerated without glide)."""
adapter, mock_client = _make_adapter(ttl=300)
# ExpirySet/ExpiryType are lazy-imported inside save(); without a live
# glide install this raises RuntimeError — both paths are valid in CI.
try:
adapter.save("ttlkey", {"key": "val"})
mock_client.set.assert_called_once()
_, kwargs = mock_client.set.call_args
assert "expiry" in kwargs
except (ImportError, RuntimeError):
pass
def test_valkey_adapter_load_existing(self):
"""Test load returns a dict when client.get returns JSON bytes."""
adapter, mock_client = _make_adapter()
data = {"result": "ok"}
mock_client.get.return_value = json.dumps(data).encode("utf-8")
loaded = adapter.load("mykey")
mock_client.get.assert_called_once_with("praisonai:mykey")
assert loaded == data
def test_valkey_adapter_load_missing(self):
"""Test load returns None when client.get returns None."""
adapter, mock_client = _make_adapter()
mock_client.get.return_value = None
loaded = adapter.load("missing")
assert loaded is None
def test_valkey_adapter_delete_existing(self):
"""Test delete returns True when client.delete returns 1."""
adapter, mock_client = _make_adapter()
mock_client.delete.return_value = 1
result = adapter.delete("mykey")
mock_client.delete.assert_called_once_with(["praisonai:mykey"])
assert result is True
def test_valkey_adapter_delete_missing(self):
"""Test delete returns False when client.delete returns 0."""
adapter, mock_client = _make_adapter()
mock_client.delete.return_value = 0
result = adapter.delete("absent")
assert result is False
def test_valkey_adapter_list_keys(self):
"""Test list_keys returns stripped, sorted key names via client.scan."""
adapter, mock_client = _make_adapter()
raw_keys = [b"praisonai:beta", b"praisonai:alpha", b"praisonai:gamma"]
# scan() returns [cursor, [keys]]; cursor b"0" signals end of iteration
mock_client.scan.return_value = [b"0", raw_keys]
keys = adapter.list_keys()
mock_client.scan.assert_called_once()
assert keys == ["alpha", "beta", "gamma"]
def test_valkey_adapter_exists_true(self):
"""Test exists returns True when client.exists returns 1."""
adapter, mock_client = _make_adapter()
mock_client.exists.return_value = 1
assert adapter.exists("mykey") is True
mock_client.exists.assert_called_once_with(["praisonai:mykey"])
def test_valkey_adapter_exists_false(self):
"""Test exists returns False when client.exists returns 0."""
adapter, mock_client = _make_adapter()
mock_client.exists.return_value = 0
assert adapter.exists("absent") is False
def test_valkey_adapter_clear(self):
"""Test clear deletes all prefixed keys and returns count."""
adapter, mock_client = _make_adapter()
raw_keys = [b"praisonai:a", b"praisonai:b", b"praisonai:c"]
# scan() returns [cursor, [keys]]; cursor b"0" signals end of iteration
mock_client.scan.return_value = [b"0", raw_keys]
mock_client.delete.return_value = 3
count = adapter.clear()
assert count == 3
mock_client.delete.assert_called_once()
def test_valkey_adapter_set_ttl(self):
"""Test set_ttl calls client.expire with prefixed key and returns result."""
adapter, mock_client = _make_adapter()
mock_client.expire.return_value = True
result = adapter.set_ttl("mykey", 600)
mock_client.expire.assert_called_once_with("praisonai:mykey", 600)
assert result is True
def test_valkey_adapter_import_error(self):
"""Test ImportError with 'valkey' hint when glide package is not available."""
import praisonai.persistence._valkey_client as client_mod
from praisonai.storage.valkey_adapter import ValkeyStorageAdapter
adapter = ValkeyStorageAdapter.__new__(ValkeyStorageAdapter)
adapter.host = "localhost"
adapter.port = 6379
adapter.prefix = "praisonai:"
adapter.ttl = None
adapter.password = None
adapter._client = None
original = client_mod.GlideClientSync
try:
client_mod.GlideClientSync = None
try:
adapter._get_client()
assert False, "Should have raised ImportError"
except ImportError as e:
assert "valkey" in str(e).lower()
finally:
client_mod.GlideClientSync = original
def test_valkey_adapter_operation_error(self):
"""Test RuntimeError raised when a client operation fails."""
adapter, mock_client = _make_adapter()
mock_client.get.side_effect = Exception("connection refused")
try:
adapter.load("anykey")
assert False, "Should have raised RuntimeError"
except RuntimeError as e:
assert "Valkey" in str(e) or "valkey" in str(e).lower()
class TestValkeySearchBackend:
"""Tests for ValkeySearchBackend."""
def test_create_index(self):
"""Test create_index uses ft.create (no-op if already exists)."""
backend, _ = _make_search_backend()
mock_ft = MagicMock()
mock_ft.create.return_value = b"OK"
with patch("praisonai.storage.valkey_adapter.ft", mock_ft):
backend.create_index()
mock_ft.create.assert_called_once()
assert backend.index_name == mock_ft.create.call_args[0][1]
def test_add_document(self):
"""Test add_document calls hset with correct key and fields."""
backend, mock_client = _make_search_backend()
mock_client.hset.return_value = 2
backend.add_document("doc1", "hello world", [0.1, 0.2, 0.3, 0.4])
mock_client.hset.assert_called_once()
call_args = mock_client.hset.call_args[0]
assert call_args[0] == "praisonai_vectors:doc1"
mapping = call_args[1]
assert "text" in mapping
assert mapping["text"] == "hello world"
assert "embedding" in mapping
def test_search(self):
"""Test search uses ft.search and returns a list of dicts."""
backend, _ = _make_search_backend()
ft_result = [1, {b"praisonai_vectors:doc1": {b"text": b"hello world", b"score": b"0.05"}}]
mock_ft = MagicMock()
mock_ft.search.return_value = ft_result
with patch("praisonai.storage.valkey_adapter.ft", mock_ft):
results = backend.search([0.1, 0.2, 0.3, 0.4], k=5)
assert isinstance(results, list)
assert len(results) == 1
assert results[0]["id"] == "doc1"
assert results[0]["text"] == "hello world"
mock_ft.search.assert_called_once()
+355 -158
View File
@@ -17,6 +17,37 @@ overrides = [
{ name = "neonize", marker = "sys_platform == 'never'" },
]
[[package]]
name = "a2a-sdk"
version = "0.3.26"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "google-api-core" },
{ name = "httpx" },
{ name = "httpx-sse" },
{ name = "protobuf" },
{ name = "pydantic" },
]
sdist = { url = "https://files.pythonhosted.org/packages/be/97/a6840e01795b182ce751ca165430d46459927cde9bfab838087cbb24aef7/a2a_sdk-0.3.26.tar.gz", hash = "sha256:44068e2d037afbb07ab899267439e9bc7eaa7ac2af94f1e8b239933c993ad52d", size = 274598, upload-time = "2026-04-09T15:21:13.902Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/dd/d5/51f4ee1bf3b736add42a542d3c8a3fd3fa85f3d36c17972127defc46c26f/a2a_sdk-0.3.26-py3-none-any.whl", hash = "sha256:754e0573f6d33b225c1d8d51f640efa69cbbed7bdfb06ce9c3540ea9f58d4a91", size = 151016, upload-time = "2026-04-09T15:21:12.35Z" },
]
[[package]]
name = "a2ui-agent-sdk"
version = "0.2.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "a2a-sdk" },
{ name = "google-adk" },
{ name = "google-genai" },
{ name = "jsonschema" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8f/a1/66106b413acf22a7531e86d9ce6663e33d8bdead5043f15cbed6e22a427f/a2ui_agent_sdk-0.2.1.tar.gz", hash = "sha256:cc42af7a1cdae5ef7b7c90a619a7497b677038558a4273c7f751e843c41fac04", size = 233418, upload-time = "2026-04-10T00:18:43.387Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5b/21/6a10fa0e20db25825f384a6147a11fb521bb5471e2082dc9f4b471d1e3b4/a2ui_agent_sdk-0.2.1-py3-none-any.whl", hash = "sha256:36157834654c591dbe11c40131a51c9f9b54bdedcb15e789cb33eedbefddbc03", size = 73369, upload-time = "2026-04-10T00:18:41.878Z" },
]
[[package]]
name = "absl-py"
version = "2.4.0"
@@ -61,18 +92,27 @@ wheels = [
[[package]]
name = "agentops"
version = "0.3.16"
version = "0.4.21"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiohttp" },
{ name = "httpx" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-exporter-otlp-proto-http" },
{ name = "opentelemetry-instrumentation" },
{ name = "opentelemetry-sdk" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "ordered-set" },
{ name = "packaging" },
{ name = "psutil" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "termcolor" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/aa/74/8e77e654b37a5e0c977eca4f7e92740c1e24be39c827815e7bd8da429003/agentops-0.3.16.tar.gz", hash = "sha256:564163eb048939d64e848c7e6caf25d6c0aee31200623ef97efe492f090f8939", size = 51308, upload-time = "2024-11-09T18:44:23.037Z" }
sdist = { url = "https://files.pythonhosted.org/packages/0a/c4/023fe976169c57b1edd71f4c08d6dedaf66814f5b25ecf59b3a8540311ab/agentops-0.4.21.tar.gz", hash = "sha256:47759c6dfd6ea58bad2f7764257e4778cb2e34ae180cef642f60f56adced6510", size = 430861, upload-time = "2025-08-29T06:36:55.323Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/b8/76/e1c933480ec9ad093a841321e5c9f7f16a0af59f339ba2c840851b1af01d/agentops-0.3.16-py3-none-any.whl", hash = "sha256:7763e65efe053fa81cea2a2e16f015c7603365280972e0c0709eec32c3c8569e", size = 55351, upload-time = "2024-11-09T18:44:21.626Z" },
{ url = "https://files.pythonhosted.org/packages/d9/63/3e48da56d5121ddcefef8645ad5a3446b0974154111a14bf75ea2b5b3cc3/agentops-0.4.21-py3-none-any.whl", hash = "sha256:93b098ea77bc5f64dcae5031a8292531cb446d9d66e6c7ef2f21a66d4e4fb2f0", size = 309579, upload-time = "2025-08-29T06:36:53.855Z" },
]
[[package]]
@@ -253,14 +293,11 @@ wheels = [
[[package]]
name = "aiosqlite"
version = "0.20.0"
version = "0.22.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/0d/3a/22ff5415bf4d296c1e92b07fd746ad42c96781f13295a074d58e77747848/aiosqlite-0.20.0.tar.gz", hash = "sha256:6d35c8c256637f4672f843c31021464090805bf925385ac39473fb16eaaca3d7", size = 21691, upload-time = "2024-02-20T06:12:53.915Z" }
sdist = { url = "https://files.pythonhosted.org/packages/4e/8a/64761f4005f17809769d23e518d915db74e6310474e733e3593cfc854ef1/aiosqlite-0.22.1.tar.gz", hash = "sha256:043e0bd78d32888c0a9ca90fc788b38796843360c855a7262a532813133a0650", size = 14821, upload-time = "2025-12-23T19:25:43.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/c4/c93eb22025a2de6b83263dfe3d7df2e19138e345bca6f18dba7394120930/aiosqlite-0.20.0-py3-none-any.whl", hash = "sha256:36a1deaca0cac40ebe32aac9977a6e2bbc7f5189f23f4a54d5908986729e5bd6", size = 15564, upload-time = "2024-02-20T06:12:50.657Z" },
{ url = "https://files.pythonhosted.org/packages/00/b7/e3bf5133d697a08128598c8d0abc5e16377b51465a33756de24fa7dee953/aiosqlite-0.22.1-py3-none-any.whl", hash = "sha256:21c002eb13823fad740196c5a2e9d8e62f6243bd9e7e4a1f87fb5e44ecb4fceb", size = 17405, upload-time = "2025-12-23T19:25:42.139Z" },
]
[[package]]
@@ -462,6 +499,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/f6/22/91616fe707a5c5510de2cac9b046a30defe7007ba8a0c04f9c08f27df312/audioop_lts-0.2.2-cp314-cp314t-win_arm64.whl", hash = "sha256:b492c3b040153e68b9fdaff5913305aaaba5bb433d8a7f73d5cf6a64ed3cc1dd", size = 25206, upload-time = "2025-08-05T16:43:16.444Z" },
]
[[package]]
name = "authlib"
version = "1.6.12"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "cryptography" },
]
sdist = { url = "https://files.pythonhosted.org/packages/d3/30/6691fdc63b35f54a5a65e04fa1e59d827f4d4e8f4a39678ba7d3088ce0c8/authlib-1.6.12.tar.gz", hash = "sha256:0656d8482f28fc8221929d5f35b2bde5d13e10555ebc06b4561b0d622e83b1bd", size = 165368, upload-time = "2026-05-04T08:11:31.826Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/cd/51/9b0b5cd4cf683a02db937a6f9bbebcdc9c56558a7bb3763ce7d3512103c3/authlib-1.6.12-py2.py3-none-any.whl", hash = "sha256:e9229ad7fde610b139dd12f5edbe97eab9ee78bfb85691247e767727850b99ab", size = 244473, upload-time = "2026-05-04T08:11:30.354Z" },
]
[[package]]
name = "autogen-agentchat"
version = "0.6.4"
@@ -653,6 +702,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/13/6f/7b45ed2ca300c1ad38ecfc82c1368546d4a90512d9dff589ebbd182a7317/botocore-1.42.66-py3-none-any.whl", hash = "sha256:ac48af1ab527dfa08c4617c387413ca56a7f87780d7bfc1da34ef847a59219a5", size = 14653886, upload-time = "2026-03-11T19:58:04.922Z" },
]
[[package]]
name = "bracex"
version = "2.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/63/9a/fec38644694abfaaeca2798b58e276a8e61de49e2e37494ace423395febc/bracex-2.6.tar.gz", hash = "sha256:98f1347cd77e22ee8d967a30ad4e310b233f7754dbf31ff3fceb76145ba47dc7", size = 26642, upload-time = "2025-06-22T19:12:31.254Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9d/2a/9186535ce58db529927f6cf5990a849aa9e052eea3e2cfefe20b9e1802da/bracex-2.6-py3-none-any.whl", hash = "sha256:0b0049264e7340b3ec782b5cb99beb325f36c3782a32e36e876452fd49a09952", size = 11508, upload-time = "2025-06-22T19:12:29.781Z" },
]
[[package]]
name = "brotli"
version = "1.1.0"
@@ -1306,6 +1364,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/e3/26/57c6fb270950d476074c087527a558ccb6f4436657314bfb6cdf484114c4/docker-7.1.0-py3-none-any.whl", hash = "sha256:c96b93b7f0a746f9e77d325bcfb87422a3d8bd4f03136ae8a85b37f1898d5fc0", size = 147774, upload-time = "2024-05-23T11:13:55.01Z" },
]
[[package]]
name = "dockerfile-parse"
version = "2.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/92/df/929ee0b5d2c8bd8d713c45e71b94ab57c7e11e322130724d54f469b2cd48/dockerfile-parse-2.0.1.tar.gz", hash = "sha256:3184ccdc513221983e503ac00e1aa504a2aa8f84e5de673c46b0b6eee99ec7bc", size = 24556, upload-time = "2023-07-18T13:36:07.897Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/7a/6c/79cd5bc1b880d8c1a9a5550aa8dacd57353fa3bb2457227e1fb47383eb49/dockerfile_parse-2.0.1-py2.py3-none-any.whl", hash = "sha256:bdffd126d2eb26acf1066acb54cb2e336682e1d72b974a40894fac76a4df17f6", size = 14845, upload-time = "2023-07-18T13:36:06.052Z" },
]
[[package]]
name = "docstring-parser"
version = "0.16"
@@ -1324,6 +1391,43 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/4c/a3/ac312faeceffd2d8f86bc6dcb5c401188ba5a01bc88e69bed97578a0dfcd/durationpy-0.9-py3-none-any.whl", hash = "sha256:e65359a7af5cedad07fb77a2dd3f390f8eb0b74cb845589fa6c057086834dd38", size = 3461, upload-time = "2024-10-02T17:58:59.349Z" },
]
[[package]]
name = "e2b"
version = "2.21.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "dockerfile-parse" },
{ name = "h2" },
{ name = "httpcore", version = "1.0.7", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.14'" },
{ name = "httpcore", version = "1.0.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.14'" },
{ name = "httpx" },
{ name = "packaging" },
{ name = "protobuf" },
{ name = "python-dateutil" },
{ name = "rich" },
{ name = "typing-extensions" },
{ name = "wcmatch" },
]
sdist = { url = "https://files.pythonhosted.org/packages/f7/97/0e86ccb9e05c18e6e795e0808f14e2dc9f5c9ffb7be2a5cb77afd6d9f59e/e2b-2.21.1.tar.gz", hash = "sha256:2eff473ca03173cee1ccd9f9ec9e90c2b4705cca418e080e3104753d7ec33490", size = 157458, upload-time = "2026-05-14T17:36:02.318Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6f/d4/8b6a9a120e724dd8f91aededa89348a667a01fffbba28ae1a42cb397b0f0/e2b-2.21.1-py3-none-any.whl", hash = "sha256:9ec4646f3dba4a6da855baa8adeab239aa988e15904611e38bf12aeec2562ac9", size = 297476, upload-time = "2026-05-14T17:36:00.351Z" },
]
[[package]]
name = "e2b-code-interpreter"
version = "2.6.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "attrs" },
{ name = "e2b" },
{ name = "httpx" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/0a/59c95fb95ad3c7caba4828b230188abf3b53acaf09b99d45a10a861972fd/e2b_code_interpreter-2.6.2.tar.gz", hash = "sha256:ffd3b52be945b50b57b0ed110555b3a0d1d49267d2d490fd56566775e897b3d3", size = 10659, upload-time = "2026-04-30T18:28:54.192Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6c/8d/6f7bc9547db0299eb544c4ae736748176945ada55dbe6279ba20739ae76e/e2b_code_interpreter-2.6.2-py3-none-any.whl", hash = "sha256:4cbcecf805895571a49397506e23e6bdcf3ec18f437989fb963a841b26d60619", size = 13716, upload-time = "2026-04-30T18:28:52.698Z" },
]
[[package]]
name = "ecdsa"
version = "0.19.1"
@@ -1619,6 +1723,40 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/de/86/5486b0188d08aa643e127774a99bac51ffa6cf343e3deb0583956dca5b22/fsspec-2024.12.0-py3-none-any.whl", hash = "sha256:b520aed47ad9804237ff878b504267a3b0b441e97508bd6d2d8774e3db85cee2", size = 183862, upload-time = "2024-12-19T19:57:28.258Z" },
]
[[package]]
name = "google-adk"
version = "2.0.0"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "aiosqlite" },
{ name = "authlib" },
{ name = "click" },
{ name = "fastapi" },
{ name = "google-auth", extra = ["pyopenssl"] },
{ name = "google-genai" },
{ name = "graphviz" },
{ name = "httpx" },
{ name = "jsonschema" },
{ name = "opentelemetry-api" },
{ name = "opentelemetry-sdk" },
{ name = "packaging" },
{ name = "pydantic" },
{ name = "python-dotenv" },
{ name = "pyyaml" },
{ name = "requests" },
{ name = "starlette" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "tzlocal" },
{ name = "uvicorn" },
{ name = "watchdog" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a5/08/e9af9ab3b0df422f9c9c07251840f8be876694852d3ac06dbe4e15ce01a7/google_adk-2.0.0.tar.gz", hash = "sha256:2f53c70b5de8409d427f0955bc89f1ba30a8397dec5aefa0ac7c3ecd1b4018d4", size = 3337944, upload-time = "2026-05-19T16:17:10.664Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/79/f5/596e879aacad5214945ede50a4c8e4a0811979ecff83c91e0df11ef13961/google_adk-2.0.0-py3-none-any.whl", hash = "sha256:6d06d8e3b9119ccd7721505356f8c0a5253dcfdc426dd9a72e0fef1cf9ed4703", size = 3846211, upload-time = "2026-05-19T16:17:08.081Z" },
]
[[package]]
name = "google-api-core"
version = "2.30.3"
@@ -1649,6 +1787,9 @@ wheels = [
]
[package.optional-dependencies]
pyopenssl = [
{ name = "pyopenssl" },
]
requests = [
{ name = "requests" },
]
@@ -1718,52 +1859,21 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/9c/97/7d75fe37a7a6ed171a2cf17117177e7aab7e6e0d115858741b41e9dd4254/google_crc32c-1.8.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f639065ea2042d5c034bf258a9f085eaa7af0cd250667c0635a3118e8f92c69c", size = 28800, upload-time = "2025-12-16T00:40:30.322Z" },
]
[[package]]
name = "google-genai"
version = "1.67.0"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version == '3.12.*'",
"python_full_version == '3.11.*'",
"python_full_version < '3.11'",
]
dependencies = [
{ name = "anyio", marker = "python_full_version < '3.13'" },
{ name = "distro", marker = "python_full_version < '3.13'" },
{ name = "google-auth", extra = ["requests"], marker = "python_full_version < '3.13'" },
{ name = "httpx", marker = "python_full_version < '3.13'" },
{ name = "pydantic", marker = "python_full_version < '3.13'" },
{ name = "requests", marker = "python_full_version < '3.13'" },
{ name = "sniffio", marker = "python_full_version < '3.13'" },
{ name = "tenacity", marker = "python_full_version < '3.13'" },
{ name = "typing-extensions", marker = "python_full_version < '3.13'" },
{ name = "websockets", marker = "python_full_version < '3.13'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/a2/07/59a498f81f2c7b0649eacda2ea470b7fd8bd7149f20caba22962081bdd51/google_genai-1.67.0.tar.gz", hash = "sha256:897195a6a9742deb6de240b99227189ada8b2d901d61bdfba836c3092021eab6", size = 506972, upload-time = "2026-03-12T20:39:16.241Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/6e/c2/562aa1f086e53529ffbeb5b43d5d8bc42c1b968102b5e2163fad005ce298/google_genai-1.67.0-py3-none-any.whl", hash = "sha256:58b0484ff2d4335fa53c724b489e9f807fcca8115d9cdbd8fdf341121fbd6d2d", size = 733542, upload-time = "2026-03-12T20:39:14.615Z" },
]
[[package]]
name = "google-genai"
version = "1.73.1"
source = { registry = "https://pypi.org/simple" }
resolution-markers = [
"python_full_version >= '3.14' and sys_platform == 'win32'",
"python_full_version >= '3.14' and sys_platform != 'win32'",
"python_full_version == '3.13.*'",
]
dependencies = [
{ name = "anyio", marker = "python_full_version >= '3.13'" },
{ name = "distro", marker = "python_full_version >= '3.13'" },
{ name = "google-auth", extra = ["requests"], marker = "python_full_version >= '3.13'" },
{ name = "httpx", marker = "python_full_version >= '3.13'" },
{ name = "pydantic", marker = "python_full_version >= '3.13'" },
{ name = "requests", marker = "python_full_version >= '3.13'" },
{ name = "sniffio", marker = "python_full_version >= '3.13'" },
{ name = "tenacity", marker = "python_full_version >= '3.13'" },
{ name = "typing-extensions", marker = "python_full_version >= '3.13'" },
{ name = "websockets", marker = "python_full_version >= '3.13'" },
{ name = "anyio" },
{ name = "distro" },
{ name = "google-auth", extra = ["requests"] },
{ name = "httpx" },
{ name = "pydantic" },
{ name = "requests" },
{ name = "sniffio" },
{ name = "tenacity" },
{ name = "typing-extensions" },
{ name = "websockets" },
]
sdist = { url = "https://files.pythonhosted.org/packages/3d/d8/40f5f107e5a2976bbac52d421f04d14fc221b55a8f05e66be44b2f739fe6/google_genai-1.73.1.tar.gz", hash = "sha256:b637e3a3b9e2eccc46f27136d470165803de84eca52abfed2e7352081a4d5a15", size = 530998, upload-time = "2026-04-14T21:06:19.153Z" }
wheels = [
@@ -1849,6 +1959,15 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/68/72/a4611ed242a7936d38bd5262c6071fff8dda86cab1c06172a1e372783772/gradio_client-2.4.1-py3-none-any.whl", hash = "sha256:e6cef91ddee06e6a10788c5119977a35aee987968e7a87b0c4716809172c33d4", size = 59563, upload-time = "2026-04-10T20:57:11.774Z" },
]
[[package]]
name = "graphviz"
version = "0.21"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/f8/b3/3ac91e9be6b761a4b30d66ff165e54439dcd48b83f4e20d644867215f6ca/graphviz-0.21.tar.gz", hash = "sha256:20743e7183be82aaaa8ad6c93f8893c923bd6658a04c32ee115edb3c8a835f78", size = 200434, upload-time = "2025-06-15T09:35:05.824Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/91/4c/e0ce1ef95d4000ebc1c11801f9b944fa5910ecc15b5e351865763d8657f8/graphviz-0.21-py3-none-any.whl", hash = "sha256:54f33de9f4f911d7e84e4191749cac8cc5653f815b06738c54db9a15ab8b1e42", size = 47300, upload-time = "2025-06-15T09:35:04.433Z" },
]
[[package]]
name = "greenlet"
version = "3.2.2"
@@ -2773,8 +2892,7 @@ dependencies = [
{ name = "async-timeout" },
{ name = "exceptiongroup" },
{ name = "google-cloud-storage" },
{ name = "google-genai", version = "1.67.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.13'" },
{ name = "google-genai", version = "1.73.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.13'" },
{ name = "google-genai" },
{ name = "ml-collections" },
{ name = "more-itertools" },
{ name = "numpy" },
@@ -3970,32 +4088,32 @@ wheels = [
[[package]]
name = "opentelemetry-api"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "importlib-metadata" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/99/c9/4509bfca6bb43220ce7f863c9f791e0d5001c2ec2b5867d48586008b3d96/opentelemetry_api-1.35.0.tar.gz", hash = "sha256:a111b959bcfa5b4d7dffc2fbd6a241aa72dd78dd8e79b5b1662bda896c5d2ffe", size = 64778, upload-time = "2025-07-11T12:23:28.804Z" }
sdist = { url = "https://files.pythonhosted.org/packages/fa/fc/b7564cbef36601aef0d6c9bc01f7badb64be8e862c2e1c3c5c3b43b53e4f/opentelemetry_api-1.41.1.tar.gz", hash = "sha256:0ad1814d73b875f84494387dae86ce0b12c68556331ce6ce8fe789197c949621", size = 71416, upload-time = "2026-04-24T13:15:38.262Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/1d/5a/3f8d078dbf55d18442f6a2ecedf6786d81d7245844b2b20ce2b8ad6f0307/opentelemetry_api-1.35.0-py3-none-any.whl", hash = "sha256:c4ea7e258a244858daf18474625e9cc0149b8ee354f37843415771a40c25ee06", size = 65566, upload-time = "2025-07-11T12:23:07.944Z" },
{ url = "https://files.pythonhosted.org/packages/29/59/3e7118ed140f76b0982ba4321bdaed1997a0473f9720de2d10788a577033/opentelemetry_api-1.41.1-py3-none-any.whl", hash = "sha256:a22df900e75c76dc08440710e51f52f1aa6b451b429298896023e60db5b3139f", size = 69007, upload-time = "2026-04-24T13:15:15.662Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-common"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-proto" },
]
sdist = { url = "https://files.pythonhosted.org/packages/56/d1/887f860529cba7fc3aba2f6a3597fefec010a17bd1b126810724707d9b51/opentelemetry_exporter_otlp_proto_common-1.35.0.tar.gz", hash = "sha256:6f6d8c39f629b9fa5c79ce19a2829dbd93034f8ac51243cdf40ed2196f00d7eb", size = 20299, upload-time = "2025-07-11T12:23:31.046Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ae/fa/f9e3bd3c4d692b3ce9a2880a167d1f79681a1bea11f00d5bf76adc03e6ea/opentelemetry_exporter_otlp_proto_common-1.41.1.tar.gz", hash = "sha256:0e253156ea9c36b0bd3d2440c5c9ba7dd1f3fb64ba7a08fc85fbac536b56e1fb", size = 20409, upload-time = "2026-04-24T13:15:40.924Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/5a/2c/e31dd3c719bff87fa77391eb7f38b1430d22868c52312cba8aad60f280e5/opentelemetry_exporter_otlp_proto_common-1.35.0-py3-none-any.whl", hash = "sha256:863465de697ae81279ede660f3918680b4480ef5f69dcdac04f30722ed7b74cc", size = 18349, upload-time = "2025-07-11T12:23:11.713Z" },
{ url = "https://files.pythonhosted.org/packages/29/48/bce76d3ea772b609757e9bc844e02ab408a6446609bf74fb562062ba6b71/opentelemetry_exporter_otlp_proto_common-1.41.1-py3-none-any.whl", hash = "sha256:10da74dad6a49344b9b7b21b6182e3060373a235fde1528616d5f01f92e66aa9", size = 18366, upload-time = "2026-04-24T13:15:18.917Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-grpc"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -4006,14 +4124,14 @@ dependencies = [
{ name = "opentelemetry-sdk" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/20/de/222e4f2f8cd39250991f84d76b661534aef457cafc6a3eb3fcd513627698/opentelemetry_exporter_otlp_proto_grpc-1.35.0.tar.gz", hash = "sha256:ac4c2c3aa5674642db0df0091ab43ec08bbd91a9be469c8d9b18923eb742b9cc", size = 23794, upload-time = "2025-07-11T12:23:31.662Z" }
sdist = { url = "https://files.pythonhosted.org/packages/1e/9b/e4503060b8695579dbaad187dc8cef4554188de68748c88060599b77489e/opentelemetry_exporter_otlp_proto_grpc-1.41.1.tar.gz", hash = "sha256:b05df8fa1333dc9a3fda36b676b96b5095ab6016d3f0c3296d430d629ba1443b", size = 25755, upload-time = "2026-04-24T13:15:41.93Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f4/a6/3f60a77279e6a3dc21fc076dcb51be159a633b0bba5cba9fb804062a9332/opentelemetry_exporter_otlp_proto_grpc-1.35.0-py3-none-any.whl", hash = "sha256:ee31203eb3e50c7967b8fa71db366cc355099aca4e3726e489b248cdb2fd5a62", size = 18846, upload-time = "2025-07-11T12:23:12.957Z" },
{ url = "https://files.pythonhosted.org/packages/ac/f2/c54f33c92443d087703e57e52e55f22f111373a5c4c4aa349ea60efe512e/opentelemetry_exporter_otlp_proto_grpc-1.41.1-py3-none-any.whl", hash = "sha256:537926dcef951136992479af1d9cd88f25e33d56c530e9f020ed57774dca2f94", size = 20297, upload-time = "2026-04-24T13:15:20.212Z" },
]
[[package]]
name = "opentelemetry-exporter-otlp-proto-http"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "googleapis-common-protos" },
@@ -4024,48 +4142,72 @@ dependencies = [
{ name = "requests" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/88/7f/7bdc06e84266a5b4b0fefd9790b3859804bf7682ce2daabcba2e22fdb3b2/opentelemetry_exporter_otlp_proto_http-1.35.0.tar.gz", hash = "sha256:cf940147f91b450ef5f66e9980d40eb187582eed399fa851f4a7a45bb880de79", size = 15908, upload-time = "2025-07-11T12:23:32.335Z" }
sdist = { url = "https://files.pythonhosted.org/packages/33/5b/9d3c7f70cca10136ba82a81e738dee626c8e7fc61c6887ea9a58bf34c606/opentelemetry_exporter_otlp_proto_http-1.41.1.tar.gz", hash = "sha256:4747a9604c8550ab38c6fd6180e2fcb80de3267060bef2c306bad3cb443302bc", size = 24139, upload-time = "2026-04-24T13:15:42.977Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d4/71/f118cd90dc26797077931dd598bde5e0cc652519db166593f962f8fcd022/opentelemetry_exporter_otlp_proto_http-1.35.0-py3-none-any.whl", hash = "sha256:9a001e3df3c7f160fb31056a28ed7faa2de7df68877ae909516102ae36a54e1d", size = 18589, upload-time = "2025-07-11T12:23:13.906Z" },
{ url = "https://files.pythonhosted.org/packages/ba/4d/ef07ff2fc630849f2080ae0ae73a61f67257905b7ac79066640bfa0c5739/opentelemetry_exporter_otlp_proto_http-1.41.1-py3-none-any.whl", hash = "sha256:1a21e8f49c7a946d935551e90947d6c3eb39236723c6624401da0f33d68edcb4", size = 22673, upload-time = "2026-04-24T13:15:21.313Z" },
]
[[package]]
name = "opentelemetry-instrumentation"
version = "0.62b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "packaging" },
{ name = "wrapt" },
]
sdist = { url = "https://files.pythonhosted.org/packages/52/cb/0523b92c112a6cc70be43724343dc45225d3af134419844d7879a07755d4/opentelemetry_instrumentation-0.62b1.tar.gz", hash = "sha256:90e92a905ba4f84db06ac3aec96701df6c079b2d66e9379f8739f0a1bdcc7f45", size = 34043, upload-time = "2026-04-24T13:22:31.997Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/4d/0f/45adbaea1f81b847cffdcee4f4b5f89297e42facf7fac78c7aaac4c38e75/opentelemetry_instrumentation-0.62b1-py3-none-any.whl", hash = "sha256:976fc6e640f2006599e97429c949e622c108d0c17c2059347d1e6c93c707f257", size = 34163, upload-time = "2026-04-24T13:21:31.722Z" },
]
[[package]]
name = "opentelemetry-proto"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "protobuf" },
]
sdist = { url = "https://files.pythonhosted.org/packages/dc/a2/7366e32d9a2bccbb8614942dbea2cf93c209610385ea966cb050334f8df7/opentelemetry_proto-1.35.0.tar.gz", hash = "sha256:532497341bd3e1c074def7c5b00172601b28bb83b48afc41a4b779f26eb4ee05", size = 46151, upload-time = "2025-07-11T12:23:38.797Z" }
sdist = { url = "https://files.pythonhosted.org/packages/99/e8/633c6d8a9c8840338b105907e55c32d3da1983abab5e52f899f72a82c3d1/opentelemetry_proto-1.41.1.tar.gz", hash = "sha256:4b9d2eb631237ea43b80e16c073af438554e32bc7e9e3f8ca4a9582f900020e5", size = 45670, upload-time = "2026-04-24T13:15:49.768Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/00/a7/3f05de580da7e8a8b8dff041d3d07a20bf3bb62d3bcc027f8fd669a73ff4/opentelemetry_proto-1.35.0-py3-none-any.whl", hash = "sha256:98fffa803164499f562718384e703be8d7dfbe680192279a0429cb150a2f8809", size = 72536, upload-time = "2025-07-11T12:23:23.247Z" },
{ url = "https://files.pythonhosted.org/packages/e4/1e/5cd77035e3e82070e2265a63a760f715aacd3cb16dddc7efee913f297fcc/opentelemetry_proto-1.41.1-py3-none-any.whl", hash = "sha256:0496713b804d127a4147e32849fbaf5683fac8ee98550e8e7679cd706c289720", size = 72076, upload-time = "2026-04-24T13:15:32.542Z" },
]
[[package]]
name = "opentelemetry-sdk"
version = "1.35.0"
version = "1.41.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "opentelemetry-semantic-conventions" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/9a/cf/1eb2ed2ce55e0a9aa95b3007f26f55c7943aeef0a783bb006bdd92b3299e/opentelemetry_sdk-1.35.0.tar.gz", hash = "sha256:2a400b415ab68aaa6f04e8a6a9f6552908fb3090ae2ff78d6ae0c597ac581954", size = 160871, upload-time = "2025-07-11T12:23:39.566Z" }
sdist = { url = "https://files.pythonhosted.org/packages/58/d0/54ee30dab82fb0acda23d144502771ff76ef8728459c83c3e89ef9fb1825/opentelemetry_sdk-1.41.1.tar.gz", hash = "sha256:724b615e1215b5aeacda0abb8a6a8922c9a1853068948bd0bd225a56d0c792e6", size = 230180, upload-time = "2026-04-24T13:15:50.991Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/01/4f/8e32b757ef3b660511b638ab52d1ed9259b666bdeeceba51a082ce3aea95/opentelemetry_sdk-1.35.0-py3-none-any.whl", hash = "sha256:223d9e5f5678518f4842311bb73966e0b6db5d1e0b74e35074c052cd2487f800", size = 119379, upload-time = "2025-07-11T12:23:24.521Z" },
{ url = "https://files.pythonhosted.org/packages/b4/e7/a1420b698aad018e1cf60fdbaaccbe49021fb415e2a0d81c242f4c518f54/opentelemetry_sdk-1.41.1-py3-none-any.whl", hash = "sha256:edee379c126c1bce952b0c812b48fe8ff35b30df0eecf17e98afa4d598b7d85d", size = 180213, upload-time = "2026-04-24T13:15:33.767Z" },
]
[[package]]
name = "opentelemetry-semantic-conventions"
version = "0.56b0"
version = "0.62b1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "opentelemetry-api" },
{ name = "typing-extensions" },
]
sdist = { url = "https://files.pythonhosted.org/packages/32/8e/214fa817f63b9f068519463d8ab46afd5d03b98930c39394a37ae3e741d0/opentelemetry_semantic_conventions-0.56b0.tar.gz", hash = "sha256:c114c2eacc8ff6d3908cb328c811eaf64e6d68623840be9224dc829c4fd6c2ea", size = 124221, upload-time = "2025-07-11T12:23:40.71Z" }
sdist = { url = "https://files.pythonhosted.org/packages/9e/de/911ac9e309052aca1b20b2d5549d3db45d1011e1a610e552c6ccdd1b64f8/opentelemetry_semantic_conventions-0.62b1.tar.gz", hash = "sha256:c5cc6e04a7f8c7cdd30be2ed81499fa4e75bfbd52c9cb70d40af1f9cd3619802", size = 145750, upload-time = "2026-04-24T13:15:52.236Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c7/3f/e80c1b017066a9d999efffe88d1cce66116dcf5cb7f80c41040a83b6e03b/opentelemetry_semantic_conventions-0.56b0-py3-none-any.whl", hash = "sha256:df44492868fd6b482511cc43a942e7194be64e94945f572db24df2e279a001a2", size = 201625, upload-time = "2025-07-11T12:23:25.63Z" },
{ url = "https://files.pythonhosted.org/packages/eb/a6/83dc2ab6fa397ee66fba04fe2e74bdf7be3b3870005359ceb7689103c058/opentelemetry_semantic_conventions-0.62b1-py3-none-any.whl", hash = "sha256:cf506938103d331fbb78eded0d9788095f7fd59016f2bda813c3324e5a74a93c", size = 231620, upload-time = "2026-04-24T13:15:35.454Z" },
]
[[package]]
name = "ordered-set"
version = "4.1.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/4c/ca/bfac8bc689799bcca4157e0e0ced07e70ce125193fc2e166d2e685b7e2fe/ordered-set-4.1.0.tar.gz", hash = "sha256:694a8e44c87657c59292ede72891eb91d34131f6531463aab3009191c77364a8", size = 12826, upload-time = "2022-01-26T14:38:56.6Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/33/55/af02708f230eb77084a299d7b08175cff006dea4f2721074b92cdb0296c0/ordered_set-4.1.0-py3-none-any.whl", hash = "sha256:046e1132c71fcf3330438a539928932caf51ddbc582496833e23de611de14562", size = 7634, upload-time = "2022-01-26T14:38:48.677Z" },
]
[[package]]
@@ -4191,11 +4333,11 @@ wheels = [
[[package]]
name = "packaging"
version = "23.2"
version = "24.2"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/fb/2b/9b9c33ffed44ee921d0967086d653047286054117d584f1b1a7c22ceaf7b/packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5", size = 146714, upload-time = "2023-10-01T13:50:05.279Z" }
sdist = { url = "https://files.pythonhosted.org/packages/d0/63/68dbb6eb2de9cb10ee4c9c14a0148804425e13c4fb20d61cce69f53106da/packaging-24.2.tar.gz", hash = "sha256:c228a6dc5e932d346bc5739379109d49e8853dd8223571c7c5b55260edc0b97f", size = 163950, upload-time = "2024-11-08T09:47:47.202Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/ec/1a/610693ac4ee14fcdf2d9bf3c493370e4f2ef7ae2e19217d7a237ff42367d/packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7", size = 53011, upload-time = "2023-10-01T13:50:03.745Z" },
{ url = "https://files.pythonhosted.org/packages/88/ef/eb23f262cca3c0c4eb7ab1933c3b1f03d021f2c48f54763065b6f0e321be/packaging-24.2-py3-none-any.whl", hash = "sha256:09abb1bccd265c01f4a3aa3f7a7db064b36514d2cba19a2f694fe6150451a759", size = 65451, upload-time = "2024-11-08T09:47:44.722Z" },
]
[[package]]
@@ -4841,7 +4983,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.40"
version = "1.6.43"
source = { directory = "../praisonai-agents" }
dependencies = [
{ name = "aiohttp" },
@@ -4854,11 +4996,14 @@ dependencies = [
[package.optional-dependencies]
all = [
{ name = "a2a-sdk" },
{ name = "a2ui-agent-sdk" },
{ name = "ast-grep-cli" },
{ name = "chonkie" },
{ name = "chromadb" },
{ name = "crawl4ai" },
{ name = "ddgs" },
{ name = "e2b-code-interpreter" },
{ name = "fastapi" },
{ name = "litellm" },
{ name = "markitdown", extra = ["all"] },
@@ -4892,6 +5037,8 @@ search = [
[package.metadata]
requires-dist = [
{ name = "a2a-sdk", marker = "extra == 'a2ui'", specifier = ">=0.3.0,<1.0.0" },
{ name = "a2ui-agent-sdk", marker = "extra == 'a2ui'", specifier = ">=0.2.1" },
{ name = "aiohttp", specifier = ">=3.8.0" },
{ name = "ast-grep-cli", marker = "extra == 'autonomy'", specifier = ">=0.30.0" },
{ name = "chonkie", marker = "extra == 'knowledge'", specifier = ">=1.0.2" },
@@ -4900,6 +5047,8 @@ requires-dist = [
{ name = "chromadb", marker = "extra == 'memory'", specifier = ">=1.0.0" },
{ name = "crawl4ai", marker = "extra == 'crawl'", specifier = ">=0.4.0" },
{ name = "ddgs", marker = "extra == 'search'", specifier = ">=9.0.0" },
{ name = "docker", marker = "extra == 'sandbox-docker'", specifier = ">=7.0.0" },
{ name = "e2b-code-interpreter", marker = "extra == 'sandbox'", specifier = ">=1.0.0" },
{ name = "fastapi", marker = "extra == 'api'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'mcp'", specifier = ">=0.115.0" },
{ name = "fastapi", marker = "extra == 'os'", specifier = ">=0.115.0" },
@@ -4915,6 +5064,7 @@ requires-dist = [
{ name = "playwright", marker = "extra == 'crawl'", specifier = ">=1.47.0" },
{ name = "posthog", specifier = ">=3.0.0" },
{ name = "posthog", marker = "extra == 'telemetry'", specifier = ">=3.0.0" },
{ name = "praisonaiagents", extras = ["a2ui"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["api"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["auth"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["autonomy"], marker = "extra == 'all'" },
@@ -4926,6 +5076,7 @@ requires-dist = [
{ name = "praisonaiagents", extras = ["memory"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["mongodb"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["os"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["sandbox"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["search"], marker = "extra == 'all'" },
{ name = "praisonaiagents", extras = ["telemetry"], marker = "extra == 'all'" },
{ name = "pydantic", specifier = ">=2.10.0" },
@@ -4942,7 +5093,7 @@ requires-dist = [
{ name = "uvicorn", marker = "extra == 'os'", specifier = ">=0.34.0" },
{ name = "websockets", marker = "extra == 'mcp'", specifier = ">=12.0" },
]
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "search", "crawl", "all"]
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "search", "crawl", "sandbox", "a2ui", "sandbox-docker", "all"]
[[package]]
name = "pre-commit"
@@ -5064,16 +5215,16 @@ wheels = [
[[package]]
name = "protobuf"
version = "5.29.4"
version = "5.29.6"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/17/7d/b9dca7365f0e2c4fa7c193ff795427cfa6290147e5185ab11ece280a18e7/protobuf-5.29.4.tar.gz", hash = "sha256:4f1dfcd7997b31ef8f53ec82781ff434a28bf71d9102ddde14d076adcfc78c99", size = 424902, upload-time = "2025-03-19T21:23:24.25Z" }
sdist = { url = "https://files.pythonhosted.org/packages/7e/57/394a763c103e0edf87f0938dafcd918d53b4c011dfc5c8ae80f3b0452dbb/protobuf-5.29.6.tar.gz", hash = "sha256:da9ee6a5424b6b30fd5e45c5ea663aef540ca95f9ad99d1e887e819cdf9b8723", size = 425623, upload-time = "2026-02-04T22:54:40.584Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/9a/b2/043a1a1a20edd134563699b0e91862726a0dc9146c090743b6c44d798e75/protobuf-5.29.4-cp310-abi3-win32.whl", hash = "sha256:13eb236f8eb9ec34e63fc8b1d6efd2777d062fa6aaa68268fb67cf77f6839ad7", size = 422709, upload-time = "2025-03-19T21:23:08.293Z" },
{ url = "https://files.pythonhosted.org/packages/79/fc/2474b59570daa818de6124c0a15741ee3e5d6302e9d6ce0bdfd12e98119f/protobuf-5.29.4-cp310-abi3-win_amd64.whl", hash = "sha256:bcefcdf3976233f8a502d265eb65ea740c989bacc6c30a58290ed0e519eb4b8d", size = 434506, upload-time = "2025-03-19T21:23:11.253Z" },
{ url = "https://files.pythonhosted.org/packages/46/de/7c126bbb06aa0f8a7b38aaf8bd746c514d70e6a2a3f6dd460b3b7aad7aae/protobuf-5.29.4-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:307ecba1d852ec237e9ba668e087326a67564ef83e45a0189a772ede9e854dd0", size = 417826, upload-time = "2025-03-19T21:23:13.132Z" },
{ url = "https://files.pythonhosted.org/packages/a2/b5/bade14ae31ba871a139aa45e7a8183d869efe87c34a4850c87b936963261/protobuf-5.29.4-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:aec4962f9ea93c431d5714ed1be1c93f13e1a8618e70035ba2b0564d9e633f2e", size = 319574, upload-time = "2025-03-19T21:23:14.531Z" },
{ url = "https://files.pythonhosted.org/packages/46/88/b01ed2291aae68b708f7d334288ad5fb3e7aa769a9c309c91a0d55cb91b0/protobuf-5.29.4-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:d7d3f7d1d5a66ed4942d4fefb12ac4b14a29028b209d4bfb25c68ae172059922", size = 319672, upload-time = "2025-03-19T21:23:15.839Z" },
{ url = "https://files.pythonhosted.org/packages/12/fb/a586e0c973c95502e054ac5f81f88394f24ccc7982dac19c515acd9e2c93/protobuf-5.29.4-py3-none-any.whl", hash = "sha256:3fde11b505e1597f71b875ef2fc52062b6a9740e5f7c8997ce878b6009145862", size = 172551, upload-time = "2025-03-19T21:23:22.682Z" },
{ url = "https://files.pythonhosted.org/packages/d4/88/9ee58ff7863c479d6f8346686d4636dd4c415b0cbeed7a6a7d0617639c2a/protobuf-5.29.6-cp310-abi3-win32.whl", hash = "sha256:62e8a3114992c7c647bce37dcc93647575fc52d50e48de30c6fcb28a6a291eb1", size = 423357, upload-time = "2026-02-04T22:54:25.805Z" },
{ url = "https://files.pythonhosted.org/packages/1c/66/2dc736a4d576847134fb6d80bd995c569b13cdc7b815d669050bf0ce2d2c/protobuf-5.29.6-cp310-abi3-win_amd64.whl", hash = "sha256:7e6ad413275be172f67fdee0f43484b6de5a904cc1c3ea9804cb6fe2ff366eda", size = 435175, upload-time = "2026-02-04T22:54:28.592Z" },
{ url = "https://files.pythonhosted.org/packages/06/db/49b05966fd208ae3f44dcd33837b6243b4915c57561d730a43f881f24dea/protobuf-5.29.6-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:b5a169e664b4057183a34bdc424540e86eea47560f3c123a0d64de4e137f9269", size = 418619, upload-time = "2026-02-04T22:54:30.266Z" },
{ url = "https://files.pythonhosted.org/packages/b7/d7/48cbf6b0c3c39761e47a99cb483405f0fde2be22cf00d71ef316ce52b458/protobuf-5.29.6-cp38-abi3-manylinux2014_aarch64.whl", hash = "sha256:a8866b2cff111f0f863c1b3b9e7572dc7eaea23a7fae27f6fc613304046483e6", size = 320284, upload-time = "2026-02-04T22:54:31.782Z" },
{ url = "https://files.pythonhosted.org/packages/e3/dd/cadd6ec43069247d91f6345fa7a0d2858bef6af366dbd7ba8f05d2c77d3b/protobuf-5.29.6-cp38-abi3-manylinux2014_x86_64.whl", hash = "sha256:e3387f44798ac1106af0233c04fb8abf543772ff241169946f698b3a9a3d3ab9", size = 320478, upload-time = "2026-02-04T22:54:32.909Z" },
{ url = "https://files.pythonhosted.org/packages/5a/cb/e3065b447186cb70aa65acc70c86baf482d82bf75625bf5a2c4f6919c6a3/protobuf-5.29.6-py3-none-any.whl", hash = "sha256:6b9edb641441b2da9fa8f428760fc136a49cf97a52076010cf22a2ff73438a86", size = 173126, upload-time = "2026-02-04T22:54:39.462Z" },
]
[[package]]
@@ -6087,7 +6238,7 @@ wheels = [
[[package]]
name = "requests"
version = "2.32.3"
version = "2.34.2"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "certifi" },
@@ -6095,9 +6246,9 @@ dependencies = [
{ name = "idna" },
{ name = "urllib3" },
]
sdist = { url = "https://files.pythonhosted.org/packages/63/70/2bf7780ad2d390a8d301ad0b550f1581eadbd9a20f896afe06353c2a2913/requests-2.32.3.tar.gz", hash = "sha256:55365417734eb18255590a9ff9eb97e9e1da868d4ccd6402399eaf68af20a760", size = 131218, upload-time = "2024-05-29T15:37:49.536Z" }
sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/f9/9b/335f9764261e915ed497fcdeb11df5dfd6f7bf257d4a6a2a686d80da4d54/requests-2.32.3-py3-none-any.whl", hash = "sha256:70761cfe03c773ceb22aa2f671b4757976145175cdfca038c02654d061d6dcc6", size = 64928, upload-time = "2024-05-29T15:37:47.027Z" },
{ url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
]
[[package]]
@@ -6127,16 +6278,15 @@ wheels = [
[[package]]
name = "rich"
version = "13.9.4"
version = "14.3.4"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "markdown-it-py" },
{ name = "pygments" },
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/ab/3a/0316b28d0761c6734d6bc14e770d85506c986c85ffb239e688eeaab2c2bc/rich-13.9.4.tar.gz", hash = "sha256:439594978a49a09530cff7ebc4b5c7103ef57baf48d5ea3184f21d9a2befa098", size = 223149, upload-time = "2024-11-01T16:43:57.873Z" }
sdist = { url = "https://files.pythonhosted.org/packages/e9/67/cae617f1351490c25a4b8ac3b8b63a4dda609295d8222bad12242dfdc629/rich-14.3.4.tar.gz", hash = "sha256:817e02727f2b25b40ef56f5aa2217f400c8489f79ca8f46ea2b70dd5e14558a9", size = 230524, upload-time = "2026-04-11T02:57:45.419Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/19/71/39c7c0d87f8d4e6c020a393182060eaefeeae6c01dab6a84ec346f2567df/rich-13.9.4-py3-none-any.whl", hash = "sha256:6049d5e6ec054bf2779ab3358186963bac2ea89175919d699e378b99738c2a90", size = 242424, upload-time = "2024-11-01T16:43:55.817Z" },
{ url = "https://files.pythonhosted.org/packages/b3/76/6d163cfac87b632216f71879e6b2cf17163f773ff59c00b5ff4900a80fa3/rich-14.3.4-py3-none-any.whl", hash = "sha256:07e7adb4690f68864777b1450859253bed81a99a31ac321ac1817b2313558952", size = 310480, upload-time = "2026-04-11T02:57:47.484Z" },
]
[[package]]
@@ -6720,11 +6870,11 @@ wheels = [
[[package]]
name = "tenacity"
version = "8.5.0"
version = "9.1.4"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/a3/4d/6a19536c50b849338fcbe9290d562b52cbdcf30d8963d3588a68a4107df1/tenacity-8.5.0.tar.gz", hash = "sha256:8bc6c0c8a09b31e6cad13c47afbed1a567518250a9a171418582ed8d9c20ca78", size = 47309, upload-time = "2024-07-05T07:25:31.836Z" }
sdist = { url = "https://files.pythonhosted.org/packages/47/c6/ee486fd809e357697ee8a44d3d69222b344920433d3b6666ccd9b374630c/tenacity-9.1.4.tar.gz", hash = "sha256:adb31d4c263f2bd041081ab33b498309a57c77f9acf2db65aadf0898179cf93a", size = 49413, upload-time = "2026-02-07T10:45:33.841Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/d2/3f/8ba87d9e287b9d385a02a7114ddcef61b26f86411e121c9003eb509a1773/tenacity-8.5.0-py3-none-any.whl", hash = "sha256:b594c2a5945830c267ce6b79a166228323ed52718f30302c1359836112346687", size = 28165, upload-time = "2024-07-05T07:25:29.591Z" },
{ url = "https://files.pythonhosted.org/packages/d7/c1/eb8f9debc45d3b7918a32ab756658a0904732f75e555402972246b0b8e71/tenacity-9.1.4-py3-none-any.whl", hash = "sha256:6095a360c919085f28c6527de529e76a06ad89b23659fa881ae0649b867a9d55", size = 28926, upload-time = "2026-02-07T10:45:32.24Z" },
]
[[package]]
@@ -7162,6 +7312,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/a6/ab/7e5f53c3b9d14972843a647d8d7a853969a58aecc7559cb3267302c94774/tzdata-2024.2-py2.py3-none-any.whl", hash = "sha256:a48093786cdcde33cad18c2555e8532f34422074448fbc874186f0abd79565cd", size = 346586, upload-time = "2024-09-23T18:56:45.478Z" },
]
[[package]]
name = "tzlocal"
version = "5.3.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "tzdata", marker = "sys_platform == 'win32'" },
]
sdist = { url = "https://files.pythonhosted.org/packages/8b/2e/c14812d3d4d9cd1773c6be938f89e5735a1f11a9f184ac3639b93cef35d5/tzlocal-5.3.1.tar.gz", hash = "sha256:cceffc7edecefea1f595541dbd6e990cb1ea3d19bf01b2809f362a03dd7921fd", size = 30761, upload-time = "2025-03-05T21:17:41.549Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/c2/14/e2a54fabd4f08cd7af1c07030603c3356b74da07f7cc056e600436edfa17/tzlocal-5.3.1-py3-none-any.whl", hash = "sha256:eb1a66c3ef5847adf7a834f1be0800581b683b5608e74f86ecbcef8ab91bb85d", size = 18026, upload-time = "2025-03-05T21:17:39.857Z" },
]
[[package]]
name = "uc-micro-py"
version = "1.0.3"
@@ -7330,6 +7492,38 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/79/0c/c05523fa3181fdf0c9c52a6ba91a23fbf3246cc095f26f6516f9c60e6771/virtualenv-20.35.4-py3-none-any.whl", hash = "sha256:c21c9cede36c9753eeade68ba7d523529f228a403463376cf821eaae2b650f1b", size = 6005095, upload-time = "2025-10-29T06:57:37.598Z" },
]
[[package]]
name = "watchdog"
version = "6.0.0"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/db/7d/7f3d619e951c88ed75c6037b246ddcf2d322812ee8ea189be89511721d54/watchdog-6.0.0.tar.gz", hash = "sha256:9ddf7c82fda3ae8e24decda1338ede66e1c99883db93711d8fb941eaa2d8c282", size = 131220, upload-time = "2024-11-01T14:07:13.037Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/0c/56/90994d789c61df619bfc5ce2ecdabd5eeff564e1eb47512bd01b5e019569/watchdog-6.0.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d1cdb490583ebd691c012b3d6dae011000fe42edb7a82ece80965b42abd61f26", size = 96390, upload-time = "2024-11-01T14:06:24.793Z" },
{ url = "https://files.pythonhosted.org/packages/55/46/9a67ee697342ddf3c6daa97e3a587a56d6c4052f881ed926a849fcf7371c/watchdog-6.0.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:bc64ab3bdb6a04d69d4023b29422170b74681784ffb9463ed4870cf2f3e66112", size = 88389, upload-time = "2024-11-01T14:06:27.112Z" },
{ url = "https://files.pythonhosted.org/packages/44/65/91b0985747c52064d8701e1075eb96f8c40a79df889e59a399453adfb882/watchdog-6.0.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:c897ac1b55c5a1461e16dae288d22bb2e412ba9807df8397a635d88f671d36c3", size = 89020, upload-time = "2024-11-01T14:06:29.876Z" },
{ url = "https://files.pythonhosted.org/packages/e0/24/d9be5cd6642a6aa68352ded4b4b10fb0d7889cb7f45814fb92cecd35f101/watchdog-6.0.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:6eb11feb5a0d452ee41f824e271ca311a09e250441c262ca2fd7ebcf2461a06c", size = 96393, upload-time = "2024-11-01T14:06:31.756Z" },
{ url = "https://files.pythonhosted.org/packages/63/7a/6013b0d8dbc56adca7fdd4f0beed381c59f6752341b12fa0886fa7afc78b/watchdog-6.0.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:ef810fbf7b781a5a593894e4f439773830bdecb885e6880d957d5b9382a960d2", size = 88392, upload-time = "2024-11-01T14:06:32.99Z" },
{ url = "https://files.pythonhosted.org/packages/d1/40/b75381494851556de56281e053700e46bff5b37bf4c7267e858640af5a7f/watchdog-6.0.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:afd0fe1b2270917c5e23c2a65ce50c2a4abb63daafb0d419fde368e272a76b7c", size = 89019, upload-time = "2024-11-01T14:06:34.963Z" },
{ url = "https://files.pythonhosted.org/packages/39/ea/3930d07dafc9e286ed356a679aa02d777c06e9bfd1164fa7c19c288a5483/watchdog-6.0.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:bdd4e6f14b8b18c334febb9c4425a878a2ac20efd1e0b231978e7b150f92a948", size = 96471, upload-time = "2024-11-01T14:06:37.745Z" },
{ url = "https://files.pythonhosted.org/packages/12/87/48361531f70b1f87928b045df868a9fd4e253d9ae087fa4cf3f7113be363/watchdog-6.0.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c7c15dda13c4eb00d6fb6fc508b3c0ed88b9d5d374056b239c4ad1611125c860", size = 88449, upload-time = "2024-11-01T14:06:39.748Z" },
{ url = "https://files.pythonhosted.org/packages/5b/7e/8f322f5e600812e6f9a31b75d242631068ca8f4ef0582dd3ae6e72daecc8/watchdog-6.0.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:6f10cb2d5902447c7d0da897e2c6768bca89174d0c6e1e30abec5421af97a5b0", size = 89054, upload-time = "2024-11-01T14:06:41.009Z" },
{ url = "https://files.pythonhosted.org/packages/68/98/b0345cabdce2041a01293ba483333582891a3bd5769b08eceb0d406056ef/watchdog-6.0.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:490ab2ef84f11129844c23fb14ecf30ef3d8a6abafd3754a6f75ca1e6654136c", size = 96480, upload-time = "2024-11-01T14:06:42.952Z" },
{ url = "https://files.pythonhosted.org/packages/85/83/cdf13902c626b28eedef7ec4f10745c52aad8a8fe7eb04ed7b1f111ca20e/watchdog-6.0.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:76aae96b00ae814b181bb25b1b98076d5fc84e8a53cd8885a318b42b6d3a5134", size = 88451, upload-time = "2024-11-01T14:06:45.084Z" },
{ url = "https://files.pythonhosted.org/packages/fe/c4/225c87bae08c8b9ec99030cd48ae9c4eca050a59bf5c2255853e18c87b50/watchdog-6.0.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:a175f755fc2279e0b7312c0035d52e27211a5bc39719dd529625b1930917345b", size = 89057, upload-time = "2024-11-01T14:06:47.324Z" },
{ url = "https://files.pythonhosted.org/packages/30/ad/d17b5d42e28a8b91f8ed01cb949da092827afb9995d4559fd448d0472763/watchdog-6.0.0-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:c7ac31a19f4545dd92fc25d200694098f42c9a8e391bc00bdd362c5736dbf881", size = 87902, upload-time = "2024-11-01T14:06:53.119Z" },
{ url = "https://files.pythonhosted.org/packages/5c/ca/c3649991d140ff6ab67bfc85ab42b165ead119c9e12211e08089d763ece5/watchdog-6.0.0-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:9513f27a1a582d9808cf21a07dae516f0fab1cf2d7683a742c498b93eedabb11", size = 88380, upload-time = "2024-11-01T14:06:55.19Z" },
{ url = "https://files.pythonhosted.org/packages/a9/c7/ca4bf3e518cb57a686b2feb4f55a1892fd9a3dd13f470fca14e00f80ea36/watchdog-6.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:7607498efa04a3542ae3e05e64da8202e58159aa1fa4acddf7678d34a35d4f13", size = 79079, upload-time = "2024-11-01T14:06:59.472Z" },
{ url = "https://files.pythonhosted.org/packages/5c/51/d46dc9332f9a647593c947b4b88e2381c8dfc0942d15b8edc0310fa4abb1/watchdog-6.0.0-py3-none-manylinux2014_armv7l.whl", hash = "sha256:9041567ee8953024c83343288ccc458fd0a2d811d6a0fd68c4c22609e3490379", size = 79078, upload-time = "2024-11-01T14:07:01.431Z" },
{ url = "https://files.pythonhosted.org/packages/d4/57/04edbf5e169cd318d5f07b4766fee38e825d64b6913ca157ca32d1a42267/watchdog-6.0.0-py3-none-manylinux2014_i686.whl", hash = "sha256:82dc3e3143c7e38ec49d61af98d6558288c415eac98486a5c581726e0737c00e", size = 79076, upload-time = "2024-11-01T14:07:02.568Z" },
{ url = "https://files.pythonhosted.org/packages/ab/cc/da8422b300e13cb187d2203f20b9253e91058aaf7db65b74142013478e66/watchdog-6.0.0-py3-none-manylinux2014_ppc64.whl", hash = "sha256:212ac9b8bf1161dc91bd09c048048a95ca3a4c4f5e5d4a7d1b1a7d5752a7f96f", size = 79077, upload-time = "2024-11-01T14:07:03.893Z" },
{ url = "https://files.pythonhosted.org/packages/2c/3b/b8964e04ae1a025c44ba8e4291f86e97fac443bca31de8bd98d3263d2fcf/watchdog-6.0.0-py3-none-manylinux2014_ppc64le.whl", hash = "sha256:e3df4cbb9a450c6d49318f6d14f4bbc80d763fa587ba46ec86f99f9e6876bb26", size = 79078, upload-time = "2024-11-01T14:07:05.189Z" },
{ url = "https://files.pythonhosted.org/packages/62/ae/a696eb424bedff7407801c257d4b1afda455fe40821a2be430e173660e81/watchdog-6.0.0-py3-none-manylinux2014_s390x.whl", hash = "sha256:2cce7cfc2008eb51feb6aab51251fd79b85d9894e98ba847408f662b3395ca3c", size = 79077, upload-time = "2024-11-01T14:07:06.376Z" },
{ url = "https://files.pythonhosted.org/packages/b5/e8/dbf020b4d98251a9860752a094d09a65e1b436ad181faf929983f697048f/watchdog-6.0.0-py3-none-manylinux2014_x86_64.whl", hash = "sha256:20ffe5b202af80ab4266dcd3e91aae72bf2da48c0d33bdb15c66658e685e94e2", size = 79078, upload-time = "2024-11-01T14:07:07.547Z" },
{ url = "https://files.pythonhosted.org/packages/07/f6/d0e5b343768e8bcb4cda79f0f2f55051bf26177ecd5651f84c07567461cf/watchdog-6.0.0-py3-none-win32.whl", hash = "sha256:07df1fdd701c5d4c8e55ef6cf55b8f0120fe1aef7ef39a1c6fc6bc2e606d517a", size = 79065, upload-time = "2024-11-01T14:07:09.525Z" },
{ url = "https://files.pythonhosted.org/packages/db/d9/c495884c6e548fce18a8f40568ff120bc3a4b7b99813081c8ac0c936fa64/watchdog-6.0.0-py3-none-win_amd64.whl", hash = "sha256:cbafb470cf848d93b5d013e2ecb245d4aa1c8fd0504e863ccefa32445359d680", size = 79070, upload-time = "2024-11-01T14:07:10.686Z" },
{ url = "https://files.pythonhosted.org/packages/33/e8/e40370e6d74ddba47f002a32919d91310d6074130fe4e17dabcafc15cbf1/watchdog-6.0.0-py3-none-win_ia64.whl", hash = "sha256:a1914259fa9e1454315171103c6a30961236f508b9b623eae470268bbcc6a22f", size = 79067, upload-time = "2024-11-01T14:07:11.845Z" },
]
[[package]]
name = "watchfiles"
version = "0.24.0"
@@ -7395,6 +7589,18 @@ wheels = [
{ url = "https://files.pythonhosted.org/packages/8b/c4/08b3c2cda45db5169148a981c2100c744a4a222fa7ae7644937c0c002069/watchfiles-0.24.0-pp310-pypy310_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:01550ccf1d0aed6ea375ef259706af76ad009ef5b0203a3a4cce0f6024f9b68a", size = 426804, upload-time = "2024-08-28T16:21:30.687Z" },
]
[[package]]
name = "wcmatch"
version = "10.1"
source = { registry = "https://pypi.org/simple" }
dependencies = [
{ name = "bracex" },
]
sdist = { url = "https://files.pythonhosted.org/packages/79/3e/c0bdc27cf06f4e47680bd5803a07cb3dfd17de84cde92dd217dcb9e05253/wcmatch-10.1.tar.gz", hash = "sha256:f11f94208c8c8484a16f4f48638a85d771d9513f4ab3f37595978801cb9465af", size = 117421, upload-time = "2025-06-22T19:14:02.49Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/eb/d8/0d1d2e9d3fabcf5d6840362adcf05f8cf3cd06a73358140c3a97189238ae/wcmatch-10.1-py3-none-any.whl", hash = "sha256:5848ace7dbb0476e5e55ab63c6bbd529745089343427caa5537f230cc01beb8a", size = 39854, upload-time = "2025-06-22T19:14:00.978Z" },
]
[[package]]
name = "webencodings"
version = "0.5.1"
@@ -7415,70 +7621,61 @@ wheels = [
[[package]]
name = "websockets"
version = "16.0"
version = "15.0.1"
source = { registry = "https://pypi.org/simple" }
sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" }
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
wheels = [
{ url = "https://files.pythonhosted.org/packages/20/74/221f58decd852f4b59cc3354cccaf87e8ef695fede361d03dc9a7396573b/websockets-16.0-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:04cdd5d2d1dacbad0a7bf36ccbcd3ccd5a30ee188f2560b7a62a30d14107b31a", size = 177343, upload-time = "2026-01-10T09:22:21.28Z" },
{ url = "https://files.pythonhosted.org/packages/19/0f/22ef6107ee52ab7f0b710d55d36f5a5d3ef19e8a205541a6d7ffa7994e5a/websockets-16.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:8ff32bb86522a9e5e31439a58addbb0166f0204d64066fb955265c4e214160f0", size = 175021, upload-time = "2026-01-10T09:22:22.696Z" },
{ url = "https://files.pythonhosted.org/packages/10/40/904a4cb30d9b61c0e278899bf36342e9b0208eb3c470324a9ecbaac2a30f/websockets-16.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:583b7c42688636f930688d712885cf1531326ee05effd982028212ccc13e5957", size = 175320, upload-time = "2026-01-10T09:22:23.94Z" },
{ url = "https://files.pythonhosted.org/packages/9d/2f/4b3ca7e106bc608744b1cdae041e005e446124bebb037b18799c2d356864/websockets-16.0-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:7d837379b647c0c4c2355c2499723f82f1635fd2c26510e1f587d89bc2199e72", size = 183815, upload-time = "2026-01-10T09:22:25.469Z" },
{ url = "https://files.pythonhosted.org/packages/86/26/d40eaa2a46d4302becec8d15b0fc5e45bdde05191e7628405a19cf491ccd/websockets-16.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df57afc692e517a85e65b72e165356ed1df12386ecb879ad5693be08fac65dde", size = 185054, upload-time = "2026-01-10T09:22:27.101Z" },
{ url = "https://files.pythonhosted.org/packages/b0/ba/6500a0efc94f7373ee8fefa8c271acdfd4dca8bd49a90d4be7ccabfc397e/websockets-16.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:2b9f1e0d69bc60a4a87349d50c09a037a2607918746f07de04df9e43252c77a3", size = 184565, upload-time = "2026-01-10T09:22:28.293Z" },
{ url = "https://files.pythonhosted.org/packages/04/b4/96bf2cee7c8d8102389374a2616200574f5f01128d1082f44102140344cc/websockets-16.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:335c23addf3d5e6a8633f9f8eda77efad001671e80b95c491dd0924587ece0b3", size = 183848, upload-time = "2026-01-10T09:22:30.394Z" },
{ url = "https://files.pythonhosted.org/packages/02/8e/81f40fb00fd125357814e8c3025738fc4ffc3da4b6b4a4472a82ba304b41/websockets-16.0-cp310-cp310-win32.whl", hash = "sha256:37b31c1623c6605e4c00d466c9d633f9b812ea430c11c8a278774a1fde1acfa9", size = 178249, upload-time = "2026-01-10T09:22:32.083Z" },
{ url = "https://files.pythonhosted.org/packages/b4/5f/7e40efe8df57db9b91c88a43690ac66f7b7aa73a11aa6a66b927e44f26fa/websockets-16.0-cp310-cp310-win_amd64.whl", hash = "sha256:8e1dab317b6e77424356e11e99a432b7cb2f3ec8c5ab4dabbcee6add48f72b35", size = 178685, upload-time = "2026-01-10T09:22:33.345Z" },
{ url = "https://files.pythonhosted.org/packages/f2/db/de907251b4ff46ae804ad0409809504153b3f30984daf82a1d84a9875830/websockets-16.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:31a52addea25187bde0797a97d6fc3d2f92b6f72a9370792d65a6e84615ac8a8", size = 177340, upload-time = "2026-01-10T09:22:34.539Z" },
{ url = "https://files.pythonhosted.org/packages/f3/fa/abe89019d8d8815c8781e90d697dec52523fb8ebe308bf11664e8de1877e/websockets-16.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:417b28978cdccab24f46400586d128366313e8a96312e4b9362a4af504f3bbad", size = 175022, upload-time = "2026-01-10T09:22:36.332Z" },
{ url = "https://files.pythonhosted.org/packages/58/5d/88ea17ed1ded2079358b40d31d48abe90a73c9e5819dbcde1606e991e2ad/websockets-16.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:af80d74d4edfa3cb9ed973a0a5ba2b2a549371f8a741e0800cb07becdd20f23d", size = 175319, upload-time = "2026-01-10T09:22:37.602Z" },
{ url = "https://files.pythonhosted.org/packages/d2/ae/0ee92b33087a33632f37a635e11e1d99d429d3d323329675a6022312aac2/websockets-16.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:08d7af67b64d29823fed316505a89b86705f2b7981c07848fb5e3ea3020c1abe", size = 184631, upload-time = "2026-01-10T09:22:38.789Z" },
{ url = "https://files.pythonhosted.org/packages/c8/c5/27178df583b6c5b31b29f526ba2da5e2f864ecc79c99dae630a85d68c304/websockets-16.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7be95cfb0a4dae143eaed2bcba8ac23f4892d8971311f1b06f3c6b78952ee70b", size = 185870, upload-time = "2026-01-10T09:22:39.893Z" },
{ url = "https://files.pythonhosted.org/packages/87/05/536652aa84ddc1c018dbb7e2c4cbcd0db884580bf8e95aece7593fde526f/websockets-16.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:d6297ce39ce5c2e6feb13c1a996a2ded3b6832155fcfc920265c76f24c7cceb5", size = 185361, upload-time = "2026-01-10T09:22:41.016Z" },
{ url = "https://files.pythonhosted.org/packages/6d/e2/d5332c90da12b1e01f06fb1b85c50cfc489783076547415bf9f0a659ec19/websockets-16.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:1c1b30e4f497b0b354057f3467f56244c603a79c0d1dafce1d16c283c25f6e64", size = 184615, upload-time = "2026-01-10T09:22:42.442Z" },
{ url = "https://files.pythonhosted.org/packages/77/fb/d3f9576691cae9253b51555f841bc6600bf0a983a461c79500ace5a5b364/websockets-16.0-cp311-cp311-win32.whl", hash = "sha256:5f451484aeb5cafee1ccf789b1b66f535409d038c56966d6101740c1614b86c6", size = 178246, upload-time = "2026-01-10T09:22:43.654Z" },
{ url = "https://files.pythonhosted.org/packages/54/67/eaff76b3dbaf18dcddabc3b8c1dba50b483761cccff67793897945b37408/websockets-16.0-cp311-cp311-win_amd64.whl", hash = "sha256:8d7f0659570eefb578dacde98e24fb60af35350193e4f56e11190787bee77dac", size = 178684, upload-time = "2026-01-10T09:22:44.941Z" },
{ url = "https://files.pythonhosted.org/packages/84/7b/bac442e6b96c9d25092695578dda82403c77936104b5682307bd4deb1ad4/websockets-16.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:71c989cbf3254fbd5e84d3bff31e4da39c43f884e64f2551d14bb3c186230f00", size = 177365, upload-time = "2026-01-10T09:22:46.787Z" },
{ url = "https://files.pythonhosted.org/packages/b0/fe/136ccece61bd690d9c1f715baaeefd953bb2360134de73519d5df19d29ca/websockets-16.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:8b6e209ffee39ff1b6d0fa7bfef6de950c60dfb91b8fcead17da4ee539121a79", size = 175038, upload-time = "2026-01-10T09:22:47.999Z" },
{ url = "https://files.pythonhosted.org/packages/40/1e/9771421ac2286eaab95b8575b0cb701ae3663abf8b5e1f64f1fd90d0a673/websockets-16.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:86890e837d61574c92a97496d590968b23c2ef0aeb8a9bc9421d174cd378ae39", size = 175328, upload-time = "2026-01-10T09:22:49.809Z" },
{ url = "https://files.pythonhosted.org/packages/18/29/71729b4671f21e1eaa5d6573031ab810ad2936c8175f03f97f3ff164c802/websockets-16.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9b5aca38b67492ef518a8ab76851862488a478602229112c4b0d58d63a7a4d5c", size = 184915, upload-time = "2026-01-10T09:22:51.071Z" },
{ url = "https://files.pythonhosted.org/packages/97/bb/21c36b7dbbafc85d2d480cd65df02a1dc93bf76d97147605a8e27ff9409d/websockets-16.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e0334872c0a37b606418ac52f6ab9cfd17317ac26365f7f65e203e2d0d0d359f", size = 186152, upload-time = "2026-01-10T09:22:52.224Z" },
{ url = "https://files.pythonhosted.org/packages/4a/34/9bf8df0c0cf88fa7bfe36678dc7b02970c9a7d5e065a3099292db87b1be2/websockets-16.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:a0b31e0b424cc6b5a04b8838bbaec1688834b2383256688cf47eb97412531da1", size = 185583, upload-time = "2026-01-10T09:22:53.443Z" },
{ url = "https://files.pythonhosted.org/packages/47/88/4dd516068e1a3d6ab3c7c183288404cd424a9a02d585efbac226cb61ff2d/websockets-16.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:485c49116d0af10ac698623c513c1cc01c9446c058a4e61e3bf6c19dff7335a2", size = 184880, upload-time = "2026-01-10T09:22:55.033Z" },
{ url = "https://files.pythonhosted.org/packages/91/d6/7d4553ad4bf1c0421e1ebd4b18de5d9098383b5caa1d937b63df8d04b565/websockets-16.0-cp312-cp312-win32.whl", hash = "sha256:eaded469f5e5b7294e2bdca0ab06becb6756ea86894a47806456089298813c89", size = 178261, upload-time = "2026-01-10T09:22:56.251Z" },
{ url = "https://files.pythonhosted.org/packages/c3/f0/f3a17365441ed1c27f850a80b2bc680a0fa9505d733fe152fdf5e98c1c0b/websockets-16.0-cp312-cp312-win_amd64.whl", hash = "sha256:5569417dc80977fc8c2d43a86f78e0a5a22fee17565d78621b6bb264a115d4ea", size = 178693, upload-time = "2026-01-10T09:22:57.478Z" },
{ url = "https://files.pythonhosted.org/packages/cc/9c/baa8456050d1c1b08dd0ec7346026668cbc6f145ab4e314d707bb845bf0d/websockets-16.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:878b336ac47938b474c8f982ac2f7266a540adc3fa4ad74ae96fea9823a02cc9", size = 177364, upload-time = "2026-01-10T09:22:59.333Z" },
{ url = "https://files.pythonhosted.org/packages/7e/0c/8811fc53e9bcff68fe7de2bcbe75116a8d959ac699a3200f4847a8925210/websockets-16.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:52a0fec0e6c8d9a784c2c78276a48a2bdf099e4ccc2a4cad53b27718dbfd0230", size = 175039, upload-time = "2026-01-10T09:23:01.171Z" },
{ url = "https://files.pythonhosted.org/packages/aa/82/39a5f910cb99ec0b59e482971238c845af9220d3ab9fa76dd9162cda9d62/websockets-16.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:e6578ed5b6981005df1860a56e3617f14a6c307e6a71b4fff8c48fdc50f3ed2c", size = 175323, upload-time = "2026-01-10T09:23:02.341Z" },
{ url = "https://files.pythonhosted.org/packages/bd/28/0a25ee5342eb5d5f297d992a77e56892ecb65e7854c7898fb7d35e9b33bd/websockets-16.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:95724e638f0f9c350bb1c2b0a7ad0e83d9cc0c9259f3ea94e40d7b02a2179ae5", size = 184975, upload-time = "2026-01-10T09:23:03.756Z" },
{ url = "https://files.pythonhosted.org/packages/f9/66/27ea52741752f5107c2e41fda05e8395a682a1e11c4e592a809a90c6a506/websockets-16.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0204dc62a89dc9d50d682412c10b3542d748260d743500a85c13cd1ee4bde82", size = 186203, upload-time = "2026-01-10T09:23:05.01Z" },
{ url = "https://files.pythonhosted.org/packages/37/e5/8e32857371406a757816a2b471939d51c463509be73fa538216ea52b792a/websockets-16.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:52ac480f44d32970d66763115edea932f1c5b1312de36df06d6b219f6741eed8", size = 185653, upload-time = "2026-01-10T09:23:06.301Z" },
{ url = "https://files.pythonhosted.org/packages/9b/67/f926bac29882894669368dc73f4da900fcdf47955d0a0185d60103df5737/websockets-16.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6e5a82b677f8f6f59e8dfc34ec06ca6b5b48bc4fcda346acd093694cc2c24d8f", size = 184920, upload-time = "2026-01-10T09:23:07.492Z" },
{ url = "https://files.pythonhosted.org/packages/3c/a1/3d6ccdcd125b0a42a311bcd15a7f705d688f73b2a22d8cf1c0875d35d34a/websockets-16.0-cp313-cp313-win32.whl", hash = "sha256:abf050a199613f64c886ea10f38b47770a65154dc37181bfaff70c160f45315a", size = 178255, upload-time = "2026-01-10T09:23:09.245Z" },
{ url = "https://files.pythonhosted.org/packages/6b/ae/90366304d7c2ce80f9b826096a9e9048b4bb760e44d3b873bb272cba696b/websockets-16.0-cp313-cp313-win_amd64.whl", hash = "sha256:3425ac5cf448801335d6fdc7ae1eb22072055417a96cc6b31b3861f455fbc156", size = 178689, upload-time = "2026-01-10T09:23:10.483Z" },
{ url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" },
{ url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" },
{ url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" },
{ url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" },
{ url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" },
{ url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" },
{ url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" },
{ url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" },
{ url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" },
{ url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" },
{ url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" },
{ url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" },
{ url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" },
{ url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" },
{ url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" },
{ url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" },
{ url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" },
{ url = "https://files.pythonhosted.org/packages/72/07/c98a68571dcf256e74f1f816b8cc5eae6eb2d3d5cfa44d37f801619d9166/websockets-16.0-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:349f83cd6c9a415428ee1005cadb5c2c56f4389bc06a9af16103c3bc3dcc8b7d", size = 174947, upload-time = "2026-01-10T09:23:36.166Z" },
{ url = "https://files.pythonhosted.org/packages/7e/52/93e166a81e0305b33fe416338be92ae863563fe7bce446b0f687b9df5aea/websockets-16.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:4a1aba3340a8dca8db6eb5a7986157f52eb9e436b74813764241981ca4888f03", size = 175260, upload-time = "2026-01-10T09:23:37.409Z" },
{ url = "https://files.pythonhosted.org/packages/56/0c/2dbf513bafd24889d33de2ff0368190a0e69f37bcfa19009ef819fe4d507/websockets-16.0-pp311-pypy311_pp73-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:f4a32d1bd841d4bcbffdcb3d2ce50c09c3909fbead375ab28d0181af89fd04da", size = 176071, upload-time = "2026-01-10T09:23:39.158Z" },
{ url = "https://files.pythonhosted.org/packages/a5/8f/aea9c71cc92bf9b6cc0f7f70df8f0b420636b6c96ef4feee1e16f80f75dd/websockets-16.0-pp311-pypy311_pp73-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0298d07ee155e2e9fda5be8a9042200dd2e3bb0b8a38482156576f863a9d457c", size = 176968, upload-time = "2026-01-10T09:23:41.031Z" },
{ url = "https://files.pythonhosted.org/packages/9a/3f/f70e03f40ffc9a30d817eef7da1be72ee4956ba8d7255c399a01b135902a/websockets-16.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:a653aea902e0324b52f1613332ddf50b00c06fdaf7e92624fbf8c77c78fa5767", size = 178735, upload-time = "2026-01-10T09:23:42.259Z" },
{ url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" },
{ url = "https://files.pythonhosted.org/packages/1e/da/6462a9f510c0c49837bbc9345aca92d767a56c1fb2939e1579df1e1cdcf7/websockets-15.0.1-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:d63efaa0cd96cf0c5fe4d581521d9fa87744540d4bc999ae6e08595a1014b45b", size = 175423, upload-time = "2025-03-05T20:01:35.363Z" },
{ url = "https://files.pythonhosted.org/packages/1c/9f/9d11c1a4eb046a9e106483b9ff69bce7ac880443f00e5ce64261b47b07e7/websockets-15.0.1-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:ac60e3b188ec7574cb761b08d50fcedf9d77f1530352db4eef1707fe9dee7205", size = 173080, upload-time = "2025-03-05T20:01:37.304Z" },
{ url = "https://files.pythonhosted.org/packages/d5/4f/b462242432d93ea45f297b6179c7333dd0402b855a912a04e7fc61c0d71f/websockets-15.0.1-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:5756779642579d902eed757b21b0164cd6fe338506a8083eb58af5c372e39d9a", size = 173329, upload-time = "2025-03-05T20:01:39.668Z" },
{ url = "https://files.pythonhosted.org/packages/6e/0c/6afa1f4644d7ed50284ac59cc70ef8abd44ccf7d45850d989ea7310538d0/websockets-15.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0fdfe3e2a29e4db3659dbd5bbf04560cea53dd9610273917799f1cde46aa725e", size = 182312, upload-time = "2025-03-05T20:01:41.815Z" },
{ url = "https://files.pythonhosted.org/packages/dd/d4/ffc8bd1350b229ca7a4db2a3e1c482cf87cea1baccd0ef3e72bc720caeec/websockets-15.0.1-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4c2529b320eb9e35af0fa3016c187dffb84a3ecc572bcee7c3ce302bfeba52bf", size = 181319, upload-time = "2025-03-05T20:01:43.967Z" },
{ url = "https://files.pythonhosted.org/packages/97/3a/5323a6bb94917af13bbb34009fac01e55c51dfde354f63692bf2533ffbc2/websockets-15.0.1-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac1e5c9054fe23226fb11e05a6e630837f074174c4c2f0fe442996112a6de4fb", size = 181631, upload-time = "2025-03-05T20:01:46.104Z" },
{ url = "https://files.pythonhosted.org/packages/a6/cc/1aeb0f7cee59ef065724041bb7ed667b6ab1eeffe5141696cccec2687b66/websockets-15.0.1-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:5df592cd503496351d6dc14f7cdad49f268d8e618f80dce0cd5a36b93c3fc08d", size = 182016, upload-time = "2025-03-05T20:01:47.603Z" },
{ url = "https://files.pythonhosted.org/packages/79/f9/c86f8f7af208e4161a7f7e02774e9d0a81c632ae76db2ff22549e1718a51/websockets-15.0.1-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:0a34631031a8f05657e8e90903e656959234f3a04552259458aac0b0f9ae6fd9", size = 181426, upload-time = "2025-03-05T20:01:48.949Z" },
{ url = "https://files.pythonhosted.org/packages/c7/b9/828b0bc6753db905b91df6ae477c0b14a141090df64fb17f8a9d7e3516cf/websockets-15.0.1-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:3d00075aa65772e7ce9e990cab3ff1de702aa09be3940d1dc88d5abf1ab8a09c", size = 181360, upload-time = "2025-03-05T20:01:50.938Z" },
{ url = "https://files.pythonhosted.org/packages/89/fb/250f5533ec468ba6327055b7d98b9df056fb1ce623b8b6aaafb30b55d02e/websockets-15.0.1-cp310-cp310-win32.whl", hash = "sha256:1234d4ef35db82f5446dca8e35a7da7964d02c127b095e172e54397fb6a6c256", size = 176388, upload-time = "2025-03-05T20:01:52.213Z" },
{ url = "https://files.pythonhosted.org/packages/1c/46/aca7082012768bb98e5608f01658ff3ac8437e563eca41cf068bd5849a5e/websockets-15.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:39c1fec2c11dc8d89bba6b2bf1556af381611a173ac2b511cf7231622058af41", size = 176830, upload-time = "2025-03-05T20:01:53.922Z" },
{ url = "https://files.pythonhosted.org/packages/9f/32/18fcd5919c293a398db67443acd33fde142f283853076049824fc58e6f75/websockets-15.0.1-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:823c248b690b2fd9303ba00c4f66cd5e2d8c3ba4aa968b2779be9532a4dad431", size = 175423, upload-time = "2025-03-05T20:01:56.276Z" },
{ url = "https://files.pythonhosted.org/packages/76/70/ba1ad96b07869275ef42e2ce21f07a5b0148936688c2baf7e4a1f60d5058/websockets-15.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:678999709e68425ae2593acf2e3ebcbcf2e69885a5ee78f9eb80e6e371f1bf57", size = 173082, upload-time = "2025-03-05T20:01:57.563Z" },
{ url = "https://files.pythonhosted.org/packages/86/f2/10b55821dd40eb696ce4704a87d57774696f9451108cff0d2824c97e0f97/websockets-15.0.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d50fd1ee42388dcfb2b3676132c78116490976f1300da28eb629272d5d93e905", size = 173330, upload-time = "2025-03-05T20:01:59.063Z" },
{ url = "https://files.pythonhosted.org/packages/a5/90/1c37ae8b8a113d3daf1065222b6af61cc44102da95388ac0018fcb7d93d9/websockets-15.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d99e5546bf73dbad5bf3547174cd6cb8ba7273062a23808ffea025ecb1cf8562", size = 182878, upload-time = "2025-03-05T20:02:00.305Z" },
{ url = "https://files.pythonhosted.org/packages/8e/8d/96e8e288b2a41dffafb78e8904ea7367ee4f891dafc2ab8d87e2124cb3d3/websockets-15.0.1-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:66dd88c918e3287efc22409d426c8f729688d89a0c587c88971a0faa2c2f3792", size = 181883, upload-time = "2025-03-05T20:02:03.148Z" },
{ url = "https://files.pythonhosted.org/packages/93/1f/5d6dbf551766308f6f50f8baf8e9860be6182911e8106da7a7f73785f4c4/websockets-15.0.1-cp311-cp311-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8dd8327c795b3e3f219760fa603dcae1dcc148172290a8ab15158cf85a953413", size = 182252, upload-time = "2025-03-05T20:02:05.29Z" },
{ url = "https://files.pythonhosted.org/packages/d4/78/2d4fed9123e6620cbf1706c0de8a1632e1a28e7774d94346d7de1bba2ca3/websockets-15.0.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8fdc51055e6ff4adeb88d58a11042ec9a5eae317a0a53d12c062c8a8865909e8", size = 182521, upload-time = "2025-03-05T20:02:07.458Z" },
{ url = "https://files.pythonhosted.org/packages/e7/3b/66d4c1b444dd1a9823c4a81f50231b921bab54eee2f69e70319b4e21f1ca/websockets-15.0.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:693f0192126df6c2327cce3baa7c06f2a117575e32ab2308f7f8216c29d9e2e3", size = 181958, upload-time = "2025-03-05T20:02:09.842Z" },
{ url = "https://files.pythonhosted.org/packages/08/ff/e9eed2ee5fed6f76fdd6032ca5cd38c57ca9661430bb3d5fb2872dc8703c/websockets-15.0.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:54479983bd5fb469c38f2f5c7e3a24f9a4e70594cd68cd1fa6b9340dadaff7cf", size = 181918, upload-time = "2025-03-05T20:02:11.968Z" },
{ url = "https://files.pythonhosted.org/packages/d8/75/994634a49b7e12532be6a42103597b71098fd25900f7437d6055ed39930a/websockets-15.0.1-cp311-cp311-win32.whl", hash = "sha256:16b6c1b3e57799b9d38427dda63edcbe4926352c47cf88588c0be4ace18dac85", size = 176388, upload-time = "2025-03-05T20:02:13.32Z" },
{ url = "https://files.pythonhosted.org/packages/98/93/e36c73f78400a65f5e236cd376713c34182e6663f6889cd45a4a04d8f203/websockets-15.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:27ccee0071a0e75d22cb35849b1db43f2ecd3e161041ac1ee9d2352ddf72f065", size = 176828, upload-time = "2025-03-05T20:02:14.585Z" },
{ url = "https://files.pythonhosted.org/packages/51/6b/4545a0d843594f5d0771e86463606a3988b5a09ca5123136f8a76580dd63/websockets-15.0.1-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:3e90baa811a5d73f3ca0bcbf32064d663ed81318ab225ee4f427ad4e26e5aff3", size = 175437, upload-time = "2025-03-05T20:02:16.706Z" },
{ url = "https://files.pythonhosted.org/packages/f4/71/809a0f5f6a06522af902e0f2ea2757f71ead94610010cf570ab5c98e99ed/websockets-15.0.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:592f1a9fe869c778694f0aa806ba0374e97648ab57936f092fd9d87f8bc03665", size = 173096, upload-time = "2025-03-05T20:02:18.832Z" },
{ url = "https://files.pythonhosted.org/packages/3d/69/1a681dd6f02180916f116894181eab8b2e25b31e484c5d0eae637ec01f7c/websockets-15.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0701bc3cfcb9164d04a14b149fd74be7347a530ad3bbf15ab2c678a2cd3dd9a2", size = 173332, upload-time = "2025-03-05T20:02:20.187Z" },
{ url = "https://files.pythonhosted.org/packages/a6/02/0073b3952f5bce97eafbb35757f8d0d54812b6174ed8dd952aa08429bcc3/websockets-15.0.1-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:e8b56bdcdb4505c8078cb6c7157d9811a85790f2f2b3632c7d1462ab5783d215", size = 183152, upload-time = "2025-03-05T20:02:22.286Z" },
{ url = "https://files.pythonhosted.org/packages/74/45/c205c8480eafd114b428284840da0b1be9ffd0e4f87338dc95dc6ff961a1/websockets-15.0.1-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0af68c55afbd5f07986df82831c7bff04846928ea8d1fd7f30052638788bc9b5", size = 182096, upload-time = "2025-03-05T20:02:24.368Z" },
{ url = "https://files.pythonhosted.org/packages/14/8f/aa61f528fba38578ec553c145857a181384c72b98156f858ca5c8e82d9d3/websockets-15.0.1-cp312-cp312-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:64dee438fed052b52e4f98f76c5790513235efaa1ef7f3f2192c392cd7c91b65", size = 182523, upload-time = "2025-03-05T20:02:25.669Z" },
{ url = "https://files.pythonhosted.org/packages/ec/6d/0267396610add5bc0d0d3e77f546d4cd287200804fe02323797de77dbce9/websockets-15.0.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:d5f6b181bb38171a8ad1d6aa58a67a6aa9d4b38d0f8c5f496b9e42561dfc62fe", size = 182790, upload-time = "2025-03-05T20:02:26.99Z" },
{ url = "https://files.pythonhosted.org/packages/02/05/c68c5adbf679cf610ae2f74a9b871ae84564462955d991178f95a1ddb7dd/websockets-15.0.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:5d54b09eba2bada6011aea5375542a157637b91029687eb4fdb2dab11059c1b4", size = 182165, upload-time = "2025-03-05T20:02:30.291Z" },
{ url = "https://files.pythonhosted.org/packages/29/93/bb672df7b2f5faac89761cb5fa34f5cec45a4026c383a4b5761c6cea5c16/websockets-15.0.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:3be571a8b5afed347da347bfcf27ba12b069d9d7f42cb8c7028b5e98bbb12597", size = 182160, upload-time = "2025-03-05T20:02:31.634Z" },
{ url = "https://files.pythonhosted.org/packages/ff/83/de1f7709376dc3ca9b7eeb4b9a07b4526b14876b6d372a4dc62312bebee0/websockets-15.0.1-cp312-cp312-win32.whl", hash = "sha256:c338ffa0520bdb12fbc527265235639fb76e7bc7faafbb93f6ba80d9c06578a9", size = 176395, upload-time = "2025-03-05T20:02:33.017Z" },
{ url = "https://files.pythonhosted.org/packages/7d/71/abf2ebc3bbfa40f391ce1428c7168fb20582d0ff57019b69ea20fa698043/websockets-15.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:fcd5cf9e305d7b8338754470cf69cf81f420459dbae8a3b40cee57417f4614a7", size = 176841, upload-time = "2025-03-05T20:02:34.498Z" },
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
{ url = "https://files.pythonhosted.org/packages/02/9e/d40f779fa16f74d3468357197af8d6ad07e7c5a27ea1ca74ceb38986f77a/websockets-15.0.1-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:0c9e74d766f2818bb95f84c25be4dea09841ac0f734d1966f415e4edfc4ef1c3", size = 173109, upload-time = "2025-03-05T20:03:17.769Z" },
{ url = "https://files.pythonhosted.org/packages/bc/cd/5b887b8585a593073fd92f7c23ecd3985cd2c3175025a91b0d69b0551372/websockets-15.0.1-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:1009ee0c7739c08a0cd59de430d6de452a55e42d6b522de7aa15e6f67db0b8e1", size = 173343, upload-time = "2025-03-05T20:03:19.094Z" },
{ url = "https://files.pythonhosted.org/packages/fe/ae/d34f7556890341e900a95acf4886833646306269f899d58ad62f588bf410/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:76d1f20b1c7a2fa82367e04982e708723ba0e7b8d43aa643d3dcd404d74f1475", size = 174599, upload-time = "2025-03-05T20:03:21.1Z" },
{ url = "https://files.pythonhosted.org/packages/71/e6/5fd43993a87db364ec60fc1d608273a1a465c0caba69176dd160e197ce42/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f29d80eb9a9263b8d109135351caf568cc3f80b9928bccde535c235de55c22d9", size = 174207, upload-time = "2025-03-05T20:03:23.221Z" },
{ url = "https://files.pythonhosted.org/packages/2b/fb/c492d6daa5ec067c2988ac80c61359ace5c4c674c532985ac5a123436cec/websockets-15.0.1-pp310-pypy310_pp73-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b359ed09954d7c18bbc1680f380c7301f92c60bf924171629c5db97febb12f04", size = 174155, upload-time = "2025-03-05T20:03:25.321Z" },
{ url = "https://files.pythonhosted.org/packages/68/a1/dcb68430b1d00b698ae7a7e0194433bce4f07ded185f0ee5fb21e2a2e91e/websockets-15.0.1-pp310-pypy310_pp73-win_amd64.whl", hash = "sha256:cad21560da69f4ce7658ca2cb83138fb4cf695a2ba3e475e0559e05991aa8122", size = 176884, upload-time = "2025-03-05T20:03:27.934Z" },
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
]
[[package]]