Compare commits

...
67 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
praison a72e156c4d Release v4.6.40 2026-05-19 07:23:30 +01:00
Mervin Praison 24385d6487 Merge pull request #1686 from MervinPraison/security/batch-12-remaining
refactor: security batch 3 — platform service scoping (do not auto-merge)
2026-05-19 07:21:49 +01:00
praisonai-triage-agent[bot]andMervin Praison 2f8162207f security: fix workspace scoping gaps in issue service
- Add workspace_id parameter to assign() and transition() methods
- Fix SECURITY_TRIAGE.md duplicate entries and formatting
- Ensures cross-tenant mutations are prevented

Co-authored-by: Mervin Praison <[email protected]>
2026-05-19 06:07:52 +00:00
praisonandCursor 516d8b6d88 refactor: enforce workspace scope in platform services
Add workspace_id checks to issue, project, and agent services;
restrict admin/owner role assignment to owners; default platform bind
to 127.0.0.1; scope dependency deletes to parent issue.

Co-authored-by: Cursor <[email protected]>
2026-05-19 07:01:41 +01:00
praisonandCursor 7eb98e895b chore: add security release and advisory publish scripts
Co-authored-by: Cursor <[email protected]>
2026-05-19 06:57:05 +01:00
Mervin Praison ef79b7a056 Merge pull request #1685 from MervinPraison/security/batch-11-hardening
refactor: security batch 2 — platform IDOR and deploy hardening
2026-05-19 06:56:40 +01:00
praisonandCursor 3ea8376610 refactor: harden platform scoping and deploy output sanitisation
Extend workspace resource checks to projects, labels, dependencies, and
activity; sanitise legacy deploy HTML output; harden example eval paths;
fix workspace delete membership cleanup; add security regression tests.

Co-authored-by: Cursor <[email protected]>
2026-05-19 06:56:26 +01:00
MervinPraison 6360b24c06 docs: auto-update feature parity trackers [skip ci] 2026-05-19 05:54:22 +00:00
Mervin Praison b0d8f77752 Merge pull request #1684 from MervinPraison/security/batch-10-hardening
refactor: security batch — 10 hardening fixes
2026-05-19 06:53:57 +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
praisonandCursor 179cab02db refactor: harden input validation and access controls
Batch security hardening across agents, wrapper, and platform:
sandbox AST checks, SSRF URL validation, MCP path containment,
fail-closed call-server auth, write_file workspace bounds, platform
IDOR scoping, workspace RBAC, GHA branch quoting, and JWT issuance guard.

Co-authored-by: Cursor <[email protected]>
2026-05-19 06:34:42 +01:00
praison 402d7ed9fc Release v4.6.39 2026-05-19 06:28:39 +01:00
701e8e6a6a fix: wrapper layer architectural fixes for concurrency safety (#1680) (#1681)
* fix: wrapper layer architectural fixes for concurrency safety (#1680)

- Replace _run_praisonai event loop creation with _async_bridge.run_sync to prevent deadlocks in nested async contexts
- Remove module-level OpenAI client LRU cache that evicts in-use clients; move to per-instance ownership in BaseAutoGenerator
- Replace inbuilt_tools custom lazy-import scaffolding with existing _load_optional pattern to eliminate DRY violations

Fixes #1680

Co-authored-by: MervinPraison <[email protected]>

* test: align lazy-loading tests with per-instance OpenAI client ownership

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/682c9dfc-e131-45a2-816e-05044bc4a04a

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback on concurrency and error handling

- Fix race condition in OpenAI client initialization/teardown with proper locking
- Fix AttributeError in __del__ method for partially initialized objects
- Fix RuntimeError swallowing in InteractiveRuntime exception handling to preserve fail-fast semantics
- Fix PRAISONAI_TOOLS_AVAILABLE coupling to allow tools with just praisonai_tools package
- Add missing trailing newline to inbuilt_tools/__init__.py

Addresses feedback from CodeRabbit, Greptile, and Copilot code reviews.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-18 16:35:04 +01:00
MervinPraison ef09341cc0 docs: auto-update feature parity trackers [skip ci] 2026-05-18 15:34:14 +00:00
Jonathan Hill 3a4d7e11c8 fix: guard against empty/filtered LLM responses in core LLM paths (#1682)
Add null checks before accessing choices[0].message in four files:
- openai_client.py: sync/async tool-call paths and responses-API path
- guardrails.py: check/aguard result extraction
- rag.py: query_rag and aquery_rag answer extraction
- code_agent.py: generate/review/explain/refactor/fix methods
2026-05-18 16:33:32 +01: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
92644518c0 fix: wrapper layer architectural fixes for concurrency safety (#1673)
* fix: wrapper layer architectural fixes for concurrency safety

- Fix InMemoryJobStore: Add locks to all read methods to prevent inconsistent snapshots under concurrency
- Fix scheduler retry: Replace uninterruptible time.sleep with Event.wait for responsive stop()
- Fix ToolRegistry: Add threading.Lock synchronization to match other registries

Fixes #1672

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback - dead code, missing lock, unused import, tests

- Remove broken try-except in _get_lock() (loop var was unused, second call
  would have re-raised the same RuntimeError) - per Greptile P2 inline comment
- Protect get_stats() with list() snapshot to prevent RuntimeError on concurrent
  dict mutation - per Greptile P1 finding
- Remove unused `import time` from agent_scheduler.py (time.sleep was replaced
  with Event.wait() in the PR changes)
- Add newline at end of tool_registry.py
- Fix existing retry tests that patched time.sleep (now gone) to mock
  _stop_event.wait() instead; add two new tests that verify Event.wait behavior
- Add TestToolRegistryThreadSafety tests for concurrent register/read/clear
- Add TestInMemoryJobStoreConcurrency tests for concurrent async saves/reads
  and get_stats() snapshot correctness

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/82f4b33b-1b67-4451-be2c-4f53c57bdcf1

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback - dead code, missing lock, unused import, tests

Fixes identified by Greptile, CodeRabbit, and Copilot reviewers:

1. InMemoryJobStore.get_stats() - Added missing async lock protection
   - Method now takes snapshot under lock to prevent concurrent mutation
   - Made get_stats() async to match other store methods

2. Simplified _get_lock() implementation
   - Removed dead try-except code that had unused 'loop' variable
   - Kept lazy initialization pattern for Python 3.9 compatibility

3. Validated ToolRegistry threading safety
   - Confirmed threading.Lock is correct (not asyncio.Lock)
   - ToolRegistry is used in sync contexts, not async
   - Thread safety tests pass

All syntax checks and focused tests pass.

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

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-17 04:17:48 +01: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
cd54574b20 fix: Resolve architectural gaps in registry fragmentation and singleton patterns (#1675)
* fix: Resolve architectural gaps in registry fragmentation and singleton patterns

This commit addresses three critical architectural violations identified in issue #1674:

**Gap 1 - Registry Consolidation**: Unified four different registry patterns under canonical PluginRegistry:
- Enhanced PluginRegistry with alias support for consistent naming
- Migrated LLMProviderRegistry from class-level singleton to PluginRegistry inheritance
- Converted cli_backends from module-level dict to PluginRegistry pattern
- Refactored StoreRegistry to inherit from PluginRegistry instead of duplicating logic

**Gap 2 - Singleton Elimination**: Removed process-global singletons for multi-agent safety:
- Converted Profiler from __new__ singleton to per-instance with context-aware fallback
- Replaced LLMProviderRegistry._instance with module-level default pattern
- Added create_exec_approval_manager() for per-agent ExecApprovalManager instances
- Refactored telemetry init to avoid process-wide os.environ mutation

**Gap 3 - Async Surface**: Added complete async interface to PraisonAIDB adapter:
- Implemented aon_* async versions of all lifecycle hooks (aon_user_message, etc.)
- Added async context manager support (__aenter__, __aexit__, aclose)
- Graceful fallback: async methods detect store capabilities and use asyncio.to_thread for sync stores

All changes maintain backward compatibility while enabling proper multi-agent isolation and async safety.

Fixes #1674

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

Co-authored-by: Claude <[email protected]>

* fix: Resolve Profiler compatibility issue with per-instance delegation pattern

- Renamed Profiler class to _ProfilerImpl to avoid circular reference
- Created ProfilerCompat delegation wrapper for backward compatibility
- Added context-aware profiler support via get_profiler()
- Fixed all decorators and class-level API calls to work correctly
- Maintains full backward compatibility while enabling per-agent isolation

Resolves critical compatibility issue identified by Copilot review

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-17 04:16:33 +01: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
68035af76d fix(tests): BotOS integration tests use registry unregister (#1669)
* fix(tests): unregister mock bot platforms via BotPlatformRegistry

Co-authored-by: Cursor <[email protected]>

* fix(tests): add exception handling to _unregister_bot_platform helper

Prevents cleanup failures from masking primary test errors by catching
KeyError/ValueError exceptions during platform unregistration.

Addresses Gemini reviewer feedback.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-15 07:49:31 +01: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
MervinPraison c989f2c31e docs: auto-update feature parity trackers [skip ci] 2026-05-14 18:16:52 +00:00
a2c51a0760 fix(praisonaiagents): export execute_code via lazy tools loader (#1666)
* fix(tools): lazy-export execute_code as module function

Co-authored-by: Cursor <[email protected]>

* test(tools): add regression test for execute_code lazy export

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a9d10f35-24e5-4e53-a064-043ecb6a0b08

Co-authored-by: MervinPraison <[email protected]>

* fix(tools): extend lazy-export pattern to all python code tools

Maps analyze_code, format_code, lint_code, disassemble_code, and python_tools
to direct function imports (None) instead of PythonTools class instantiation.
This ensures consistent lazy loading, fixes module alias imports, and avoids
unnecessary dependency checks until functions are actually called.

Addresses reviewer feedback for architectural consistency.

Co-authored-by: Mervin Praison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:16:21 +01:00
7a601b5373 fix(tests): align framework and datastore mocks with current APIs (#1665)
* fix(tests): patch get_default_registry and session store loader

Co-authored-by: Cursor <[email protected]>

* test: scope datastore impl cache reset via patch.object

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/2a49bd60-577c-45b9-8236-3ee18f853a01

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-14 19:16:16 +01:00
12f430e29c fix(tests): preserve provider API keys from the environment (#1664)
* fix(tests): only set placeholder API keys when unset

Co-authored-by: Cursor <[email protected]>

* test: add focused coverage for setup_test_environment fixture

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* test: dedupe provider key list in fixture coverage

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* fix(tests): use existence check instead of truthiness for env vars

Addresses issue identified by all reviewers: the previous check
'if not os.environ.get(key)' treated empty strings as missing and
would overwrite them, inconsistent with the intent to preserve
any pre-existing environment variable values.

Now uses 'if key not in os.environ' which only sets placeholders
when the key is truly absent, aligning with the teardown logic.

Co-authored-by: Mervin Praison <[email protected]>

* test: avoid hardcoded fixture test path default

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

* test: clarify fixture helper naming in env setup tests

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/0bafd91e-1df1-4422-8ac1-e4a0a8f8ee8c

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:15:51 +01:00
c3c78fbc7b docs: verify pytest failure categories (#1663)
* docs: add verification notes for pytest failure categories

Co-authored-by: Cursor <[email protected]>

* docs: fix verification command path assumptions

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/8421f477-fa95-4465-a7b1-0c1c6da79fa2

Co-authored-by: MervinPraison <[email protected]>

* fix: address reviewer feedback on pytest verification guide

- Add missing executable verification commands for sections 4 & 5
- Fix conftest.py to only set placeholder API keys when unset/empty
- Align implementation with documented behavior for mixed testing

Co-authored-by: Mervin Praison <[email protected]>

* Delete docs/testing/VERIFY_TEST_FAILURE_CATEGORIES.md

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-14 19:14:43 +01:00
bf120c8dca fix(tests): bot _BUILTIN_PLATFORMS uses lazy loaders (#1667)
* fix(tests): expect lazy loader callables in bot registry

Co-authored-by: Cursor <[email protected]>

* fix(tests): improve bot registry tests with identity checks

- Replace weak __name__ assertions with robust identity checks
- Use 'is' operator instead of name comparison for loader validation
- Addresses Gemini Code Assist feedback for more reliable tests

Co-authored-by: Mervin Praison <[email protected]>

* test(bots): split loader-shape and adapter-identity assertions

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/b57884c2-8621-4f00-94e1-85eeb82a1cd5

Co-authored-by: MervinPraison <[email protected]>

---------

Co-authored-by: praison <[email protected]>
Co-authored-by: Cursor <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-14 19:12:37 +01:00
praisonai-triage-agent[bot]andMervinPraison 13d073b02a fix: move scripts folder from root to appropriate location (#1661)
* fix: move scripts folder from root to appropriate location (fixes #1659)

- Move all smoke test scripts from /scripts to src/praisonai/tests/integration/
- Update documentation in moved files to reflect new paths
- Per AGENTS.md architecture, these bot/BotOS integration tests belong in praisonai wrapper tests
- Preserves git history with git mv command

Co-authored-by: MervinPraison <[email protected]>

* fix: add __test__ = False to smoke test files to prevent pytest collection

- Prevents accidental pytest discovery in environments with different collection rules
- Follows Python testing best practices for non-test files in test directories
- Addresses Copilot's review feedback for explicit test exclusion

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
2026-05-13 10:33:57 +01:00
160 changed files with 10299 additions and 5427 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
+5 -2
View File
@@ -181,12 +181,15 @@ jobs:
- name: Fetch PR branch and Setup Remote
if: github.event.issue.pull_request
env:
PR_BRANCH: ${{ steps.check_fork.outputs.pr_branch }}
IS_FORK: ${{ steps.check_fork.outputs.is_fork }}
run: |
# Fetch PR head from base repo and put it into local branch
git fetch origin pull/${{ github.event.issue.number }}/head:${{ steps.check_fork.outputs.pr_branch }}
git fetch origin pull/${{ github.event.issue.number }}/head:"${PR_BRANCH}"
# If it's a fork, make origin point to local to trick claude-code-action's `git fetch origin <branch>`
if [ "${{ steps.check_fork.outputs.is_fork }}" == "true" ]; then
if [ "${IS_FORK}" = "true" ]; then
git remote set-url origin file://$(pwd)
fi
+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 != ''
+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.38" \
"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.38" \
"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.38" \
"praisonai>=4.6.43" \
"praisonai[ui]" \
"praisonai[crewai]"
@@ -23,12 +23,39 @@ print(f"[2] Updated to v{managed.agent_version}")
result = agent.start("What is 123 + 456?", stream=True)
# ── 3. Custom tool ──
def _safe_calc(expr: str) -> str:
import ast
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expr):
return "error"
try:
tree = ast.parse(expr, mode="eval")
for node in ast.walk(tree):
if not isinstance(
node,
(
ast.Expression,
ast.BinOp,
ast.UnaryOp,
ast.Constant,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.USub,
ast.UAdd,
),
):
return "error"
val = eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
return str(val)
except Exception:
return "error"
def handle_calculator(tool_name, tool_input):
expr = tool_input.get("expression", "0")
try:
val = eval(expr, {"__builtins__": {}})
except Exception:
val = "error"
val = _safe_calc(str(expr))
print(f" [Calculator: {expr} = {val}]")
return str(val)
+26 -5
View File
@@ -44,12 +44,33 @@ def main():
def calculate(expression: str) -> str:
"""Calculate a math expression safely."""
try:
# Safe eval for basic math
allowed = set("0123456789+-*/.() ")
if all(c in allowed for c in expression):
return str(eval(expression))
import ast
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return "Error: Invalid expression"
try:
tree = ast.parse(expression, mode="eval")
for node in ast.walk(tree):
if not isinstance(
node,
(
ast.Expression,
ast.BinOp,
ast.UnaryOp,
ast.Constant,
ast.Add,
ast.Sub,
ast.Mult,
ast.Div,
ast.USub,
ast.UAdd,
),
):
return "Error: Invalid expression"
return str(
eval(compile(tree, "<expr>", "eval"), {"__builtins__": {}})
)
except Exception as e:
return f"Error: {e}"
@@ -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:
@@ -164,7 +164,8 @@ Follow best practices and coding standards."""
messages=messages,
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
code = response.choices[0].message.content
self._log("Code generated successfully", "green")
return code
@@ -204,7 +205,8 @@ Follow best practices and coding standards."""
messages=messages,
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
code = response.choices[0].message.content
self._log("Code generated successfully", "green")
return code
@@ -365,6 +367,8 @@ Provide clear, actionable feedback."""
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
review = response.choices[0].message.content
self._log("Review completed", "green")
return review
@@ -405,6 +409,8 @@ Include:
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
explanation = response.choices[0].message.content
self._log("Explanation completed", "green")
return explanation
@@ -451,6 +457,8 @@ Only output the refactored code."""
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
refactored = response.choices[0].message.content
self._log("Refactoring completed", "green")
return refactored
@@ -495,6 +503,8 @@ Ensure the fix:
**{k: v for k, v in kwargs.items() if k != "model"}
)
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
fixed = response.choices[0].message.content
self._log("Fix completed", "green")
return fixed
@@ -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"
@@ -907,6 +907,8 @@ class OpenAIClient:
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
@@ -1167,6 +1169,8 @@ class OpenAIClient:
final_response = self._responses_to_chat_completion(raw)
# Display the response text if display_fn provided
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
response_text = final_response.choices[0].message.content or ""
if display_fn and response_text:
from rich.markdown import Markdown
@@ -1638,8 +1642,10 @@ class OpenAIClient:
)
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
@@ -1835,10 +1841,12 @@ class OpenAIClient:
if not final_response:
return None
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
# Emit llm_content for intermediate narrative display
# (gpt-4.1+ models produce text alongside tool calls)
response_content = getattr(final_response.choices[0].message, 'content', None)
@@ -2037,6 +2045,8 @@ class OpenAIClient:
return
# Check for tool calls
if not final_response.choices or final_response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
tool_calls = getattr(final_response.choices[0].message, 'tool_calls', None)
if tool_calls and execute_tool_fn:
@@ -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
@@ -75,12 +75,12 @@ TOOL_MAPPINGS = {
# Python Tools
'execute_code': ('.python_tools', 'PythonTools'),
'analyze_code': ('.python_tools', 'PythonTools'),
'format_code': ('.python_tools', 'PythonTools'),
'lint_code': ('.python_tools', 'PythonTools'),
'disassemble_code': ('.python_tools', 'PythonTools'),
'python_tools': ('.python_tools', 'PythonTools'),
'execute_code': ('.python_tools', None),
'analyze_code': ('.python_tools', None),
'format_code': ('.python_tools', None),
'lint_code': ('.python_tools', None),
'disassemble_code': ('.python_tools', None),
'python_tools': ('.python_tools', None),
# Chain of Thought Training Tools
@@ -326,7 +326,7 @@ def __getattr__(name: str) -> Any:
if name in [
'duckduckgo', 'internet_search', 'searxng_search', 'searxng',
'scrape_page', 'extract_links', 'crawl', 'extract_text',
'execute_command', 'list_processes', 'kill_process', 'get_system_info',
'execute_command', 'execute_code', 'analyze_code', 'format_code', 'lint_code', 'disassemble_code', 'list_processes', 'kill_process', 'get_system_info',
'tavily', 'tavily_search', 'tavily_extract', 'tavily_crawl', 'tavily_map',
'tavily_search_async', 'tavily_extract_async',
'ydc', 'ydc_search', 'ydc_contents', 'ydc_news', 'ydc_images',
@@ -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,
}
@@ -268,6 +268,11 @@ class MentionsParser:
def _process_url_mention(self, url: str) -> Optional[str]:
"""Process @url:https://... mention."""
try:
from praisonaiagents.tools.spider_tools import SpiderTools
if not SpiderTools()._validate_url(url):
return f"# URL: {url}\n[Blocked: URL is not allowed]"
import urllib.request
req = urllib.request.Request(
@@ -36,6 +36,30 @@ def _safe_getattr(obj, name, *default):
return getattr(obj, name, *default) if default else getattr(obj, name)
_SANDBOX_BLOCKED_ATTRS = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
'__self__', # C builtins leak real builtins module (GHSA-4mr5-g6f9-cfrh)
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
'__dir__', '__get__', '__set__', '__delete__',
})
_SANDBOX_BLOCKED_CALLS = frozenset({
'exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir', 'vars',
})
def _validate_code_ast(code: str):
"""Validate code using AST — catches attacks that bypass text checks.
@@ -48,23 +72,6 @@ def _validate_code_ast(code: str):
except SyntaxError:
return None # let compile() handle syntax errors later
# Dangerous dunder attributes attackers use for sandbox escape
_blocked_attrs = frozenset({
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
'__dir__', '__get__', '__set__', '__delete__',
})
for node in ast.walk(tree):
# Block import statements
if isinstance(node, (ast.Import, ast.ImportFrom)):
@@ -72,28 +79,26 @@ def _validate_code_ast(code: str):
# Block attribute access to dangerous dunders
if isinstance(node, ast.Attribute):
if node.attr in _blocked_attrs:
if node.attr in _SANDBOX_BLOCKED_ATTRS:
return (
f"Access to attribute '{node.attr}' is restricted"
)
# Block calls to dangerous builtins by name
# Block calls to dangerous builtins (bare name or attribute access)
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in (
'exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir',
):
if isinstance(func, ast.Name) and func.id in _SANDBOX_BLOCKED_CALLS:
return f"Call to '{func.id}' is not allowed"
if isinstance(func, ast.Attribute) and func.attr in _SANDBOX_BLOCKED_CALLS:
return f"Call to '{func.attr}' is not allowed"
# Block dangerous constants (strings containing dunders)
# Fallback for Python 3.7 ast.Str
if isinstance(node, ast.Constant) and isinstance(node.value, str):
if any(attr in node.value for attr in _blocked_attrs):
if any(attr in node.value for attr in _SANDBOX_BLOCKED_ATTRS):
return f"String constant contains restricted attribute name"
elif type(node).__name__ == 'Str':
if any(attr in getattr(node, 's', '') for attr in _blocked_attrs):
if any(attr in getattr(node, 's', '') for attr in _SANDBOX_BLOCKED_ATTRS):
return f"String constant contains restricted attribute name"
return None
@@ -112,7 +117,16 @@ def _execute_code_sandboxed(
"""
if limits is None:
limits = ResourceLimits.minimal()
ast_error = _validate_code_ast(code)
if ast_error:
return {
'result': None,
'stdout': '',
'stderr': f'Security Error: {ast_error}',
'success': False,
}
try:
# Create temporary file for the code
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
@@ -151,21 +165,8 @@ def safe_execute():
}}
# Block dangerous patterns
blocked_attrs = {{
'__subclasses__', '__bases__', '__mro__', '__globals__',
'__code__', '__class__', '__dict__', '__builtins__',
'__import__', '__loader__', '__spec__', '__init_subclass__',
'__set_name__', '__reduce__', '__reduce_ex__',
'__traceback__', '__qualname__', '__module__',
'__wrapped__', '__closure__', '__annotations__',
# Frame/code object introspection
'gi_frame', 'gi_code', 'cr_frame', 'cr_code',
'ag_frame', 'ag_code', 'tb_frame', 'tb_next',
'f_globals', 'f_locals', 'f_builtins', 'f_code',
'co_consts', 'co_names',
'__getattribute__', '__getattr__', '__setattr__', '__delattr__',
'__dir__', '__get__', '__set__', '__delete__',
}}
blocked_attrs = {set(_SANDBOX_BLOCKED_ATTRS)!r}
blocked_calls = {set(_SANDBOX_BLOCKED_CALLS)!r}
for node in ast.walk(tree):
if isinstance(node, (ast.Import, ast.ImportFrom)):
@@ -182,14 +183,20 @@ def safe_execute():
"stderr": f"Access to attribute '{{node.attr}}' is restricted",
"success": False
}}
if isinstance(node, ast.Call) and isinstance(node.func, ast.Name):
if node.func.id in ('exec', 'eval', 'compile', '__import__',
'open', 'input', 'breakpoint',
'setattr', 'delattr', 'dir'):
if isinstance(node, ast.Call):
func = node.func
if isinstance(func, ast.Name) and func.id in blocked_calls:
return {{
"result": None,
"stdout": "",
"stderr": f"Call to '{{node.func.id}}' is not allowed",
"stderr": f"Call to '{{func.id}}' is not allowed",
"success": False
}}
if isinstance(func, ast.Attribute) and func.attr in blocked_calls:
return {{
"result": None,
"stdout": "",
"stderr": f"Call to '{{func.attr}}' is not allowed",
"success": False
}}
if isinstance(node, ast.Constant) and isinstance(node.value, str):
@@ -481,7 +488,7 @@ def _execute_code_direct(
'__import__', 'import ', 'from ', 'exec', 'eval',
'compile', 'open(', 'file(', 'input(', 'raw_input',
'__subclasses__', '__bases__', '__globals__', '__code__',
'__class__', 'globals(', 'locals(', 'vars('
'__class__', '__self__', 'globals(', 'locals(', 'vars('
]
code_lower = code.lower()
@@ -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)
@@ -11,6 +11,8 @@ content = scrape_page("https://example.com")
"""
import logging
import ipaddress
import socket
from typing import List, Dict, Union, Optional, Any
from importlib import util
import json
@@ -20,6 +22,48 @@ import os
import hashlib
import time
def _host_is_blocked(hostname: str) -> bool:
"""Return True when hostname resolves to loopback/private/internal targets."""
if not hostname:
return True
host = hostname.lower().rstrip(".")
if host in ("localhost", "0.0.0.0", "::1") or host.endswith(".localhost"):
return True
if host in ("169.254.169.254", "metadata.google.internal"):
return True
if any(host.endswith(suffix) for suffix in (".local", ".internal", ".localdomain")):
return True
def _ip_blocked(ip: ipaddress._BaseAddress) -> bool:
return bool(
ip.is_private or ip.is_loopback or ip.is_reserved or ip.is_link_local
)
if host.isdigit():
try:
return _ip_blocked(ipaddress.ip_address(int(host)))
except (ValueError, OverflowError):
return True
if host.startswith("0x"):
try:
return _ip_blocked(ipaddress.ip_address(int(host, 16)))
except (ValueError, OverflowError):
return True
try:
return _ip_blocked(ipaddress.ip_address(host))
except ValueError:
pass
try:
return _ip_blocked(ipaddress.ip_address(socket.inet_aton(host)))
except OSError:
pass
return False
class SpiderTools:
"""Tools for web scraping and crawling."""
@@ -59,31 +103,9 @@ class SpiderTools:
if not parsed.hostname:
return False
# Reject local/internal addresses
hostname = parsed.hostname.lower()
# Block localhost and loopback
if hostname in ['localhost', '127.0.0.1', '0.0.0.0', '::1']:
if _host_is_blocked(parsed.hostname):
return False
# Block private IP ranges
import ipaddress
try:
ip = ipaddress.ip_address(hostname)
if ip.is_private or ip.is_reserved or ip.is_loopback or ip.is_link_local:
return False
except ValueError:
# Not an IP address, continue with domain validation
pass
# Block common internal domains
if any(hostname.endswith(domain) for domain in ['.local', '.internal', '.localdomain']):
return False
# Block metadata service endpoints
if hostname in ['169.254.169.254', 'metadata.google.internal']:
return False
return True
except Exception:
@@ -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.38"
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
@@ -21,6 +21,16 @@ import pytest
from unittest.mock import MagicMock
def _unregister_bot_platform(name: str) -> None:
"""Remove a runtime-registered bot platform (tests only)."""
from praisonai.bots._registry import get_default_bot_registry
try:
get_default_bot_registry().unregister(name.lower())
except (KeyError, ValueError):
pass
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
# Helper: Mock adapter that simulates a platform bot
# ━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
@@ -59,9 +69,10 @@ class TestBotWithMockAdapter:
"""Integration tests for Bot class with a mock platform adapter."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -148,10 +159,14 @@ class TestBotOSLifecycle:
"""Integration tests for BotOS multi-bot orchestration."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mock1", MockPlatformAdapter)
register_platform("mock2", MockPlatformAdapter)
self._cleanup = lambda: (_custom_platforms.pop("mock1", None), _custom_platforms.pop("mock2", None))
self._cleanup = lambda: (
_unregister_bot_platform("mock1"),
_unregister_bot_platform("mock2"),
)
def teardown_method(self):
self._cleanup()
@@ -212,9 +227,10 @@ class TestRealAgentInBot:
"""Tests with a real praisonaiagents.Agent inside Bot."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -246,9 +262,10 @@ class TestAgentTeamInBotOS:
"""AgentTeam (multi-agent) inside Bot and BotOS."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -300,9 +317,10 @@ class TestAgentFlowInBotOS:
"""AgentFlow (deterministic workflow) inside Bot and BotOS."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -355,9 +373,8 @@ class TestPlatformExtensibility:
"""Third-party platform registration and usage."""
def teardown_method(self):
from praisonai.bots._registry import _custom_platforms
_custom_platforms.pop("custom_chat", None)
_custom_platforms.pop("my_platform", None)
_unregister_bot_platform("custom_chat")
_unregister_bot_platform("my_platform")
def test_register_and_use_custom_platform(self):
from praisonai.bots._registry import register_platform, resolve_adapter
@@ -389,7 +406,7 @@ class TestPlatformExtensibility:
def test_resolve_unknown_platform_raises(self):
from praisonai.bots._registry import resolve_adapter
with pytest.raises(ValueError, match="Unknown platform"):
with pytest.raises(ValueError, match=r"Unknown .*plugin"):
resolve_adapter("nonexistent_xyz")
@@ -401,9 +418,10 @@ class TestBotOSFromConfig:
"""BotOS.from_config() YAML config loading."""
def setup_method(self):
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mockbot", MockPlatformAdapter)
self._cleanup = lambda: _custom_platforms.pop("mockbot", None)
self._cleanup = lambda: _unregister_bot_platform("mockbot")
def teardown_method(self):
self._cleanup()
@@ -459,7 +477,8 @@ platforms:
def test_from_config_multi_platform(self):
from praisonai.bots import BotOS
from praisonai.bots._registry import register_platform, _custom_platforms
from praisonai.bots._registry import register_platform
register_platform("mock2", MockPlatformAdapter)
yaml_content = """\
@@ -484,7 +503,7 @@ platforms:
assert "mock2" in botos.list_bots()
finally:
os.unlink(path)
_custom_platforms.pop("mock2", None)
_unregister_bot_platform("mock2")
def test_from_config_invalid_raises(self):
from praisonai.bots import BotOS
@@ -0,0 +1,10 @@
"""@url mentions must not fetch loopback targets."""
from praisonaiagents.tools.mentions import MentionsParser
def test_url_mention_blocks_loopback():
parser = MentionsParser()
result = parser._process_url_mention("http://127.0.0.1:8765/")
assert result is not None
assert "Blocked" in result
@@ -361,3 +361,11 @@ class TestProfileExports:
from praisonaiagents.tools import AUTONOMY_PROFILE
assert AUTONOMY_PROFILE is not None
def test_import_execute_code_uses_module_function(self):
"""execute_code should resolve to python_tools.execute_code function."""
from praisonaiagents.tools import execute_code
from praisonaiagents.tools.python_tools import execute_code as execute_code_impl
assert callable(execute_code)
assert execute_code is execute_code_impl
@@ -112,6 +112,22 @@ class TestSandboxEscapePrevention:
result = sandbox.run("__import__('os')")
assert result["success"] is False
def test_print_self_blocked(self, sandbox):
"""print.__self__ leaks real builtins module (GHSA-4mr5-g6f9-cfrh)."""
result = sandbox.run("b = print.__self__")
assert result["success"] is False
assert "restricted" in result["stderr"].lower()
def test_vars_call_blocked(self, sandbox):
"""vars() can expose builtins.__dict__ after __self__ leak."""
result = sandbox.run("vars({})")
assert result["success"] is False
def test_attribute_dunder_call_blocked(self, sandbox):
"""Attribute calls bypass bare-name Call checks."""
result = sandbox.run("(1).__class__.__mro__")
assert result["success"] is False
# ── Legitimate Code (all must PASS) ─────────────────────────────────────────
@@ -38,6 +38,16 @@ def test_still_blocks_loopback():
assert spider._validate_url("http://localhost/") is False
def test_blocks_alternate_loopback_encodings():
"""GHSA-5c6w-wwfq-7qqm: non-canonical loopback host forms."""
spider = SpiderTools()
assert spider._validate_url("http://localhost.:8765/") is False
assert spider._validate_url("http://127.1:8765/") is False
assert spider._validate_url("http://0177.0.0.1:8765/") is False
assert spider._validate_url("http://0x7f000001:8765/") is False
assert spider._validate_url("http://2130706433:8765/") is False
def test_rejects_non_string_input():
spider = SpiderTools()
assert spider._validate_url(None) is False # type: ignore[arg-type]
+628 -299
View File
File diff suppressed because it is too large. Load diff
@@ -7,12 +7,18 @@ Usage::
"""
import argparse
import os
import sys
def main() -> None:
default_host = os.environ.get("PLATFORM_HOST", "127.0.0.1")
parser = argparse.ArgumentParser(description="PraisonAI Platform Server")
parser.add_argument("--host", default="0.0.0.0", help="Bind host (default: 0.0.0.0)")
parser.add_argument(
"--host",
default=default_host,
help="Bind host (default: 127.0.0.1, or PLATFORM_HOST env)",
)
parser.add_argument("--port", type=int, default=8000, help="Bind port (default: 8000)")
parser.add_argument("--reload", action="store_true", help="Enable auto-reload for development")
args = parser.parse_args()
@@ -71,3 +71,57 @@ async def require_workspace_member(
)
user.workspace_id = workspace_id
return user
async def require_workspace_admin(
workspace_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> AuthIdentity:
"""Require admin or owner role in the workspace."""
return await require_workspace_member(
workspace_id, user, session, min_role="admin"
)
async def require_workspace_owner(
workspace_id: str,
user: AuthIdentity = Depends(get_current_user),
session: AsyncSession = Depends(get_db),
) -> AuthIdentity:
"""Require owner role in the workspace."""
return await require_workspace_member(
workspace_id, user, session, min_role="owner"
)
def ensure_resource_in_workspace(
resource_workspace_id: str | None,
workspace_id: str,
*,
label: str = "Resource",
) -> None:
"""Reject cross-workspace access (IDOR) with a generic 404."""
if resource_workspace_id != workspace_id:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail=f"{label} not found",
)
async def require_issue_in_workspace(
workspace_id: str,
issue_id: str,
session: AsyncSession,
):
"""Load an issue and verify it belongs to the URL workspace."""
from ..db.models import Issue
issue = await session.get(Issue, issue_id)
if issue is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Issue not found",
)
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
return issue
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_db, require_workspace_member
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
from ..schemas import ActivityLogResponse
from ...services.activity_service import ActivityService
@@ -38,6 +38,7 @@ async def list_issue_activity(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = ActivityService(session)
logs = await svc.list_for_issue(issue_id, limit=limit, offset=offset)
return [ActivityLogResponse.model_validate(log) for log in logs]
@@ -58,7 +58,7 @@ async def get_agent(
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
agent = await svc.get(agent_id)
agent = await svc.get(agent_id, workspace_id=workspace_id)
if agent is None:
raise HTTPException(status_code=404, detail="Agent not found")
return AgentResponse.model_validate(agent)
@@ -75,6 +75,7 @@ async def update_agent(
svc = AgentService(session)
agent = await svc.update(
agent_id,
workspace_id=workspace_id,
name=body.name,
status=body.status,
instructions=body.instructions,
@@ -95,6 +96,6 @@ async def delete_agent(
session: AsyncSession = Depends(get_db),
):
svc = AgentService(session)
deleted = await svc.delete(agent_id)
deleted = await svc.delete(agent_id, workspace_id=workspace_id)
if not deleted:
raise HTTPException(status_code=404, detail="Agent not found")
@@ -9,9 +9,9 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_db, require_workspace_member
from ..schemas import DependencyCreate, DependencyResponse
from ..deps import get_db, require_issue_in_workspace, require_workspace_member
from ...services.dependency_service import DependencyService
from ..schemas import DependencyCreate, DependencyResponse
router = APIRouter(
prefix="/workspaces/{workspace_id}/issues/{issue_id}/dependencies",
@@ -27,6 +27,10 @@ async def create_dependency(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
await require_issue_in_workspace(
workspace_id, body.depends_on_issue_id, session
)
svc = DependencyService(session)
dep = await svc.create(issue_id, body.depends_on_issue_id, body.type)
return DependencyResponse.model_validate(dep)
@@ -39,6 +43,7 @@ async def list_dependencies(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = DependencyService(session)
deps = await svc.list_for_issue(issue_id)
return [DependencyResponse.model_validate(d) for d in deps]
@@ -52,7 +57,13 @@ async def delete_dependency(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = DependencyService(session)
dep = await svc.get(dep_id)
if dep is None:
raise HTTPException(status_code=404, detail="Dependency not found")
if dep.issue_id != issue_id and dep.depends_on_issue_id != issue_id:
raise HTTPException(status_code=404, detail="Dependency not found")
deleted = await svc.delete(dep_id)
if not deleted:
raise HTTPException(status_code=404, detail="Dependency not found")
@@ -9,7 +9,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_db, require_workspace_member
from ..deps import ensure_resource_in_workspace, get_db, require_workspace_member
from ..schemas import (
CommentCreate,
CommentResponse,
@@ -87,7 +87,7 @@ async def get_issue(
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
issue = await svc.get(issue_id)
issue = await svc.get(issue_id, workspace_id=workspace_id)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
return IssueResponse.model_validate(issue)
@@ -104,6 +104,7 @@ async def update_issue(
svc = IssueService(session)
issue = await svc.update(
issue_id,
workspace_id=workspace_id,
title=body.title,
description=body.description,
status=body.status,
@@ -132,7 +133,7 @@ async def delete_issue(
session: AsyncSession = Depends(get_db),
):
svc = IssueService(session)
deleted = await svc.delete(issue_id)
deleted = await svc.delete(issue_id, workspace_id=workspace_id)
if not deleted:
raise HTTPException(status_code=404, detail="Issue not found")
@@ -148,6 +149,11 @@ async def add_comment(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
issue_svc = IssueService(session)
issue = await issue_svc.get(issue_id)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
svc = CommentService(session)
comment = await svc.create(
issue_id=issue_id,
@@ -166,6 +172,11 @@ async def list_comments(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
issue_svc = IssueService(session)
issue = await issue_svc.get(issue_id)
if issue is None:
raise HTTPException(status_code=404, detail="Issue not found")
ensure_resource_in_workspace(issue.workspace_id, workspace_id, label="Issue")
svc = CommentService(session)
comments = await svc.list_for_issue(issue_id)
return [CommentResponse.model_validate(c) for c in comments]
@@ -9,7 +9,12 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_db, require_workspace_member
from ..deps import (
ensure_resource_in_workspace,
get_db,
require_issue_in_workspace,
require_workspace_member,
)
from ..schemas import LabelCreate, LabelResponse, LabelUpdate
from ...services.label_service import LabelService
@@ -48,6 +53,10 @@ async def update_label(
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
label = await svc.update(label_id, body.name, body.color)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
@@ -62,6 +71,10 @@ async def delete_label(
session: AsyncSession = Depends(get_db),
):
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
deleted = await svc.delete(label_id)
if not deleted:
raise HTTPException(status_code=404, detail="Label not found")
@@ -78,7 +91,12 @@ async def add_label_to_issue(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
await svc.add_to_issue(issue_id, label_id)
@@ -90,7 +108,12 @@ async def remove_label_from_issue(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = LabelService(session)
label = await svc.get(label_id)
if label is None:
raise HTTPException(status_code=404, detail="Label not found")
ensure_resource_in_workspace(label.workspace_id, workspace_id, label="Label")
await svc.remove_from_issue(issue_id, label_id)
@@ -101,6 +124,7 @@ async def list_issue_labels(
user: AuthIdentity = Depends(require_workspace_member),
session: AsyncSession = Depends(get_db),
):
await require_issue_in_workspace(workspace_id, issue_id, session)
svc = LabelService(session)
labels = await svc.list_for_issue(issue_id)
return [LabelResponse.model_validate(l) for l in labels]
@@ -56,7 +56,7 @@ async def get_project(
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
project = await svc.get(project_id)
project = await svc.get(project_id, workspace_id=workspace_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
return ProjectResponse.model_validate(project)
@@ -73,6 +73,7 @@ async def update_project(
svc = ProjectService(session)
project = await svc.update(
project_id,
workspace_id=workspace_id,
title=body.title,
description=body.description,
status=body.status,
@@ -92,7 +93,7 @@ async def delete_project(
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
deleted = await svc.delete(project_id)
deleted = await svc.delete(project_id, workspace_id=workspace_id)
if not deleted:
raise HTTPException(status_code=404, detail="Project not found")
@@ -105,4 +106,7 @@ async def project_stats(
session: AsyncSession = Depends(get_db),
):
svc = ProjectService(session)
project = await svc.get(project_id, workspace_id=workspace_id)
if project is None:
raise HTTPException(status_code=404, detail="Project not found")
return await svc.get_stats(project_id)
@@ -9,7 +9,13 @@ from sqlalchemy.ext.asyncio import AsyncSession
from praisonaiagents.auth import AuthIdentity
from ..deps import get_current_user, get_db, require_workspace_member
from ..deps import (
get_current_user,
get_db,
require_workspace_admin,
require_workspace_member,
require_workspace_owner,
)
from ..schemas import (
MemberAdd,
MemberResponse,
@@ -64,7 +70,7 @@ async def get_workspace(
async def update_workspace(
workspace_id: str,
body: WorkspaceUpdate,
user: AuthIdentity = Depends(require_workspace_member),
user: AuthIdentity = Depends(require_workspace_admin),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
@@ -77,7 +83,7 @@ async def update_workspace(
@router.delete("/{workspace_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_workspace(
workspace_id: str,
user: AuthIdentity = Depends(require_workspace_member),
user: AuthIdentity = Depends(require_workspace_owner),
session: AsyncSession = Depends(get_db),
):
ws_svc = WorkspaceService(session)
@@ -93,10 +99,16 @@ async def delete_workspace(
async def add_member(
workspace_id: str,
body: MemberAdd,
user: AuthIdentity = Depends(require_workspace_member),
user: AuthIdentity = Depends(require_workspace_admin),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
if body.role in ("owner", "admin"):
if not await member_svc.has_role(workspace_id, user.id, "owner"):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can add admin or owner roles",
)
member = await member_svc.add(workspace_id, body.user_id, body.role)
return MemberResponse.model_validate(member)
@@ -117,10 +129,32 @@ async def update_member_role(
workspace_id: str,
user_id: str,
body: MemberUpdate,
user: AuthIdentity = Depends(require_workspace_member),
user: AuthIdentity = Depends(require_workspace_admin),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
target = await member_svc.get(workspace_id, user_id)
if target is None:
raise HTTPException(status_code=404, detail="Member not found")
if user_id == user.id and body.role != target.role:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot change your own role",
)
if body.role in ("owner", "admin") and not await member_svc.has_role(
workspace_id, user.id, "owner"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can assign admin or owner roles",
)
if target.role == "owner" and not await member_svc.has_role(
workspace_id, user.id, "owner"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can change an owner's role",
)
member = await member_svc.update_role(workspace_id, user_id, body.role)
if member is None:
raise HTTPException(status_code=404, detail="Member not found")
@@ -131,10 +165,25 @@ async def update_member_role(
async def remove_member(
workspace_id: str,
user_id: str,
user: AuthIdentity = Depends(require_workspace_member),
user: AuthIdentity = Depends(require_workspace_admin),
session: AsyncSession = Depends(get_db),
):
member_svc = MemberService(session)
if user_id == user.id:
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Cannot remove yourself from the workspace",
)
target = await member_svc.get(workspace_id, user_id)
if target is None:
raise HTTPException(status_code=404, detail="Member not found")
if target.role == "owner" and not await member_svc.has_role(
workspace_id, user.id, "owner"
):
raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN,
detail="Only owners can remove an owner",
)
removed = await member_svc.remove(workspace_id, user_id)
if not removed:
raise HTTPException(status_code=404, detail="Member not found")
@@ -50,9 +50,16 @@ class AgentService:
await self._session.flush()
return agent
async def get(self, agent_id: str) -> Optional[Agent]:
"""Get agent by ID."""
return await self._session.get(Agent, agent_id)
async def get(
self, agent_id: str, *, workspace_id: Optional[str] = None
) -> Optional[Agent]:
"""Get agent by ID, optionally scoped to a workspace."""
agent = await self._session.get(Agent, agent_id)
if agent is None:
return None
if workspace_id is not None and agent.workspace_id != workspace_id:
return None
return agent
async def list_for_workspace(
self,
@@ -72,6 +79,8 @@ class AgentService:
async def update(
self,
agent_id: str,
*,
workspace_id: Optional[str] = None,
name: Optional[str] = None,
status: Optional[str] = None,
instructions: Optional[str] = None,
@@ -80,7 +89,7 @@ class AgentService:
max_concurrent_tasks: Optional[int] = None,
) -> Optional[Agent]:
"""Update agent fields."""
agent = await self.get(agent_id)
agent = await self.get(agent_id, workspace_id=workspace_id)
if agent is None:
return None
if name is not None:
@@ -102,9 +111,11 @@ class AgentService:
await self._session.flush()
return agent
async def delete(self, agent_id: str) -> bool:
async def delete(
self, agent_id: str, *, workspace_id: Optional[str] = None
) -> bool:
"""Delete an agent."""
agent = await self.get(agent_id)
agent = await self.get(agent_id, workspace_id=workspace_id)
if agent is None:
return False
await self._session.delete(agent)
@@ -113,6 +113,10 @@ class AuthService:
def _issue_token(self, user: User) -> str:
"""Issue a JWT for a user."""
if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
raise RuntimeError(
"Refusing to issue JWT with default PLATFORM_JWT_SECRET outside dev"
)
now = datetime.now(timezone.utc)
payload = {
"sub": user.id,
@@ -69,9 +69,16 @@ class IssueService:
await self._session.flush()
return issue
async def get(self, issue_id: str) -> Optional[Issue]:
"""Get issue by ID."""
return await self._session.get(Issue, issue_id)
async def get(
self, issue_id: str, *, workspace_id: Optional[str] = None
) -> Optional[Issue]:
"""Get issue by ID, optionally scoped to a workspace."""
issue = await self._session.get(Issue, issue_id)
if issue is None:
return None
if workspace_id is not None and issue.workspace_id != workspace_id:
return None
return issue
async def list_for_workspace(
self,
@@ -97,6 +104,8 @@ class IssueService:
async def update(
self,
issue_id: str,
*,
workspace_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
@@ -106,7 +115,7 @@ class IssueService:
project_id: Optional[str] = None,
) -> Optional[Issue]:
"""Update issue fields."""
issue = await self.get(issue_id)
issue = await self.get(issue_id, workspace_id=workspace_id)
if issue is None:
return None
if title is not None:
@@ -135,21 +144,27 @@ class IssueService:
issue_id: str,
assignee_type: str,
assignee_id: str,
*,
workspace_id: Optional[str] = None,
) -> Optional[Issue]:
"""Assign an issue to a member or agent."""
if assignee_type not in VALID_ASSIGNEE_TYPES:
raise ValueError(f"Invalid assignee_type: {assignee_type}")
return await self.update(
issue_id, assignee_type=assignee_type, assignee_id=assignee_id
issue_id, workspace_id=workspace_id, assignee_type=assignee_type, assignee_id=assignee_id
)
async def transition(self, issue_id: str, new_status: str) -> Optional[Issue]:
async def transition(
self, issue_id: str, new_status: str, *, workspace_id: Optional[str] = None
) -> Optional[Issue]:
"""Transition an issue to a new status."""
return await self.update(issue_id, status=new_status)
return await self.update(issue_id, workspace_id=workspace_id, status=new_status)
async def delete(self, issue_id: str) -> bool:
async def delete(
self, issue_id: str, *, workspace_id: Optional[str] = None
) -> bool:
"""Delete an issue."""
issue = await self.get(issue_id)
issue = await self.get(issue_id, workspace_id=workspace_id)
if issue is None:
return False
await self._session.delete(issue)
@@ -44,9 +44,16 @@ class ProjectService:
await self._session.flush()
return project
async def get(self, project_id: str) -> Optional[Project]:
"""Get project by ID."""
return await self._session.get(Project, project_id)
async def get(
self, project_id: str, *, workspace_id: Optional[str] = None
) -> Optional[Project]:
"""Get project by ID, optionally scoped to a workspace."""
project = await self._session.get(Project, project_id)
if project is None:
return None
if workspace_id is not None and project.workspace_id != workspace_id:
return None
return project
async def list_for_workspace(
self,
@@ -62,6 +69,8 @@ class ProjectService:
async def update(
self,
project_id: str,
*,
workspace_id: Optional[str] = None,
title: Optional[str] = None,
description: Optional[str] = None,
status: Optional[str] = None,
@@ -69,7 +78,7 @@ class ProjectService:
lead_id: Optional[str] = None,
) -> Optional[Project]:
"""Update project fields."""
project = await self.get(project_id)
project = await self.get(project_id, workspace_id=workspace_id)
if project is None:
return None
if title is not None:
@@ -85,9 +94,11 @@ class ProjectService:
await self._session.flush()
return project
async def delete(self, project_id: str) -> bool:
async def delete(
self, project_id: str, *, workspace_id: Optional[str] = None
) -> bool:
"""Delete a project."""
project = await self.get(project_id)
project = await self.get(project_id, workspace_id=workspace_id)
if project is None:
return False
await self._session.delete(project)
@@ -10,7 +10,7 @@ from __future__ import annotations
import re
from typing import Optional
from sqlalchemy import select
from sqlalchemy import delete, select
from sqlalchemy.ext.asyncio import AsyncSession
from ..db.models import Member, Workspace
@@ -98,10 +98,13 @@ class WorkspaceService:
return ws
async def delete(self, workspace_id: str) -> bool:
"""Delete a workspace."""
"""Delete a workspace and its memberships."""
ws = await self.get(workspace_id)
if ws is None:
return False
await self._session.execute(
delete(Member).where(Member.workspace_id == workspace_id)
)
await self._session.delete(ws)
await self._session.flush()
return True
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonai-platform"
version = "0.1.2"
version = "0.1.4"
description = "Platform layer for PraisonAI — workspace, auth, issues, projects"
readme = "README.md"
requires-python = ">=3.10"
@@ -0,0 +1,20 @@
"""Cross-workspace IDOR guards for platform resources."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from praisonai_platform.api.deps import ensure_resource_in_workspace
def test_project_workspace_mismatch():
with pytest.raises(HTTPException) as exc:
ensure_resource_in_workspace("ws-other", "ws-mine", label="Project")
assert exc.value.status_code == 404
def test_label_workspace_mismatch():
with pytest.raises(HTTPException) as exc:
ensure_resource_in_workspace("ws-other", "ws-mine", label="Label")
assert exc.value.status_code == 404
@@ -0,0 +1,38 @@
"""Service-layer workspace scoping for issues and projects."""
from __future__ import annotations
import pytest
from praisonai_platform.services.auth_service import AuthService
from praisonai_platform.services.issue_service import IssueService
from praisonai_platform.services.project_service import ProjectService
from praisonai_platform.services.workspace_service import WorkspaceService
@pytest.mark.asyncio
async def test_issue_get_rejects_wrong_workspace(session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_a = await WorkspaceService(session).create("A", user.id)
ws_b = await WorkspaceService(session).create("B", user.id)
issue_svc = IssueService(session)
issue = await issue_svc.create(
workspace_id=ws_a.id,
title="secret",
creator_id=user.id,
)
assert await issue_svc.get(issue.id, workspace_id=ws_a.id) is not None
assert await issue_svc.get(issue.id, workspace_id=ws_b.id) is None
@pytest.mark.asyncio
async def test_project_delete_scoped_to_workspace(session):
auth = AuthService(session)
user, _ = await auth.register("[email protected]", "pass")
ws_a = await WorkspaceService(session).create("PA", user.id)
ws_b = await WorkspaceService(session).create("PB", user.id)
proj_svc = ProjectService(session)
project = await proj_svc.create(workspace_id=ws_a.id, title="p1")
assert await proj_svc.delete(project.id, workspace_id=ws_b.id) is False
assert await proj_svc.delete(project.id, workspace_id=ws_a.id) is True
@@ -0,0 +1,18 @@
"""Workspace member RBAC and cross-workspace IDOR guards."""
from __future__ import annotations
import pytest
from fastapi import HTTPException
from praisonai_platform.api.deps import ensure_resource_in_workspace
def test_ensure_resource_in_workspace_rejects_mismatch():
with pytest.raises(HTTPException) as exc:
ensure_resource_in_workspace("ws-a", "ws-b", label="Issue")
assert exc.value.status_code == 404
def test_ensure_resource_in_workspace_allows_match():
ensure_resource_in_workspace("ws-a", "ws-a", label="Issue")
+1 -1
View File
@@ -1208,7 +1208,7 @@ wheels = [
[[package]]
name = "praisonai-platform"
version = "0.1.2"
version = "0.1.4"
source = { editable = "." }
dependencies = [
{ name = "aiosqlite" },
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-13",
"lastUpdated": "2026-05-20",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
@@ -1725,6 +1725,7 @@
"planning": [],
"plugins": [
"PluginRegistry",
"ToolDefinition",
"disable_plugins",
"enable_plugins",
"get_plugin_manager",
@@ -1749,7 +1750,6 @@
"tools": [
"FunctionTool",
"Tool",
"ToolDefinition",
"ToolRegistry",
"ToolResult"
],
+1 -1
View File
@@ -1,6 +1,6 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-13",
"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-13
> **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.38.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.38.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]"
+36 -9
View File
@@ -36,10 +36,34 @@ __all__ = [
_telemetry_lock = threading.Lock()
_telemetry_initialized = False
def _get_telemetry_defaults() -> dict[str, str]:
"""Get telemetry environment defaults without mutating os.environ.
Returns:
Dict of environment variable defaults for telemetry
"""
import os
defaults = {}
# Respect any value the user already set
if "OTEL_SDK_DISABLED" not in os.environ:
langfuse_configured = bool(
os.getenv("LANGFUSE_PUBLIC_KEY")
or os.path.exists(os.path.expanduser("~/.praisonai/langfuse.env"))
)
defaults["OTEL_SDK_DISABLED"] = "false" if langfuse_configured else "true"
if "EC_TELEMETRY" not in os.environ:
defaults["EC_TELEMETRY"] = "false"
return defaults
def _ensure_telemetry_defaults() -> None:
"""Apply telemetry env defaults exactly once, on first observability use.
Thread-safe implementation using double-checked locking pattern.
DEPRECATED: Use _apply_telemetry_defaults() with explicit config instead.
"""
global _telemetry_initialized
if _telemetry_initialized: # fast path, OK without lock
@@ -47,18 +71,21 @@ def _ensure_telemetry_defaults() -> None:
with _telemetry_lock:
if _telemetry_initialized:
return
import os
# Respect any value the user already set
if "OTEL_SDK_DISABLED" not in os.environ:
langfuse_configured = bool(
os.getenv("LANGFUSE_PUBLIC_KEY")
or os.path.exists(os.path.expanduser("~/.praisonai/langfuse.env"))
)
os.environ["OTEL_SDK_DISABLED"] = "false" if langfuse_configured else "true"
os.environ.setdefault("EC_TELEMETRY", "false") # respect user overrides
_apply_telemetry_defaults(_get_telemetry_defaults())
_telemetry_initialized = True
def _apply_telemetry_defaults(env_vars: dict[str, str]) -> None:
"""Apply telemetry defaults to os.environ.
Args:
env_vars: Dict of environment variables to set
"""
import os
for key, value in env_vars.items():
os.environ.setdefault(key, value)
# Lazy loading for heavy imports
def __getattr__(name):
"""Lazy load heavy modules to improve import time."""
@@ -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)
+70 -6
View File
@@ -24,6 +24,7 @@ class PluginRegistry(Generic[T]):
- Built-in plugins with lazy loading
- Entry points discovery
- Runtime registration
- Alias support for alternative names
- Thread-safe operations
"""
@@ -41,6 +42,7 @@ class PluginRegistry(Generic[T]):
"""
self._entry_point_group = entry_point_group
self._items: Dict[str, Type[T]] = {}
self._aliases: Dict[str, str] = {} # alias -> canonical name
self._lock = threading.Lock()
# Load built-in plugins with error handling
@@ -76,15 +78,29 @@ class PluginRegistry(Generic[T]):
# entry_points() might not be available in older Python versions
logger.debug("Entry points not available for group %s", self._entry_point_group)
def register(self, name: str, cls: Type[T]) -> None:
def register(
self,
name: str,
cls: Type[T],
*,
aliases: Optional[list[str]] = None
) -> None:
"""Register a plugin at runtime.
Args:
name: Plugin name
cls: Plugin class
aliases: Optional list of alias names for this plugin
"""
with self._lock:
self._items[name.lower()] = cls
canonical_name = name.lower()
self._items[canonical_name] = cls
# Register aliases
if aliases:
for alias in aliases:
normalized_alias = alias.lower()
self._aliases[normalized_alias] = canonical_name
def unregister(self, name: str) -> bool:
"""Unregister a plugin.
@@ -96,7 +112,27 @@ class PluginRegistry(Generic[T]):
True if plugin was found and removed, False otherwise
"""
with self._lock:
return self._items.pop(name.lower(), None) is not None
normalized_name = name.lower()
# Check if it's an alias
if normalized_name in self._aliases:
del self._aliases[normalized_name]
return True
# Check if it's a canonical name
if normalized_name in self._items:
# Remove all aliases pointing to this plugin
aliases_to_remove = [
alias for alias, canonical in self._aliases.items()
if canonical == normalized_name
]
for alias in aliases_to_remove:
del self._aliases[alias]
del self._items[normalized_name]
return True
return False
def resolve(self, name: str) -> Type[T]:
"""Resolve a plugin name to its class.
@@ -111,10 +147,20 @@ class PluginRegistry(Generic[T]):
ValueError: If plugin is not found
"""
with self._lock:
cls = self._items.get(name.lower())
normalized_name = name.lower()
# Resolve alias to canonical name
canonical_name = self._aliases.get(normalized_name, normalized_name)
cls = self._items.get(canonical_name)
# Capture available plugins snapshot while holding lock
# to avoid race condition between check and error message
available_snapshot = sorted(self._items.keys()) if cls is None else None
if cls is None:
available_plugins = sorted(self._items.keys())
available_aliases = sorted(self._aliases.keys())
available_snapshot = available_plugins + available_aliases
else:
available_snapshot = None
if cls is None:
raise ValueError(
@@ -161,4 +207,22 @@ class PluginRegistry(Generic[T]):
self.resolve(name)
return True
except ValueError:
return False
return False
def list_aliases(self) -> Dict[str, str]:
"""List all aliases and their target plugins.
Returns:
Dict mapping alias -> canonical name
"""
with self._lock:
return dict(self._aliases)
def list_all_names(self) -> list[str]:
"""List all names including aliases.
Returns:
Sorted list of all registered names and aliases
"""
with self._lock:
return sorted(list(self._items.keys()) + list(self._aliases.keys()))
+31 -31
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
@@ -1172,9 +1165,9 @@ class AgentsGenerator:
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:
runtime_started = False
try:
import asyncio
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
@@ -1190,11 +1183,12 @@ class AgentsGenerator:
interactive_runtime = InteractiveRuntime(runtime_config)
self.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())
# Runs on the persistent background loop; safe from sync and async callers.
# run_sync raises RuntimeError early if called from inside a running loop
# so the bug is loud instead of a deadlock.
from ._async_bridge import run_sync
run_sync(interactive_runtime.start())
runtime_started = True
centric_tools = create_agent_centric_tools(interactive_runtime)
self.logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
@@ -1203,13 +1197,20 @@ class AgentsGenerator:
except ImportError as e:
self.logger.warning(f"Failed to load InteractiveRuntime components: {e}")
interactive_runtime = None
interactive_loop = None
except RuntimeError:
# Don't swallow RuntimeError from run_sync - preserve fail-fast semantics
raise
except Exception as e:
if runtime_started and interactive_runtime is not None:
try:
from ._async_bridge import run_sync
run_sync(interactive_runtime.stop())
except Exception as stop_error:
self.logger.error(
f"Error stopping partially started InteractiveRuntime: {stop_error}"
)
self.logger.error(f"Error starting InteractiveRuntime: {e}")
if 'interactive_loop' in locals() and interactive_loop is not None:
interactive_loop.close()
interactive_runtime = None
interactive_loop = None
# Create agents from config
for role, details in config['roles'].items():
@@ -1443,14 +1444,13 @@ class AgentsGenerator:
self.logger.debug(f"Result: {response}")
result = response if response else ""
finally:
if interactive_runtime and interactive_loop:
if interactive_runtime:
try:
self.logger.info("Stopping InteractiveRuntime...")
interactive_loop.run_until_complete(interactive_runtime.stop())
from ._async_bridge import run_sync
run_sync(interactive_runtime.stop())
except Exception as e:
self.logger.error(f"Error stopping InteractiveRuntime: {e}")
finally:
interactive_loop.close()
if AGENTOPS_AVAILABLE:
import agentops
+14 -2
View File
@@ -29,14 +29,26 @@ logger = logging.getLogger(__name__)
# Authentication
import os
CALL_SERVER_TOKEN = os.getenv('CALL_SERVER_TOKEN')
_CALL_AUTH_DISABLED = os.getenv('PRAISONAI_CALL_AUTH', '').lower() == 'disabled'
async def verify_token(
request: Request,
authorization: Optional[str] = Header(None)
) -> None:
"""Verify API token for authentication."""
if not FASTAPI_AVAILABLE or not CALL_SERVER_TOKEN:
return # No authentication if FastAPI unavailable or no token set
if not FASTAPI_AVAILABLE:
return
if _CALL_AUTH_DISABLED:
return
if not CALL_SERVER_TOKEN:
raise HTTPException(
status_code=503,
detail=(
"CALL_SERVER_TOKEN is not configured. Set CALL_SERVER_TOKEN or "
"PRAISONAI_CALL_AUTH=disabled to run without authentication."
),
)
token = None
+69 -96
View File
@@ -15,9 +15,8 @@ from typing import Any, Dict, List, Optional, Type, TypeVar
import os
import json
import yaml
from rich import print
import threading
from collections import OrderedDict
from rich import print
from praisonai._logging import get_logger
# Type variable for Pydantic models
@@ -59,17 +58,43 @@ def _load_optional(key: str, loader):
return _optional_cache[key]
# Bounded LRU cache for OpenAI clients (one per (api_key, base_url) tuple)
_OPENAI_CLIENT_CACHE_MAX = 8
_openai_clients: "OrderedDict[tuple, object]" = OrderedDict()
_openai_clients_lock = threading.Lock()
# --- 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():
@@ -81,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"))
@@ -121,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():
@@ -138,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():
@@ -177,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"))
@@ -193,44 +173,8 @@ 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
def _get_openai_client(api_key: str = None, base_url: str = None):
"""Lazy load OpenAI client with bounded LRU cache (thread-safe, multi-tenant).
Multi-tenant safe: each (api_key, base_url) tuple gets its own cached client.
Bounded by _OPENAI_CLIENT_CACHE_MAX with proper LRU eviction.
"""
key = (api_key or os.environ.get("OPENAI_API_KEY"), base_url)
with _openai_clients_lock:
# Check if client exists and update LRU position
client = _openai_clients.get(key)
if client is not None:
_openai_clients.move_to_end(key)
return client
# Create new client
try:
from openai import OpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
client = OpenAI(api_key=key[0], base_url=key[1])
_openai_clients[key] = client
# Bound the cache; close the LRU victim
if len(_openai_clients) > _OPENAI_CLIENT_CACHE_MAX:
_, victim = _openai_clients.popitem(last=False)
try:
victim.close()
except Exception:
pass # Best-effort cleanup
return client
# Use namespaced logger; root logger is configured only by the CLI
@@ -478,6 +422,38 @@ class BaseAutoGenerator:
'api_key': ep.api_key
}
]
self._openai_client = None # lazy, per-instance
self._openai_client_lock = threading.Lock()
def _get_openai_client(self):
"""Get or create the OpenAI client for this instance."""
if self._openai_client is None:
with self._openai_client_lock:
if self._openai_client is None:
try:
from openai import OpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
cfg = self.config_list[0]
self._openai_client = OpenAI(
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
base_url=cfg.get("base_url"),
)
return self._openai_client
def close(self):
"""Close the OpenAI client if it exists."""
if not hasattr(self, '_openai_client_lock'):
return # Object was never fully initialized
with self._openai_client_lock:
client = getattr(self, '_openai_client', None)
self._openai_client = None
if client is not None:
client.close()
def __del__(self):
"""Best-effort cleanup, but the canonical path is explicit close()."""
self.close()
def _structured_completion(self, response_model: Type[T], messages: List[Dict], **kwargs) -> T:
"""
@@ -514,10 +490,7 @@ class BaseAutoGenerator:
# Fallback to OpenAI SDK (uses beta.chat.completions.parse)
if _check_openai_available():
client = _get_openai_client(
api_key=self.config_list[0].get('api_key'),
base_url=self.config_list[0].get('base_url')
)
client = self._get_openai_client()
response = client.beta.chat.completions.parse(
model=model_name,
messages=messages,
@@ -978,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
@@ -992,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."""
@@ -1537,4 +1510,4 @@ Generate a workflow for: {self.topic}
with open(full_path, 'w') as f:
yaml.dump(workflow_yaml, f, default_flow_style=False, sort_keys=False)
return full_path
return full_path
@@ -86,6 +86,8 @@ Only respond with the JSON, no other text."""
)
import json
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
result_text = response.choices[0].message.content
result_data = json.loads(result_text)
@@ -160,6 +162,8 @@ Only respond with the JSON, no other text."""
)
import json
if not response.choices or response.choices[0].message is None:
raise ValueError("LLM returned empty or filtered response")
result_text = response.choices[0].message.content
result_data = json.loads(result_text)
+2 -2
View File
@@ -103,7 +103,7 @@ def rag_query(
**kwargs
)
answer = response.choices[0].message.content if response.choices else ""
answer = response.choices[0].message.content if response.choices and response.choices[0].message is not None else ""
usage = None
if hasattr(response, 'usage') and response.usage:
@@ -188,7 +188,7 @@ async def arag_query(
**kwargs
)
answer = response.choices[0].message.content if response.choices else ""
answer = response.choices[0].message.content if response.choices and response.choices[0].message is not None else ""
usage = None
if hasattr(response, 'usage') and response.usage:
@@ -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])
Loaded 100 of 160 files, more files were not shown because too many files have changed in this diff. Show more