Compare 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
praison adcfa29941 Release v4.6.38 2026-05-13 10:28:46 +01:00
7efd0075da fix: resolve architectural gaps in src/praisonai/praisonai (#1658)
* fix: resolve architectural gaps in src/praisonai/praisonai (fixes #1657)

- Fix framework adapter dispatch kwargs mismatch by adding required keyword-only arguments to AutoGenAdapter, AutoGenV4Adapter, and AG2Adapter run() methods
- Fix InteractiveRuntime lifecycle management by keeping runtime alive during agent execution instead of stopping it prematurely
- Consolidate tools.py loading paths by making ToolResolver the single source of truth and replacing duplicate loading logic in agents_generator.py
- Remove unused load_tools_from_tools_py() method
- Update ToolResolver to use _safe_loader for consistent security model

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

* fix: define tools_path in ToolResolver local loader logging

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/bee41da9-2153-41cd-9834-439aa753a848

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

* fix: resolve critical NameError and event loop leak

- Fix NameError in ToolResolver._load_local_tools() for undefined tools_path variable
- Fix event loop leak in AgentsGenerator when InteractiveRuntime startup fails
- Change autogen_v4/ag2 adapters to raise NotImplementedError instead of returning strings

🤖 Generated with Claude 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-13 10:11:11 +01:00
MervinPraison 9ec90361d6 docs: auto-update feature parity trackers [skip ci] 2026-05-13 06:04:03 +00:00
2e5540a41b fix: check agent server readiness timeout return value (fixes #1631) (#1655)
* fix: check agent server readiness timeout return value (fixes #1631)

- Add return value checking for ready_event.wait() to detect timeouts
- Log warning when server doesn't become ready within timeout period
- Make timeout configurable via PRAISONAI_SERVER_READY_TIMEOUT env var
- Add comprehensive regression tests for timeout behavior

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: address reviewer feedback - add error handling for invalid timeout env var

- Add try/catch for float() conversion to prevent ValueError crashes
- Prevent real server spawning in unit tests via Thread.start mocking
- Add test for invalid env var fallback behavior
- Preserve other env vars during testing (avoid clear=True)

Addresses feedback from Gemini, CodeRabbit, Qodo, and Copilot reviewers.

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

* fix: use monkeypatch.delenv and move import time to module level

- Replace patch.dict(os.environ, {"": ""}) in test_default_timeout_value
  with monkeypatch.delenv so the test exercises the true "env var unset"
  default path rather than the ValueError fallback path
- Move `import time` from inside test function body to module-level imports

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/5c472f85-484f-4a03-b47a-9307639ecd0a

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: praisonai-triage-agent[bot] <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-13 07:03:37 +01:00
7fcd6af4c6 fix(claw): add .env.example and document TAVILY_API_KEY requirement (#1649)
* fix(claw): add .env.example and document TAVILY_API_KEY requirement (fixes #1648)

- Add comprehensive .env.example at repository root with all required and optional environment variables
- Document TAVILY_API_KEY requirement in README.md Claw section with link to get free API key
- Fixes silent failures on first run by providing clear variable reference template
- Improves developer onboarding experience for new Claw users

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix(env): use LOGLEVEL key in root .env.example

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/7fe15070-18b9-4586-ba5e-a70c271db412

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: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-12 14:06:07 +01:00
MervinPraison 1fb71c46b4 docs: auto-update feature parity trackers [skip ci] 2026-05-12 13:05:59 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] ca46eb1297 fix: replace deprecated datetime.utcnow() with datetime.now(timezone.utc) (fixes #1647) (#1650)
- Replace datetime.utcnow() calls across multiple components
- Update imports to include timezone module
- Ensure Python 3.12+ compatibility and prevent 3.14 runtime errors
- Fixed 10 critical files including CLI, jobs, agents, memory, and context components

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-12 14:05:16 +01:00
Mervin Praisonandpraisonai-bot 422212105a fix(cli): graceful BudgetExceededError handling (wrapper-only, no Agent param bloat) (#1645)
Re-implements the CLI UX half of reverted PR #1635 without the core SDK
changes that violated AGENTS.md.

## What changed
- Add _execute_agent_with_budget_handling(agent, method_name, *args, **kwargs)
  helper on PraisonAIApp: calls agent.<method_name>(...) and catches
  BudgetExceededError to print a single-line actionable CLI message and
  exit(1) instead of leaking a raw traceback.
- Route all 17 agent.start(prompt) / agent.chat(prompt) / auto_rag.chat(prompt)
  call sites in the direct-prompt dispatch through the helper. Covers every
  display mode: silent, quiet, verbose, debug, jsonl, json, flow, editor, default.

## What is NOT changed (intentionally)
- Zero edits to praisonaiagents (core SDK).
- NO new 'max_budget=' parameter on Agent.__init__.
- Users continue to configure budgets via the already-consolidated
  execution=ExecutionConfig(max_budget=...) pattern per AGENTS.md §5.3
  'Parameter Consolidation'.

## Why wrapper-only
AGENTS.md §4.1 reserves core SDK for protocols/hooks/adapters — CLI UX
belongs in the wrapper. AGENTS.md §5.3 explicitly consolidates execution-
related knobs (max_iter, max_rpm, max_budget, rate_limiter, ...) into
ExecutionConfig precisely to avoid adding more top-level Agent params.

## Error message
Points users to the correct API:
    Budget limit exceeded: <reason>.
    Hint: set budget via execution=ExecutionConfig(max_budget=1.00) on your Agent.

Closes #1627

Co-authored-by: praisonai-bot <[email protected]>
2026-05-09 22:59:05 +01:00
Mervin Praisonandpraisonai-bot b2611e5d61 feat(examples): add scientific writing examples (CAJAL re-implementation) (#1644)
Re-implements the CAJAL / scientific-paper functionality that was reverted
from the core SDK (PR #1611 / revert #1641) as proper examples per AGENTS.md:

- examples/scientific_writing/scientific_writer.py
  Single-agent pattern: regular Agent + @tool functions, no subclass.
  Uses the CAJAL-4B HuggingFace model via litellm.

- examples/scientific_writing/multi_agent_paper.py
  Multi-agent workflow: Literature Reviewer -> Methodologist -> Writer
  composed with AgentTeam + sequential Tasks (no custom Agent classes).

- examples/scientific_writing/agents.yaml
  YAML parity for the multi-agent workflow (AGENTS.md §7 '3-way' rule).

Why this location:
- AGENTS.md §4.1: core SDK has no heavy domain implementations.
- AGENTS.md §4.4: compose specialised Agents + tools, don't subclass Agent.
- AGENTS.md §6.1: domain-specific behaviour belongs in @tool functions.
- Wrapper / praisonai-tools were alternatives; examples/ keeps it
  discoverable and avoids bloating either package.

Co-authored-by: praisonai-bot <[email protected]>
2026-05-09 22:53:51 +01:00
Mervin Praison 78f0a71e71 Revert "fix: resolve BudgetExceededError CLI traceback issues (fixes #1627) (…" (#1642)
This reverts commit 2e05f2c0f2.
2026-05-09 22:43:59 +01:00
MervinPraison e01f63dba2 docs: auto-update feature parity trackers [skip ci] 2026-05-09 21:42:35 +00:00
Mervin Praison 99638a9129 Revert "feat: Integrate CAJAL Scientific Writer Agent (#1611)" (#1641)
This reverts commit a41a8a22d6.
2026-05-09 22:42:08 +01:00
MervinPraison a410bc88bd docs: auto-update api.md [skip ci] 2026-05-09 21:40:20 +00:00
7bcdfafb4d feat(N4): inbound dead-letter queue for failed bot messages (#1600)
* feat(N4): inbound dead-letter queue for failed bot messages

Persist failed agent.chat() calls to a SQLite-backed DLQ so user
messages are never silently lost on transient LLM failures.

- praisonai/bots/_dlq.py: InboundDLQ + DLQEntry (sqlite3 stdlib only,
  WAL mode, atomic writes via per-instance threading.Lock; TTL +
  max_size eviction; async replay with success/failure callback).
- praisonai/bots/_session.py: BotSessionManager(dlq=) optional param;
  chat() now enqueues on agent.chat() exception then re-raises (so
  adapter still shows user a friendly error). Default OFF preserves
  legacy behaviour bit-for-bit.
- praisonai/bots/__init__.py: lazy exports for InboundDLQ, DLQEntry,
  BotSessionManager.
- praisonai/cli/commands/bot.py: 'praisonai bot dlq {list,replay,purge}'
  subcommand group with --path / --limit / --yes flags.
- tests/unit/bots/test_dlq.py: 11 unit tests (basic/persistence/bounds/
  TTL/replay/wired-failure-path).
- scripts/smoke_n4_real.py: real agentic E2E — patch agent.chat to fail
  once, assert DLQ holds entry, replay through real LLM (anthropic/
  claude-haiku-4-5) returns expected answer.

Test results:
  11/11 DLQ unit tests pass
  102/102 bot/session/gateway regression pass (no W1 here, single
    pre-existing unrelated test deselected)
  Real agentic: 'What is 2+2?' fails, lands in DLQ, replay -> '4'

Docs: features/inbound-dlq.mdx with mermaid (Dark Red agents / Teal
tools / white text), CardGroup, Tabs, ParamField, Expandable,
AccordionGroup, Tip/Warning/Info/Check/Badge per Mintlify.

Closes N4.

* fix(dlq): address critical P1/P2 issues from architecture review

- Fix non-atomic SQLite enqueue operations by using explicit transactions
- Fix event loop blocking by wrapping DLQ calls in run_in_executor
- Fix replay ordering to process oldest entries first (not newest)
- Fix CLI exit codes for user cancellation and failed replays
- Fix smoke test to fail fast when no supported API key is found

Resolves critical issues identified by Greptile, CodeRabbit, Gemini reviewers

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

---------

Co-authored-by: Cascade <[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: praison <[email protected]>
2026-05-09 22:40:08 +01:00
a41a8a22d6 feat: Integrate CAJAL Scientific Writer Agent (#1611)
* feat: integrate CAJAL Scientific Writer Agent for specialized paper generation

- Add ScientificWriterAgent class with CAJAL model support
- Implement PaperSection and ScientificPaper data structures
- Add LaTeX formatting capabilities for academic output
- Create comprehensive example showing multi-agent scientific workflows
- Add unit tests for all new functionality
- Follow protocol-driven design with lazy loading
- Support both CAJAL and standard models for flexibility

Fixes #1610

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: resolve unit test failures and bugs in ScientificWriterAgent

- Move `from .agent import Agent` to module level so @patch decorators
  in tests can resolve the attribute (fixes 8 failing unit tests)
- Add `import re` at module level; remove duplicate in-method imports
- Fix broken regex in _extract_section: `#{{{name}}}` was generating
  literal curly braces, now uses `##?\s+{re.escape(name)}` which
  correctly matches markdown section headers
- Fix misleading _get_cajal_model docstring/comment
- Remove root-level test_cajal_integration.py artifact (belongs in tests/)

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/37052ea9-0c0b-4ee3-9083-cb1af5f12d9a

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

* fix: improve _extract_section regex to handle indented markdown headers

Allow optional whitespace before section headers in the lookahead
(n\s*##?\s instead of \n##?\s) so that indented LLM output like
'        ## Introduction' correctly terminates the preceding section.
Without this, the abstract section would greedily capture all
subsequent sections until end-of-string.

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/37052ea9-0c0b-4ee3-9083-cb1af5f12d9a

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

* fix: resolve __getattr__ infinite recursion in ScientificWriterAgent

Adds guard to prevent infinite recursion when self.agent is unset during
initialization failures. This was the final P1 issue identified by Greptile.

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: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-09 22:40:05 +01:00
praisonai-triage-agent[bot]andMervin Praison c18450bb35 fix: eliminate singleton registries and optimize scheduler async handling (fixes #1638) (#1639)
* fix: eliminate singleton registries and optimize scheduler async handling (fixes #1638)

* Replace singleton pattern with dependency injection across 3 registries
* Create generic PluginRegistry base class for DRY implementation
* Convert FrameworkAdapterRegistry to use dependency injection
* Convert ExternalAgentRegistry to use dependency injection
* Convert bot platforms registry to use dependency injection
* Replace asyncio.run() in scheduler hot path with _async_bridge
* Maintain backward compatibility for all existing APIs
* Add entry-points support consistently across all registries

Resolves all three architectural gaps:
1. No more process-global singleton violations
2. Unified registry pattern eliminates code duplication
3. Scheduler uses persistent event loop instead of per-call overhead

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

Co-Authored-By: MervinPraison <[email protected]>

* fix: resolve registry backward compatibility and performance issues

- Fix eager loading regression in bots/_registry.py by implementing lazy registry access
- Fix get_platform_registry() to return {name: class} instead of {name: name} for backward compatibility
- Fix thread-safety race condition in PluginRegistry.resolve() error handling
- Update stale docstring in integrations/registry.py to use get_default_registry()

Addresses all P1 and P2 issues identified by code review bots.

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]>
2026-05-09 22:40:03 +01:00
9955d65f82 fix: replace unsafe eval() with safe AST parser in README example (#1626)
* fix: replace unsafe eval() with safe AST parser in README example (fixes #1625)

- Replace eval(expression) with AST-based arithmetic parser in custom tools example
- Add comprehensive security warning about eval(), exec(), subprocess usage
- Prevent arbitrary code execution while preserving arithmetic functionality
- Blocks malicious inputs like __import__('os').system('whoami')

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* docs: normalize README calculator error handling and blockquote formatting

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/f86d8d08-03ae-4f02-8c4b-21dc53e084dc

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: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-09 22:40:00 +01:00
80c78071fc fix: resolve critical correctness, concurrency, and safety gaps (#1637)
* fix: resolve critical correctness, concurrency, and safety gaps (fixes #1636)

- Fix NameError: define missing AGENTOPS_AVAILABLE flag and add lazy import with error handling
- Fix data race: acquire _stats_lock around all counter mutations and reads in async scheduler
- Fix security bypass: route public tool-loading methods through _safe_loader.py

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

* fix: address reviewer feedback - clean up docstring, ruff warnings, and lock consistency

- Remove stale FileNotFoundError from load_tools_from_module docstring
- Add noqa comments for broad Exception catches in agentops blocks
- Fix misleading comment in get_stats() and make is_running snapshot consistent
- Replace deprecated asyncio.get_event_loop() with get_running_loop()
- Add None guard for _stats_lock in stop() method to match get_stats() pattern

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: praison <[email protected]>
2026-05-09 22:39:57 +01:00
2e05f2c0f2 fix: resolve BudgetExceededError CLI traceback issues (fixes #1627) (#1635)
* fix: resolve BudgetExceededError CLI traceback issues (fixes #1627)

- Add clean error handling for BudgetExceededError in CLI
  * Wrap agent execution calls with graceful error handling
  * Show user-friendly error messages instead of raw tracebacks
  * Exit with code 1 and provide clear remediation guidance

- Add max_budget parameter to Agent constructor
  * Accept max_budget directly as convenience parameter
  * Automatically create/update ExecutionConfig with budget setting
  * Maintain full backward compatibility
  * Add proper parameter documentation

Both fixes follow architecture guidelines and provide intuitive UX.

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

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: address review feedback for budget handling and import safety

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/16ae1b3d-1c3c-4051-9465-a718a6746a90

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

* fix: resolve print shadowing and simplify budget error message

- Import rich print as rich_print to avoid shadowing builtin
- Consolidate multi-line error output into single actionable message
- Update guidance to mention new max_budget convenience parameter

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

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

* fix: resolve print shadowing and simplify budget error message

- Remove dangerous ImportError fallback in budget error handling
- Use dataclasses.replace() instead of mutating shared ExecutionConfig
- Simplify budget error to single actionable message
- Address CodeRabbit, Greptile, and Copilot review feedback

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: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-09 22:39:55 +01:00
4985415e61 fix: improve memory provider error messages for missing packages (fixes #1630) (#1634)
* fix: improve memory provider error messages for missing packages (fixes #1630)

- Update mem0 import error messages to suggest pip install 'praisonaiagents[memory]'
- Modified Memory class to show ImportError for explicitly requested providers
- Preserve graceful fallback behavior for default provider
- Fixes misleading 'API Key not provided' error when mem0ai package is missing

Changes:
- memory/adapters/factories.py: Updated mem0 ImportError message
- knowledge/adapters/mem0_adapter.py: Updated mem0 ImportError message
- memory/memory.py: Show ImportError for explicit provider requests

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: resolve critical issues in memory provider error handling

- Fix factory return value being discarded in memory.py
- Remove silent exception swallowing that broke backward compatibility
- Fix datetime import bug in MongoDB adapter (datetime.timezone.utc)
- Preserve original error propagation for misconfigured providers
- Ensure factory-created adapters are properly used when successful

Fixes issues identified by Greptile and CodeRabbit reviewers.

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: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-08 10:48:10 +01:00
a29deefef7 fix: add validation for agents.yaml field typos with suggestions (fixes #1628) (#1633)
* fix: add validation for agents.yaml field typos with suggestions (fixes #1628)

- Add difflib import for fuzzy string matching
- Implement _validate_agents_config() method in AgentsGenerator
- Validates against known fields: role, goal, instructions, backstory, tools, llm
- Uses difflib.get_close_matches() to suggest corrections for typos
- Example: 'instrutions' -> 'Did you mean instructions?'
- Logs warnings using existing logger infrastructure
- Add comprehensive regression tests for validation functionality
- Maintains full backward compatibility - only adds warnings

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>

* fix: tighten agent field validation and activate unit coverage

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/b7d4d6b8-e9fc-455a-bacf-02ff54e3fb49

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: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-08 10:47:52 +01:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] 7ffb4f87c5 fix: add missing [autonomy] and [os] extras to [all] group (fixes #1629) (#1632)
The [all] extra group was missing [autonomy] and [os] extras, causing
ImportError when users tried to use autonomy features after installing
pip install 'praisonaiagents[all]'.

This change adds both missing extras to ensure complete feature coverage:
- [autonomy]: ast-grep-cli>=0.30.0
- [os]: FastAPI + uvicorn + PyJWT

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-08 10:47:29 +01:00
057dab0ec0 fix: resolve architectural gaps in praisonai wrapper (fixes #1623) (#1624)
* fix: resolve architectural gaps in praisonai wrapper (fixes #1623)

- Fix Issue 1: LLM registry now registers built-in providers (openai, anthropic, google) with LiteLLM backend
- Fix Issue 2: DB adapter get_runs() and get_traces() now read from state store instead of returning empty lists
- Fix Issue 3: LangfuseSink uses LIFO stack correlation for proper parallel tool span matching and replaces print with logging

All fixes maintain backward compatibility and follow protocol-driven architecture per AGENTS.md.

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

* fix: address critical architectural issues identified by reviewers

- Fix LiteLLM provider TypeError by stripping 'provider' key from config
- Fix DB adapter write-side gap allowing state store writes without conversation store
- Fix tool stacks cleanup on LangfuseSink shutdown
- Fix limit=0 logic error in get_runs/get_traces
- Add exception chaining for ImportError
- Add error handling for state store reads

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

* test: align wrapper tests and harden provider/langfuse edge cases

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/62fe6186-39b5-43e2-85e3-491cc09facdf

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

* fix: tighten wrapper edge cases and align unit coverage

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/62fe6186-39b5-43e2-85e3-491cc09facdf

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

* fix: clean up duplicate loop and add async support for LiteLLM provider

- Remove duplicate _tool_stacks cleanup loop in LangfuseSink.close()
- Add generate_async() method to _LiteLLMProvider for event loop safety
- Extract _resolve_model_and_kwargs() helper to avoid duplication
- Addresses async concerns for BotSessionManager.chat and AgentFlow usage

---------

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-08 10:46:18 +01:00
89229c5c76 fix: resolve architectural gaps in praisonai wrapper (fixes #1620) (#1621)
* fix: resolve architectural gaps in praisonai wrapper (fixes #1620)

- Replace bare except: blocks with specific exception handling and logging
  - metrics.py: Handle litellm import/attribute errors with debug logging
  - aicoder.py: Proper file read error handling (FileNotFoundError, PermissionError, UnicodeDecodeError)
  - agent_scheduler.py: Handle timestamp parsing errors
  - browser/cli.py: Handle page load timeout/connection errors

- Consolidate LLM endpoint env-var precedence into single resolver
  - Created praisonai/llm/env.py with resolve_llm_endpoint() function
  - Enforces consistent precedence: OPENAI_BASE_URL > OPENAI_API_BASE > OLLAMA_API_BASE
  - Updated 8+ call sites to use resolver, eliminating duplication

- Move framework availability checks to CLI entry point for fail-fast behavior
  - Created framework_adapters/validators.py with actionable error messages
  - Validation happens before YAML parsing, saving startup time on failures
  - Removed duplicate availability checks from all adapter run() methods

These changes address all three architectural gaps identified in the issue:
silent error masking, fragmented env-var precedence, and late framework checks.

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

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

* fix: remove unused NoReturn import; add unit tests for env.py and validators.py

- validators.py: remove unused `from typing import NoReturn` import
- tests/unit/llm/test_env_resolver.py: 15 tests for resolve_llm_endpoint()
  covering precedence, defaults, API key, frozen dataclass
- tests/unit/test_framework_validators.py: 8 tests for assert_framework_available()
  covering error messages, install hints, and success paths

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a147d9a2-6f9e-42f6-b2e5-0dba331316bf

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

* fix: resolve critical issues from architecture review

- Fix double /v1 URL bug in realtime WebSocket connections
- Restore framework validation for non-CLI callers
- Handle all OSError subclasses in file reading
- Use module logger consistently

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-08 10:46:02 +01:00
e20e426dd5 feat(core): wire seed, cancel_token, tool validator, PII redactor into Agent.chat (Phase A) (#1617)
* feat(core): wire seed, cancel_token, tool validator, PII redactor into Agent.chat (Phase A)

Four gap-closures identified in the core-features analysis. Every change
is opt-in and zero-overhead when unused — existing agents behave
identically unless the new kwargs or APIs are actively used.

C1 — Deterministic seed
  Agent.chat(..., seed=42) is now threaded into the LLM payload via
  llm_kwargs['seed']. Overrides any seed set on the LLM instance for
  this call only. (chat_mixin.py)

C2 — Cooperative cancellation
  Agent.chat(..., cancel_token=InterruptController()) is checked at
  agent start and right before the LLM call. A tripped token raises
  InterruptedError instead of hitting the network. Falls back to
  self.interrupt_controller when no per-call token is supplied.
  (chat_mixin.py)

C4 — Tool-argument validator
  _execute_tool_with_context now consults agent._tool_validator (any
  ToolValidatorProtocol impl) after BEFORE_TOOL hooks. Invalid args
  short-circuit to an error string; the tool function is never called.
  Validator exceptions are swallowed so a buggy validator can never
  break tool execution. (tool_execution.py)

C7 — PII redactor for LLM egress
  trace/redact.py gains scrub_pii_text(), enable_pii_redaction(), and
  disable_pii_redaction(). enable_pii_redaction() registers an
  idempotent BEFORE_LLM hook that rewrites message content with
  REDACT_KEYS + value-pattern rules (OpenAI keys, SSN, CC, email).
  chat_mixin now reads back before_llm_input.messages after the hook
  runs so modified_input actually reaches the LLM. (trace/redact.py,
  trace/__init__.py, chat_mixin.py)

Tests
  tests/unit/agent/test_core_phase_a.py — 9 TDD tests (all passing)
  tests/smoke_core_phase_a_real.py — real Claude Haiku 4.5 run,
    exercises all four features together

Regression sweep: 4616 unit tests pass, 0 new failures.
Pre-existing test-pollution failures (plugins/permissions/thread_safety)
unchanged.

* fix: resolve P1 issues in core phase A implementation

- Fix agent_end trace never emitted on cancellation by moving cancel check inside try block
- Fix credit card regex too broad by restricting to canonical 4-group or 16-digit patterns
- Add thread safety to PII redaction with mutex lock
- Add missing seed and cancel_token parameters to achat method for API parity
- Forward seed and cancel_token properly in async LLM calls

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

* fix(pii): remove false-positive \d{16} CC regex — matches timestamps/trace IDs

@claude: The bare \b\d{16}\b branch matched ANY 16-digit sequence (UNIX microsecond
timestamps, distributed trace IDs, order/invoice numbers) causing false-positive
redactions in LLM tool outputs and RAG results. Removed — the canonical 4-group
pattern (?:\d{4}[ -]){3}\d{4} covers real-world formatted cards with near-zero FP rate.

---------

Co-authored-by: praison <[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-08 10:45:15 +01:00
d49c559fce fix: resolve shell injection, session cache races, and dead stats lock (#1609)
* fix: resolve shell injection, session cache races, and dead stats lock (fixes #1608)

- Add shell=False parameter to sandbox backends with safe command parsing
- Fix shell injection in subprocess.py, docker.py, and ssh.py
- Add thread-safe session cache operations to PersistenceOrchestrator
- Fix AsyncAgentScheduler stats lock usage with atomic counter operations
- Create shared _shell.py utility for consistent command handling

Security fixes:
1. Shell injection: Default to safe argv execution, explicit shell opt-in
2. Session races: Thread-safe cache with RLock and defensive copying
3. Dead lock: Use existing _stats_lock for atomic counter updates

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

* fix: correct test await, dead ssh branch, unused import, and docker shlex placement

- Fix test_get_stats_initial_state: add await before get_stats() (now async)
- Remove dead if/else branches in ssh._run_command_with_limits (both identical)
- Remove unused 'from ._shell import build_argv' in ssh.run_command
- Move 'import shlex' to top-level in docker.py (was inside method body)
- Add missing EOF newline to _shell.py"

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/3ece618b-0046-4e3b-b0e9-a3dfc870f1a7

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

* fix: restore backward compatibility for get_stats() method

- Revert get_stats() from async to sync to fix breaking change
- Add get_stats_async() for atomic stats in async context
- Update test to use sync get_stats() again
- Minor comment cleanup in SSH sandbox

Fixes breaking API change affecting 8+ call sites across CLI, examples, and tests.

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-08 10:42:52 +01:00
de84d22087 feat(W1): cross-platform mirror + unified per-user session (#1599)
* feat(W1): cross-platform mirror + unified per-user session

- Core SDK: IdentityResolverProtocol, IdentityLink, InMemoryIdentityResolver
- Core SDK: task-local SessionContext via contextvars (async-safe)
- Wrapper: BotSessionManager.identity_resolver param + _persist_key separation
- Wrapper: praisonai.bots.mirror_to_session() outbound delivery mirror
- Tests: 17 core SDK + 8 wrapper unit tests; 0 regressions on 146 session + 99 bot tests
- Real agentic smoke: anthropic/claude-haiku-4-5 recalled cross-platform fact
- Docs: features/cross-platform-mirror.mdx

Closes W1.

* feat(W1): wire identity_resolver through Bot/BotOS + persistent FileIdentityResolver

- FileIdentityResolver: JSON-backed persistent resolver (atomic writes, 0o600 perms)
- Bot(identity_resolver=...) constructor param + adapter splice
- BotOS(identity_resolver=...) propagates to all bots (shortcut + add_bot)
- BotSessionManager.chat() now sets task-local SessionContext (platform/chat/user/unified_user_id)
- ContextVars propagated into executor thread via copy_context()
- Telegram/Discord/Slack handlers pass chat_id + thread_id + user_name to chat()
- 5 new FileIdentityResolver tests + 7 Bot/BotOS wiring tests = 12 new tests
- 151 core SDK session tests + 106 wrapper bot/session tests pass
- Real agentic smoke (BotOS API): claude-haiku-4-5 recalled cross-platform 'darkwave'
- Docs updated with high-level BotOS API quickstart

Closes W1.

* fix(W1): hold agent_lock across full LLM call to prevent history leak

Pre-existing concurrency bug exposed by W1 robustness suite (T3):
agent_lock only protected the history swap, not the LLM call.
Concurrent users on a shared Agent could observe each other's
chat_history during the LLM round-trip.

Fix: scope agent_lock to cover the whole load+swap+chat+capture
sequence. Throughput on a shared agent is bounded by serial LLM
latency — correct, since chat_history is a mutable instance attr.

- New regression test test_no_history_leak_between_concurrent_users
- All 16 W1 unit tests pass; 5/5 W1 robustness real-LLM tests pass:
  T1 3-platform unification | T2 restart persistence | T3 concurrent
  users isolated | T4 SessionContext visible to tools | T5 self-
  improving probe (correctly demonstrates W1 boundary vs W2)

* fix(review): address Qodo/Coderabbit/Gemini reviewer feedback on W1 PR

- memory_mixin: add -> bool return type to _add_to_chat_history_if_not_duplicate (Gemini)
- execution_mixin: add -> Optional[str] return type to arun (Gemini)
- chat_mixin: remove unused asyncio, threading, traceback, Callable, OrderedDict (Coderabbit/Qodo)
- execution_mixin: remove unused os, get_logger (Coderabbit/Qodo)
- memory_mixin: remove unused get_logger, List, Any (Coderabbit/Qodo)
- identity.py: fix TOCTOU race in FileIdentityResolver._flush() by adding _flush_lock
  that serialises the full snapshot+disk-write sequence
- _mirror.py: clarify comment - _storage_key() is a duck-typing check, not bucket creation
All 151 core SDK + 16 W1 bot tests pass.

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a8f7b411-9c5e-426b-99ce-bcfea79deaaa

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

* fix(review): address Qodo/Coderabbit/Gemini reviewer feedback on W1 PR

Critical fixes for race conditions and robustness:

P1 Race Condition Fixes:
- FileIdentityResolver._flush: Hold _lock across entire write operation to prevent TOCTOU race where concurrent link() calls could cause data loss during os.replace()
- mirror_to_session: Add thread-safe _add_mirror_entry_sync() method to BotSessionManager that properly coordinates with asyncio locks used by chat()

P2 Robustness Fixes:
- Session context cleanup: Use try/finally in chat() to ensure clear_session_context() is always called, even on agent exceptions
- Variable shadowing: Fix reset() method to use distinct storage_key/persist_key variables instead of reusing 'key'
- Model identifiers: Update smoke tests to use valid model names (claude-3-5-haiku-latest, gemini-1.5-flash)

All fixes maintain backward compatibility and follow AGENTS.md architecture guidelines.

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

* fix(review): address Qodo/Coderabbit/Gemini reviewer feedback on W1 PR

Comprehensive fixes for race conditions and code quality issues:

P1 Race Condition Fixes:
- Fixed mirror_to_session sync path race: added per-user threading locks
  when no event loop is running to prevent concurrent history corruption
- Reserved field protection in mirror entries: filter metadata to prevent
  callers from overwriting role/mirror/timestamp invariants

P2 Code Quality Fixes:
- Enhanced session context cleanup logging for debugging
- Use UTC timestamps in mirror entries for consistency
- Remove unused imports from _add_mirror_entry_sync
- Fix f-string literals without placeholders (Ruff F541)
- Fix hardcoded developer paths in smoke test docstrings
- Fix integration test skip condition to require explicit RUN_REAL_AGENTIC opt-in

All fixes maintain backward compatibility and follow AGENTS.md principles.

🤖 Generated with Claude Code

* fix(mirror): replace process-wide global lock with per-call/per-manager lock

@claude: The module-level _mirror_lock = threading.RLock() serialised
mirror_to_session() calls across ALL BotSessionManager instances in the process.
Slow disk I/O for one bot would block mirror ops for every other bot.
Now accepts optional 'lock' param (caller supplies their own per-manager lock)
or falls back to a fresh per-call RLock — never shared across bot instances.

---------

Co-authored-by: Cascade <[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]>
Co-authored-by: praison <[email protected]>
2026-05-08 10:42:06 +01:00
MervinPraison f3166b5bbd docs: auto-update feature parity trackers [skip ci] 2026-05-08 08:20:46 +00:00
0d1cc32b23 feat: add subscription-token auth for Claude Pro/ChatGPT Plus/Gemini CLI/Qwen CLI (#1619)
* feat: add subscription-token auth for Claude Pro/ChatGPT Plus/Gemini CLI/Qwen CLI (fixes #1618)

- Add subscription auth protocol infrastructure in praisonaiagents/auth/subscription/
- Implement provider-specific auth for Claude Code, Codex, Gemini CLI, and Qwen CLI
- Add auth= parameter to LLM and Agent classes with lazy credential resolution
- Include critical OAuth headers for Anthropic tokens (prevents 500s)
- Add comprehensive unit and integration tests
- Maintain backward compatibility - zero impact when auth= not used
- Follow protocol-driven design patterns from AGENTS.md

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

* fix: address review findings in subscription auth implementation

- Add get_subscription_provider() public helper to registry to avoid
  leaking _REGISTRY/_LOCK internals (used by _refresh_subscription_creds)
- Remove dead `import threading` from LLM._refresh_subscription_creds
- Fix _BUILTIN_REGISTERED not being reset in test registry fixture,
  preventing builtin providers re-registering after clear (test-order bug)
- Add pytest.importorskip('pydantic') to integration tests so CI skips
  them gracefully when LLM extras are not installed
- Correct _is_expiring type hint from int to Optional[int] (already
  handled None at runtime; type now matches)
- Expose get_subscription_provider via subscription/__init__.py and
  auth/__init__.py __all__ / __getattr__ lazy-loading chains

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/31dd06b1-5ae4-43b5-90ce-669cdc5c6db6

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

* fix: address critical bugs in subscription auth implementation

- Fix dead refresh code: Wire auth error detection to credential refresh in retry loops
- Fix race condition: Move registry imports inside lock to prevent concurrent access issues
- Fix stale credentials: Add expiration checking to clear expired cached credentials
- Implement automatic 401 retry with refreshed OAuth tokens as advertised

Addresses review findings from Greptile, CodeRabbit, and Copilot.

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

* fix: resolve critical auth bugs in subscription token handling

- Fix OAuth Bearer auth: detect auth_scheme=bearer and use Authorization header instead of x-api-key
- Fix refresh-on-401: trigger on attempt=0 and don't clear fresh credentials
- Fix function signature: use correct _build_completion_params(**kwargs) signature

Fixes critical P0 bugs that made subscription auth non-functional.

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

* fix: resolve remaining critical auth bugs in subscription token handling

- Fix Bearer auth: Use litellm native sk-ant-oat-* detection instead of manual headers
- Update litellm dependency: Bump to >=1.83.14 to resolve CVE-2026-42208 and supply-chain vulns
- Gate experimental endpoints: Codex/Gemini now raise clear errors until proper transports available
- Add comprehensive smoke test: Tests real auth flows with mocked LLM calls
- Clean up minor issues: Remove __import__('time'), simplify header duplication

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

Co-Authored-By: Claude <[email protected]>

* fix: add missing OAuth beta headers and fix smoke test bugs

- Add 3 missing OAuth beta headers to match Hermes reference:
  - claude-code-20250219 (Claude Code identity beta)
  - fine-grained-tool-streaming-2025-05-14 (tool streaming improvements)
  - context-1m-2025-08-07 (1M-token context window support)
- Implement dynamic Claude Code version detection with caching
- Fix smoke test bugs:
  - Patch LLM class instead of Agent for _classify_error_and_should_retry
  - Remove mocks that prevent real provider code execution
  - Fix fragile argument extraction using call_args.kwargs
  - Remove content assertion that depends on mocked response

This achieves full Hermes parity for the litellm path and ensures
Claude Pro/Max users get 1M context + Claude Code identity routing.

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]>
Co-authored-by: Claude <[email protected]>
2026-05-08 09:20:12 +01:00
e33b4b4b39 fix: resolve architectural gaps in praisonai wrapper (fixes #1614) (#1615)
* fix: resolve architectural gaps in praisonai wrapper (fixes #1614)

- Fixed stubbed framework adapters: PraisonAI adapter now has working implementation instead of stub message, CrewAI adapter accepts full parameter signature
- Unified duplicate sandbox/compute hierarchies: Created SandboxToComputeAdapter to eliminate code duplication between sandbox/ and integrations/compute/ backends
- Removed import-time side effects: Moved load_dotenv() calls from module level to function level, moved crewai logger config to adapter, removed eager CLI backend registration

All changes maintain backward compatibility while following the protocol-driven architecture principles.

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

* fix: address review findings - restore sandbox APIs, fix _compat.py bugs, clean up load_dotenv wrappers

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/32fa524b-bf40-4e5a-b58d-156b5e103347

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

* fix: handle TIMEOUT exit code (124) in SandboxToComputeAdapter

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/32fa524b-bf40-4e5a-b58d-156b5e103347

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

* fix: wire tools and callbacks in framework adapters (resolves reviewer feedback)

- Add tools_dict wiring in PraisonAI and CrewAI adapters
- Fix PEP 484 type annotations (Dict[str, Any] = None -> Optional[Dict[str, Any]] = None)
- Ensure agent tools, agent_callback, and task_callback are properly applied
- Maintains backward compatibility while fixing functional regression

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-05 11:04:43 +01:00
MervinPraison c9bbb5aee7 docs: auto-update api.md [skip ci] 2026-05-05 06:48:55 +00:00
ed63d34ed7 feat: Linear integration — agentic control plane (closes #1612) (#1613)
* feat: add Linear integration — agentic control plane with tools + LinearBot + AgentSession webhooks

- Add linear_tools.py with 9 GraphQL tools (search, create, update issues, comments, cycles)
- Add LinearBot adapter with HMAC-SHA256 webhook verification and AgentSession handling
- Add Linear platform to bot registry, CLI commands, and gateway integration
- Support both LINEAR_OAUTH_TOKEN (OAuth) and LINEAR_API_KEY (personal) authentication
- Include example configurations (YAML and Python) for zero-code and programmatic usage
- Follow protocol-driven architecture: tools in core SDK, bot implementation in wrapper
- Zero import-time cost via lazy loading, full BotProtocol compliance

Closes #1612

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

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

* fix: correct four bugs in Linear integration found during review

- _handle_agent_message: replace non-existent get_session() context
  manager with correct _session_mgr.chat() call (matches Slack/Telegram
  pattern in _session.py)
- _gql_query: fix aiohttp timeout — bare int 30 → ClientTimeout(total=30)
- bot_linear CLI: fix web_provider= → web_search_provider= to match
  BotCapabilities dataclass field (same as all other bot commands)
- bot_linear CLI: add `or []` guards for tools/skills/knowledge_sources
  to prevent None being passed where list is expected (matches all other
  bot commands)

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/645d14d2-c605-41bf-b83a-839209e7e3b9

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

* fix: resolve 3 blocking Linear integration bugs

- Fix BotMessage constructor field names (id->message_id, text->content)
- Fix OAuth Bearer token handling in both tools and bot implementations
- Fix send_message signature to match BotProtocol specification

All fixes verified against the original bug reproduction cases.

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

* refactor: relocate Linear tools to praisonai-tools (canonical home)

Linear GraphQL wrappers don't belong in the protocol-first core SDK
(per AGENTS.md §4.1). They duplicated an already-shipped LinearTool in
MervinPraison/PraisonAI-Tools and pulled third-party SaaS HTTP plumbing
into praisonaiagents/tools/.

This commit:
- Removes praisonaiagents/tools/linear_tools.py (the new file)
- Reverts the 9 TOOL_MAPPINGS additions and the 3 lookup-list entries
- Leaves LinearBot in praisonai/bots/linear.py unchanged (correct location:
  wrapper-side webhook adapter)

The full Linear tool surface (search/get/create/update/comment/list cycles/
list states/list teams) is being landed in MervinPraison/PraisonAI-Tools#31
with OAuth Bearer support and 36 unit tests. Users get it via:

    from praisonai_tools import LinearTool

praisonai-tools is already a runtime dep of praisonai (pyproject.toml:102),
so end-user UX is unchanged.

---------

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]>
Co-authored-by: praison <[email protected]>
2026-05-05 07:48:29 +01:00
Cascade d15c57d80c Release v4.6.37 2026-05-04 17:23:45 +01:00
Cascade 73eaacad35 chore: bump praisonaiagents to 1.6.37 2026-05-04 17:22:57 +01:00
Cascade 0cec9fd1c3 refactor: harden archive extraction and tool resolution boundary 2026-05-04 17:22:33 +01:00
Cascade 7a7df247c8 Release v4.6.36 2026-05-04 06:26:02 +01:00
Cascade cb96d03b58 chore: bump praisonaiagents to 1.6.36 2026-05-04 06:25:12 +01:00
259 changed files with 18954 additions and 6582 deletions

No files matched your search

+51
View File
@@ -0,0 +1,51 @@
# PraisonAI Environment Variables
# Copy this file to .env and update with your actual values
# === Required ===
OPENAI_API_KEY=sk-... # OpenAI key for LLM calls. Get yours at https://platform.openai.com
# === Required for Claw web-search tool ===
TAVILY_API_KEY=tvly-... # Get yours at https://app.tavily.com (free tier available)
# === Optional: Claw dashboard security ===
PRAISONAI_ALLOW_LOCAL_TOOLS=0 # Set to 1 to enable local shell tools (security risk)
# === Optional: Alternative LLM Providers ===
ANTHROPIC_API_KEY=sk-ant-... # Anthropic Claude models
GOOGLE_API_KEY=... # Google Gemini models
GROQ_API_KEY=gsk_... # Groq (fast inference)
COHERE_API_KEY=... # Cohere models
AZURE_OPENAI_API_KEY=... # Azure OpenAI
AZURE_OPENAI_ENDPOINT=... # Azure OpenAI endpoint
# === Optional: Memory backends ===
MEM0_API_KEY=... # Mem0 memory service
REDIS_URL=redis://localhost:6379 # Redis for state management
# === Optional: Observability ===
LANGFUSE_SECRET_KEY=sk-lf-... # Langfuse tracing
LANGFUSE_PUBLIC_KEY=pk-lf-... # Langfuse public key
# === Optional: Bot platforms ===
TELEGRAM_BOT_TOKEN=... # Telegram bot token
DISCORD_BOT_TOKEN=... # Discord bot token
SLACK_BOT_TOKEN=xoxb-... # Slack bot token
SLACK_APP_TOKEN=xapp-... # Slack app token
WHATSAPP_ACCESS_TOKEN=... # WhatsApp bot access token
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
DEBUG=false # Enable debug mode
+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 != ''
+39 -2
View File
@@ -294,8 +294,35 @@ def search(query: str) -> str:
@tool
def calculate(expression: str) -> float:
"""Evaluate a math expression."""
return eval(expression)
"""Safely evaluate a numeric arithmetic expression."""
import ast
import operator
# Define allowed operations
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.Pow: operator.pow,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
def _safe_eval(node):
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
elif isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
elif isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.operand))
else:
raise ValueError("Unsupported expression")
try:
return _safe_eval(ast.parse(expression, mode="eval").body)
except (ValueError, SyntaxError, TypeError, ZeroDivisionError, OverflowError):
raise ValueError("Invalid arithmetic expression")
agent = Agent(
instructions="You are a helpful assistant",
@@ -304,6 +331,7 @@ agent = Agent(
agent.start("Search for AI news and calculate 15*4")
```
> ⚠️ **Security Note:** Never use `eval()`, `exec()`, or `subprocess` in tool functions that process LLM-generated or user-supplied input. Always validate and sanitize inputs to prevent code injection attacks.
> 📖 [Full tools docs](https://docs.praison.ai/docs/tools/tools) — BaseTool, tool packages, 100+ built-in tools
### 5. Persistence (Databases)
@@ -330,6 +358,15 @@ pip install "praisonai[claw]"
praisonai claw
```
#### Required Environment Variables
Copy `.env.example` to `.env` and configure the following variables:
| Variable | Required | Description |
|----------|----------|-------------|
| `OPENAI_API_KEY` | Yes | OpenAI API key for all LLM calls |
| `TAVILY_API_KEY` | Yes (Claw) | Tavily key for the built-in web-search tool. Get one free at https://app.tavily.com |
Open **http://localhost:8082** — the dashboard comes with 13 built-in pages: Chat, Agents, Memory, Knowledge, Channels, Guardrails, Cron, and more. Add messaging channels directly from the UI.
> 📖 [Full Claw docs](https://docs.praison.ai/docs/concepts/claw) — platform tokens, CLI options, Docker, and YAML agent mode
+6
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>
@@ -786,6 +787,10 @@ Methods:
* <code title="cli">praisonai bot discord <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot email <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot install-daemon <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot linear <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot list <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot purge <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot replay <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot slack <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot start <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
* <code title="cli">praisonai bot telegram <a href="./src/praisonai/praisonai/cli/commands/bot.py">--help</a></code>
@@ -853,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.35" \
"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.35" \
"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.35" \
"praisonai>=4.6.43" \
"praisonai[ui]" \
"praisonai[crewai]"
+68
View File
@@ -0,0 +1,68 @@
"""
Linear Agent Example for PraisonAI.
Demonstrates how to create a Linear bot that responds to issue mentions
and assignments with full coding capabilities.
"""
import asyncio
from praisonaiagents import Agent
from praisonai.bots import LinearBot
# Create an agent with Linear tools and coding capabilities
agent = Agent(
name="PraisonAI Coder",
instructions="""
You are an autonomous coding agent integrated with Linear.
When you are mentioned or assigned an issue:
1. Analyze the issue description and requirements
2. Break down complex tasks into smaller steps
3. Use available tools to implement solutions
4. Update the issue with progress comments
5. Create GitHub pull requests when code changes are needed
Focus on being helpful, thorough, and providing clear updates.
""",
llm="gpt-4o-mini",
tools=[
"linear_search_issues",
"linear_get_issue",
"linear_add_comment",
"linear_update_issue",
"linear_list_teams",
"linear_list_issue_states",
"github_create_branch",
"github_commit_and_push",
"github_create_pull_request",
"read_file",
"write_file",
"execute_command"
],
memory=True,
web_search=True,
auto_approve_tools=True,
)
# Create Linear bot
bot = LinearBot(
token="your-linear-oauth-token", # or set LINEAR_OAUTH_TOKEN env var
signing_secret="your-webhook-secret", # or set LINEAR_WEBHOOK_SECRET env var
agent=agent,
webhook_port=8080,
)
async def main():
"""Start the Linear bot."""
print("Starting Linear bot...")
print("Set up your Linear webhook to point to: http://your-host:8080/webhook")
print("Configure AgentSession and Comment events in Linear webhook settings")
try:
await bot.start()
except KeyboardInterrupt:
print("\nStopping bot...")
await bot.stop()
if __name__ == "__main__":
asyncio.run(main())
@@ -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)
+40
View File
@@ -0,0 +1,40 @@
# Scientific paper workflow — YAML equivalent of multi_agent_paper.py
# Run with:
# praisonai agents run --file examples/scientific_writing/agents.yaml
framework: praisonai
topic: "Climate change effects on coral reef biodiversity"
roles:
literature_reviewer:
role: "Literature Reviewer"
goal: "Survey recent academic work on {topic} and produce an APA review."
backstory: "Expert in academic literature surveying and citation hygiene."
llm: "gpt-4o-mini"
tasks:
review_literature:
description: "Produce a concise literature review with 5-8 APA citations."
expected_output: "APA-formatted literature review."
methodology_designer:
role: "Methodology Designer"
goal: "Design a reproducible research methodology for {topic}."
backstory: "Specialises in rigorous experimental design."
llm: "gpt-4o-mini"
tasks:
design_methodology:
description: "Write a reproducible methods section in LaTeX."
expected_output: "LaTeX methods section."
scientific_writer:
role: "Scientific Writer"
goal: "Assemble the final LaTeX paper on {topic}."
backstory: >-
Specialised in LaTeX-formatted academic papers, fine-tuned on
scientific literature (CAJAL-4B checkpoint).
llm: "huggingface/Agnuxo/CAJAL-4B-P2PCLAW"
tasks:
write_paper:
description: >-
Combine the literature review and methodology into a full
scientific paper on {topic}. Use LaTeX formatting.
expected_output: "Complete LaTeX paper."
@@ -0,0 +1,73 @@
"""Multi-agent scientific paper workflow (Researcher -> Methodologist -> Writer).
Demonstrates the agent-centric pattern from AGENTS.md: compose specialised
Agents with ``AgentTeam`` + sequential ``Task``s instead of creating custom
Agent subclasses. The writer uses the CAJAL-4B HuggingFace model; the
reviewer and methodologist use general-purpose LLMs.
Usage:
python examples/scientific_writing/multi_agent_paper.py
"""
from praisonaiagents import Agent, AgentTeam, Task
from scientific_writer import ( # noqa: E402 — example imports a sibling file
build_scientific_writer,
format_citation,
format_latex_section,
)
literature_reviewer = Agent(
name="Literature Reviewer",
instructions=(
"You are an expert at surveying academic literature. "
"Produce a concise literature review with 5-8 key citations in APA."
),
tools=[format_citation],
)
methodology_designer = Agent(
name="Methodology Designer",
instructions=(
"You design rigorous research methodologies. "
"Output a clear, reproducible methods section."
),
tools=[format_latex_section],
)
scientific_writer = build_scientific_writer()
def run(topic: str) -> str:
"""Run the 3-agent workflow for a given research ``topic``."""
t1 = Task(
name="review_literature",
description=f"Review literature on: {topic}",
agent=literature_reviewer,
expected_output="APA literature review.",
)
t2 = Task(
name="design_methodology",
description=f"Design research methodology for: {topic}",
agent=methodology_designer,
expected_output="LaTeX methods section.",
)
t3 = Task(
name="write_paper",
description=(
f"Combine the literature review and methodology into a full "
f"scientific paper on: {topic}. Use LaTeX formatting."
),
agent=scientific_writer,
expected_output="Complete LaTeX paper.",
)
team = AgentTeam(
agents=[literature_reviewer, methodology_designer, scientific_writer],
tasks=[t1, t2, t3],
)
return team.start()
if __name__ == "__main__":
result = run("Transformer architectures for protein folding prediction")
print(result)
@@ -0,0 +1,85 @@
"""Scientific Paper Writer — single-agent example using the CAJAL model.
CAJAL (``Agnuxo/CAJAL-4B-P2PCLAW``) is a 4B-parameter HuggingFace model
specialised for LaTeX-formatted academic writing. This example shows how
to use it with a regular ``Agent`` and ``@tool`` functions — no new Agent
subclass is needed. Run 3 ways: Python (this file), CLI, or YAML.
Requirements:
pip install "praisonaiagents[llm]" # for litellm / HuggingFace
export HUGGINGFACE_API_KEY=... # or run a local inference server
Usage:
python examples/scientific_writing/scientific_writer.py
"""
from praisonaiagents import Agent, tool
@tool
def format_latex_section(title: str, content: str) -> str:
"""Wrap prose content in a LaTeX ``\\section{}`` block.
Args:
title: Section heading (e.g. ``"Introduction"``).
content: The body text.
Returns:
LaTeX-formatted section string.
"""
return f"\\section{{{title}}}\n{content}\n"
@tool
def format_citation(authors: str, year: int, title: str, venue: str) -> str:
"""Render a single APA-style citation line.
Args:
authors: ``"Smith, J. & Jones, A."``
year: Publication year.
title: Paper title.
venue: Journal / conference name.
Returns:
One APA-formatted citation string.
"""
return f"{authors} ({year}). {title}. *{venue}*."
SCIENTIFIC_INSTRUCTIONS = """You are a specialised scientific paper writer.
Produce high-quality academic content with:
- Clear structure (Abstract, Introduction, Methodology, Results, Discussion,
Conclusion, References)
- Rigorous methodology and precise language
- Proper APA citations and LaTeX formatting
- Honest limitations and future work sections
Use ``format_latex_section`` to wrap each section in LaTeX, and
``format_citation`` to build the References list.
"""
def build_scientific_writer(
model: str = "huggingface/Agnuxo/CAJAL-4B-P2PCLAW",
) -> Agent:
"""Construct a scientific-writing Agent.
Override ``model`` for any HuggingFace / local / API model — the default
targets the CAJAL-4B checkpoint for LaTeX-style academic output.
"""
return Agent(
name="Scientific Writer",
instructions=SCIENTIFIC_INSTRUCTIONS,
llm=model,
tools=[format_latex_section, format_citation],
)
if __name__ == "__main__":
agent = build_scientific_writer()
paper = agent.start(
"Write a short scientific paper (Abstract + Introduction + Conclusion) "
"on 'Climate change effects on coral reef biodiversity'. "
"Use LaTeX formatting and include at least 3 APA citations."
)
print(paper)
+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}"
+35
View File
@@ -0,0 +1,35 @@
platform: linear
token: ${LINEAR_OAUTH_TOKEN}
signing_secret: ${LINEAR_WEBHOOK_SECRET}
webhook_port: 8080
agent:
name: "PraisonAI Coder"
instructions: |
You are an autonomous coding agent integrated with Linear.
When you are mentioned or assigned an issue:
1. Analyze the issue description and requirements
2. Break down complex tasks into smaller steps
3. Use available tools to implement solutions
4. Update the issue with progress comments
5. Create GitHub pull requests when code changes are needed
Focus on being helpful, thorough, and providing clear updates.
llm: gpt-4o-mini
tools:
- linear_search_issues
- linear_get_issue
- linear_add_comment
- linear_update_issue
- linear_list_teams
- linear_list_issue_states
- github_create_branch
- github_commit_and_push
- github_create_pull_request
- read_file
- write_file
- execute_command
memory: true
web_search: true
auto_approve_tools: true
@@ -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()
@@ -519,6 +520,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
model: Optional[Union[str, Any]] = None, # Alias for llm=
base_url: Optional[str] = None, # Kept separate (connection/auth)
api_key: Optional[str] = None, # Kept separate (connection/auth)
auth: Optional[str] = None, # Subscription auth provider: "claude-code", "codex", etc.
# Tools
tools: Optional[List[Any]] = None,
allow_delegation: bool = False, # Deprecated: use handoffs= instead
@@ -554,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.
@@ -1521,6 +1524,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
llm_config['base_url'] = base_url
if api_key:
llm_config['api_key'] = api_key
if auth:
llm_config['auth'] = auth
llm_config['metrics'] = metrics
self.llm_instance = LLM(**llm_config)
self.llm = llm.get('model', Agent._get_default_model())
@@ -1531,6 +1536,7 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
model=model_name,
base_url=base_url,
api_key=api_key,
auth=auth,
metrics=metrics,
web_search=web_search,
web_fetch=web_fetch,
@@ -1552,6 +1558,10 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
if api_key and 'api_key' not in llm:
llm = llm.copy()
llm['api_key'] = api_key
# Add auth if provided and not in dict
if auth and 'auth' not in llm:
llm = llm.copy()
llm['auth'] = auth
# Add metrics parameter
llm = llm.copy()
llm['metrics'] = metrics
@@ -1571,6 +1581,8 @@ class Agent(UnifiedExecutionMixin, ToolExecutionMixin, ChatHandlerMixin, Session
llm_params = {'model': llm}
if api_key:
llm_params['api_key'] = api_key
if auth:
llm_params['auth'] = auth
llm_params['metrics'] = metrics
llm_params['web_search'] = web_search
llm_params['web_fetch'] = web_fetch
@@ -1936,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):
@@ -11,8 +11,6 @@ import time
import json
import logging
from praisonaiagents._logging import get_logger
import asyncio
import threading
from ..errors import BudgetExceededError
# Fallback helpers to avoid circular imports
@@ -45,9 +43,7 @@ logger = logging.getLogger(__name__)
import traceback
from typing import List, Optional, Any, Dict, Union, Callable, Generator, TYPE_CHECKING
from collections import OrderedDict
from typing import List, Optional, Any, Dict, Union, Generator, TYPE_CHECKING
if TYPE_CHECKING:
pass
@@ -560,7 +556,11 @@ Your Goal: {self.goal}"""
temperature=temperature
)
self._hook_runner.execute_sync(HookEvent.BEFORE_LLM, before_llm_input)
# C7 — honour any BEFORE_LLM hook that mutated the message stream
# (e.g. PII redactor). The runner applies modified_input in-place on
# before_llm_input.messages; adopt that value for the actual LLM call.
messages = before_llm_input.messages
logging.debug(f"{self.name} sending messages to LLM: {messages}")
# Emit LLM request trace event (zero overhead when not set)
@@ -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.
@@ -1149,7 +1294,7 @@ Your Goal: {self.goal}"""
)
return rendered
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None) -> Optional[str]:
def chat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None) -> Optional[str]:
"""
Chat with the agent.
@@ -1193,11 +1338,17 @@ Your Goal: {self.goal}"""
_trace_emitter.agent_start(self.name, {"role": self.role, "goal": self.goal})
try:
return self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice)
# C2 — cooperative cancellation: abort early if a pre-set token is given
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
raise InterruptedError(f"Agent chat cancelled: {reason}")
return self._chat_impl(prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice, seed=seed, cancel_token=_cancel)
finally:
_trace_emitter.agent_end(self.name)
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None):
def _chat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
"""Internal chat implementation (extracted for trace wrapping)."""
# Apply rate limiter if configured (before any LLM call)
if self._rate_limiter is not None:
@@ -1418,7 +1569,16 @@ Your Goal: {self.goal}"""
effective_tool_choice = tool_choice or getattr(self, '_yaml_tool_choice', None)
if effective_tool_choice:
llm_kwargs['tool_choice'] = effective_tool_choice
# C1 — per-call seed overrides llm_instance.seed for determinism
if seed is not None:
llm_kwargs['seed'] = seed
# C2 — last-chance cancel check before handing to the LLM
if cancel_token is not None and getattr(cancel_token, 'is_set', lambda: False)():
reason = getattr(cancel_token, 'reason', None) or 'cancelled'
raise InterruptedError(f"Agent chat cancelled: {reason}")
response_text = self.llm_instance.get_response(**llm_kwargs)
self._add_to_chat_history("assistant", response_text)
@@ -1714,7 +1874,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
cleaned = cleaned[:-3].strip()
return cleaned
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None):
async def achat(self, prompt: str, temperature: float = 1.0, tools: Optional[List[Any]] = None, output_json: Optional[Any] = None, output_pydantic: Optional[Any] = None, reasoning_steps: bool = False, stream: Optional[bool] = None, task_name: Optional[str] = None, task_description: Optional[str] = None, task_id: Optional[str] = None, config: Optional[Dict[str, Any]] = None, force_retrieval: bool = False, skip_retrieval: bool = False, attachments: Optional[List[str]] = None, tool_choice: Optional[str] = None, seed: Optional[int] = None, cancel_token: Optional[Any] = None):
"""Async version of chat method with self-reflection support.
Args:
@@ -1737,13 +1897,20 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
reasoning_steps=reasoning_steps, stream=stream,
task_name=task_name, task_description=task_description, task_id=task_id,
config=config, force_retrieval=force_retrieval, skip_retrieval=skip_retrieval,
attachments=attachments, _trace_emitter=_trace_emitter, tool_choice=tool_choice
attachments=attachments, _trace_emitter=_trace_emitter, tool_choice=tool_choice,
seed=seed, cancel_token=cancel_token
)
finally:
_trace_emitter.agent_end(self.name)
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None):
async def _achat_impl(self, prompt, temperature, tools, output_json, output_pydantic, reasoning_steps, stream, task_name, task_description, task_id, config, force_retrieval, skip_retrieval, attachments, _trace_emitter, tool_choice=None, seed=None, cancel_token=None):
"""Internal async chat implementation (extracted for trace wrapping)."""
# C2 — cooperative cancellation: abort early if a pre-set token is given
_cancel = cancel_token if cancel_token is not None else getattr(self, "interrupt_controller", None)
if _cancel is not None and getattr(_cancel, "is_set", lambda: False)():
reason = getattr(_cancel, "reason", None) or "cancelled before LLM call"
raise InterruptedError(f"Agent chat cancelled: {reason}")
# Use agent's stream setting if not explicitly provided
if stream is None:
stream = self.stream
@@ -1866,31 +2033,43 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
logging.debug(f"[compaction] skipped (non-fatal): {_ce}")
try:
response_text = await self.llm_instance.get_response_async(
prompt=prompt,
system_prompt=self._build_system_prompt(tools),
chat_history=self.chat_history,
temperature=temperature,
tools=tools,
output_json=output_json,
output_pydantic=output_pydantic,
verbose=self.verbose,
markdown=self.markdown,
reflection=self.self_reflect,
max_reflect=self.max_reflect,
min_reflect=self.min_reflect,
console=self.console,
agent_name=self.name,
agent_role=self.role,
agent_tools=[t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools if tools is not None else self.tools)],
task_name=task_name,
task_description=task_description,
task_id=task_id,
execute_tool_fn=self.execute_tool_async,
parallel_tool_calls=getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
reasoning_steps=reasoning_steps,
stream=stream
)
# C1 — per-call seed forwarding (async path)
llm_kwargs = {
'prompt': prompt,
'system_prompt': self._build_system_prompt(tools),
'chat_history': self.chat_history,
'temperature': temperature,
'tools': tools,
'output_json': output_json,
'output_pydantic': output_pydantic,
'verbose': self.verbose,
'markdown': self.markdown,
'reflection': self.self_reflect,
'max_reflect': self.max_reflect,
'min_reflect': self.min_reflect,
'console': self.console,
'agent_name': self.name,
'agent_role': self.role,
'agent_tools': [t.__name__ if hasattr(t, '__name__') else str(t) for t in (tools if tools is not None else self.tools)],
'task_name': task_name,
'task_description': task_description,
'task_id': task_id,
'execute_tool_fn': self.execute_tool_async,
'parallel_tool_calls': getattr(getattr(self, "execution", None), "parallel_tool_calls", False),
'reasoning_steps': reasoning_steps,
'stream': stream
}
# C1 — per-call seed overrides llm_instance.seed for determinism
if seed is not None:
llm_kwargs['seed'] = seed
# C2 — last-chance cancel check before handing to the LLM
if _cancel is not None and getattr(_cancel, 'is_set', lambda: False)():
reason = getattr(_cancel, 'reason', None) or 'cancelled'
raise InterruptedError(f"Agent chat cancelled: {reason}")
response_text = await self.llm_instance.get_response_async(**llm_kwargs)
self._append_to_chat_history({"role": "assistant", "content": response_text})
@@ -2008,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
@@ -5,12 +5,10 @@ Contains all methods for run/start/launch lifecycle, planning,
and server endpoints. Extracted from agent.py for maintainability.
"""
import os
import time
import json
import logging
import inspect
from praisonaiagents._logging import get_logger
import asyncio
import threading
@@ -126,7 +124,7 @@ class ExecutionMixin:
config=HandoffConfig(context_policy=ContextPolicy.NONE),
)
async def arun(self, prompt: str, **kwargs):
async def arun(self, prompt: str, **kwargs) -> Optional[str]:
"""Async version of run() - silent, non-streaming, returns structured result.
Production-friendly async execution. Does not stream or display output.
@@ -9,7 +9,6 @@ for maintainability.
import os
import logging
from typing import Optional
from praisonaiagents._logging import get_logger
# Fallback helpers to avoid circular imports
def _get_console():
@@ -42,7 +41,7 @@ logger = logging.getLogger(__name__)
import contextlib
from typing import List, Optional, Any, Dict, Generator, TYPE_CHECKING
from typing import Optional, Dict, Generator, TYPE_CHECKING
from collections import OrderedDict
if TYPE_CHECKING:
@@ -82,7 +81,7 @@ class MemoryMixin:
with self._history_lock:
self.chat_history.append({"role": role, "content": content})
def _add_to_chat_history_if_not_duplicate(self, role, content):
def _add_to_chat_history_if_not_duplicate(self, role, content) -> bool:
"""Thread-safe method to add messages to chat history only if not duplicate.
Atomically checks for duplicate and adds message under the same lock to prevent TOCTOU races.
@@ -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]
@@ -194,6 +194,21 @@ class ToolExecutionMixin:
if res.output and res.output.modified_data:
arguments.update(res.output.modified_data)
# C4 — optional tool-argument validation via ToolValidatorProtocol.
# Zero overhead when not set. Users wire via `agent._tool_validator = MyValidator()`.
_validator = getattr(self, '_tool_validator', None)
if _validator is not None:
try:
_vres = _validator.validate_args(function_name, arguments)
if _vres is not None and not getattr(_vres, 'valid', True):
_errs = "; ".join(getattr(_vres, 'errors', []) or ["validation failed"])
logging.warning(
f"Tool {function_name} args rejected by validator: {_errs}"
)
return f"Tool arguments rejected: {_errs}"
except Exception as _ve: # noqa: BLE001 — never break tool exec on validator bug
logging.debug(f"Tool validator raised; skipping validation: {_ve}")
# P8/G11: Apply tool timeout if configured
tool_timeout = getattr(self, '_tool_timeout', None)
if tool_timeout and tool_timeout > 0:
@@ -758,11 +773,9 @@ class ToolExecutionMixin:
pass
if func is None:
# If not found in tools or registry, try globals and main
func = globals().get(function_name)
if not func:
import __main__
func = getattr(__main__, function_name, None)
# Tool not found in declared tools or registry — do not fall back to
# globals() or __main__ as that allows undeclared callables to execute.
pass
if func:
try:
@@ -126,7 +126,24 @@ class _AgentServerRegistry:
thread = threading.Thread(target=run_server, daemon=True)
thread.start()
ready_event.wait(timeout=5.0) # Deterministic wait instead of sleep(0.5)
# Check for configurable timeout via environment variable
try:
timeout = float(os.environ.get("PRAISONAI_SERVER_READY_TIMEOUT", "5.0"))
except ValueError:
logger.warning("Invalid PRAISONAI_SERVER_READY_TIMEOUT value. Using default 5.0s.")
timeout = 5.0
became_ready = ready_event.wait(timeout=timeout)
if not became_ready:
logger.warning(
"Agent server on port %s did not become ready within %.1fs. "
"Proceeding, but some features may not work correctly. "
"Check server logs for startup errors.",
port,
timeout,
)
return True
@@ -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
@@ -14,6 +14,13 @@ Quick start::
async def authorize(self, identity, resource, action):
return "owner" in identity.roles
Subscription auth (lazy-loaded)::
from praisonaiagents.auth import resolve_subscription_credentials
creds = resolve_subscription_credentials("claude-code")
print(creds.api_key, creds.headers)
"""
from .protocols import (
@@ -25,7 +32,36 @@ from .protocols import (
__all__ = [
"AuthIdentity",
"AuthBackendProtocol",
"AuthBackendProtocol",
"WorkspaceContextProtocol",
"AuthConfig",
# Subscription auth (lazy-loaded)
"SubscriptionAuthProtocol",
"SubscriptionCredentials",
"register_subscription_provider",
"list_subscription_providers",
"resolve_subscription_credentials",
"get_subscription_provider",
]
def __getattr__(name):
"""Lazy-load subscription auth to avoid import overhead."""
if name in (
"SubscriptionAuthProtocol",
"SubscriptionCredentials",
"register_subscription_provider",
"list_subscription_providers",
"resolve_subscription_credentials",
"get_subscription_provider",
):
from .subscription import (
SubscriptionAuthProtocol,
SubscriptionCredentials,
register_subscription_provider,
list_subscription_providers,
resolve_subscription_credentials,
get_subscription_provider,
)
return locals()[name]
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
@@ -0,0 +1,35 @@
"""Subscription / OAuth auth for LLM providers.
Opt-in, zero-overhead. Lazy-loaded via __getattr__ so importing
praisonaiagents stays fast.
"""
from __future__ import annotations
__all__ = [
"SubscriptionAuthProtocol",
"SubscriptionCredentials",
"register_subscription_provider",
"list_subscription_providers",
"resolve_subscription_credentials",
"get_subscription_provider",
]
def __getattr__(name):
if name in ("SubscriptionAuthProtocol", "SubscriptionCredentials"):
from .protocols import SubscriptionAuthProtocol, SubscriptionCredentials
return locals()[name]
if name in (
"register_subscription_provider",
"list_subscription_providers",
"resolve_subscription_credentials",
"get_subscription_provider",
):
from .registry import (
register_subscription_provider,
list_subscription_providers,
resolve_subscription_credentials,
get_subscription_provider,
)
return locals()[name]
raise AttributeError(name)
@@ -0,0 +1,202 @@
"""Claude Code OAuth — read ~/.claude/.credentials.json or macOS Keychain."""
from __future__ import annotations
import json
import os
import re
import subprocess
import sys
import time
import urllib.parse
import urllib.request
from pathlib import Path
from typing import Any, Dict, Optional
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
from .registry import register_subscription_provider
_OAUTH_CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
_TOKEN_ENDPOINTS = (
"https://platform.claude.com/v1/oauth/token",
"https://console.anthropic.com/v1/oauth/token",
)
_CLAUDE_CODE_VERSION_FALLBACK = "2.1.74"
def _detect_claude_code_version() -> str:
"""Detect the installed Claude Code version, fall back to a static constant.
Anthropic's OAuth infrastructure validates the user-agent version and may
reject requests with a version that's too old. Detecting dynamically means
users who keep Claude Code updated never hit stale-version 400s.
"""
cached = getattr(_detect_claude_code_version, "_cache", None)
if cached:
return cached
try:
out = subprocess.run(
["claude", "--version"], capture_output=True, text=True, timeout=2, check=False
)
match = re.match(r"(\d+\.\d+\.\d+)", out.stdout.strip())
version = match.group(1) if match else _CLAUDE_CODE_VERSION_FALLBACK
except Exception:
version = _CLAUDE_CODE_VERSION_FALLBACK
_detect_claude_code_version._cache = version
return version
_CLI_USER_AGENT_FALLBACK = f"claude-cli/{_CLAUDE_CODE_VERSION_FALLBACK} (external, cli)"
def _read_keychain_credentials() -> Optional[Dict[str, Any]]:
"""Read 'Claude Code-credentials' entry from macOS Keychain.
Returns dict with accessToken/refreshToken/expiresAt or None.
"""
if sys.platform != "darwin":
return None
import subprocess
try:
out = subprocess.run(
["security", "find-generic-password", "-s", "Claude Code-credentials", "-w"],
capture_output=True, text=True, timeout=5, check=False,
)
if out.returncode != 0 or not out.stdout.strip():
return None
data = json.loads(out.stdout)
oauth = data.get("claudeAiOauth") or {}
if oauth.get("accessToken"):
return {
"accessToken": oauth["accessToken"],
"refreshToken": oauth.get("refreshToken", ""),
"expiresAt": int(oauth.get("expiresAt", 0)),
"source": "claude-code-keychain",
}
except Exception:
return None
return None
def _read_file_credentials() -> Optional[Dict[str, Any]]:
"""Read ~/.claude/.credentials.json."""
path = Path.home() / ".claude" / ".credentials.json"
if not path.exists():
return None
try:
data = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return None
oauth = data.get("claudeAiOauth") or {}
if not oauth.get("accessToken"):
return None
return {
"accessToken": oauth["accessToken"],
"refreshToken": oauth.get("refreshToken", ""),
"expiresAt": int(oauth.get("expiresAt", 0)),
"source": "claude-code-file",
}
def _is_expiring(expires_at_ms: Optional[int], skew_ms: int = 60_000) -> bool:
if not expires_at_ms:
return False
return int(time.time() * 1000) >= (expires_at_ms - skew_ms)
def _refresh(refresh_token: str) -> Dict[str, Any]:
"""Pure refresh — does not touch local files."""
if not refresh_token:
raise AuthError("no refresh_token; please re-run 'claude /login'")
body = urllib.parse.urlencode({
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": _OAUTH_CLIENT_ID,
}).encode()
last = None
for endpoint in _TOKEN_ENDPOINTS:
req = urllib.request.Request(
endpoint, data=body, method="POST",
headers={
"Content-Type": "application/x-www-form-urlencoded",
"User-Agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
},
)
try:
with urllib.request.urlopen(req, timeout=10) as resp:
payload = json.loads(resp.read().decode())
return {
"accessToken": payload["access_token"],
"refreshToken": payload.get("refresh_token", refresh_token),
"expiresAt": int(time.time() * 1000) + int(payload.get("expires_in", 3600)) * 1000,
}
except Exception as exc:
last = exc
raise AuthError(f"Anthropic refresh failed: {last}")
class ClaudeCodeAuth:
"""Claude Code OAuth subscription auth."""
name = "claude-code"
def resolve_credentials(self) -> SubscriptionCredentials:
# 1. ANTHROPIC_TOKEN / CLAUDE_CODE_OAUTH_TOKEN env (Hermes-style)
for env_key in ("ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"):
tok = os.environ.get(env_key, "").strip()
if tok:
return SubscriptionCredentials(
api_key=tok,
base_url="https://api.anthropic.com",
headers=self.headers_for("https://api.anthropic.com", ""),
auth_scheme="bearer",
source=f"env:{env_key}",
)
# 2. Keychain or file
creds = _read_keychain_credentials() or _read_file_credentials()
if not creds:
raise AuthError(
"No Claude Code credentials found. Install Claude Code and run "
"'claude /login', or set ANTHROPIC_TOKEN."
)
if _is_expiring(creds["expiresAt"]) and creds.get("refreshToken"):
creds.update(_refresh(creds["refreshToken"]))
return SubscriptionCredentials(
api_key=creds["accessToken"],
base_url="https://api.anthropic.com",
headers=self.headers_for("https://api.anthropic.com", ""),
auth_scheme="bearer",
expires_at_ms=creds.get("expiresAt"),
source=creds["source"],
)
def refresh(self) -> SubscriptionCredentials:
creds = _read_keychain_credentials() or _read_file_credentials()
if not creds or not creds.get("refreshToken"):
raise AuthError("cannot refresh: no refresh token available")
new = _refresh(creds["refreshToken"])
return SubscriptionCredentials(
api_key=new["accessToken"],
base_url="https://api.anthropic.com",
headers=self.headers_for("https://api.anthropic.com", ""),
auth_scheme="bearer",
expires_at_ms=new["expiresAt"],
source="claude-code-refreshed",
)
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
# CRITICAL: without these headers Anthropic returns 500s on OAuth tokens.
# Match Hermes' OAuth header set exactly. litellm auto-adds oauth-2025-04-20.
return {
"anthropic-beta": ",".join([
"interleaved-thinking-2025-05-14",
"fine-grained-tool-streaming-2025-05-14",
"context-1m-2025-08-07",
"claude-code-20250219",
]),
"user-agent": f"claude-cli/{_detect_claude_code_version()} (external, cli)",
"x-app": "cli",
}
register_subscription_provider("claude-code", lambda: ClaudeCodeAuth())
@@ -0,0 +1,69 @@
"""Codex / ChatGPT subscription — read ~/.codex/auth.json (or CODEX_HOME)."""
from __future__ import annotations
import json
import os
import time
from pathlib import Path
from typing import Any, Dict, Optional
from .protocols import AuthError, SubscriptionCredentials
from .registry import register_subscription_provider
_CODEX_BASE_URL = "https://chatgpt.com/backend-api/codex"
def _read_codex_tokens() -> Optional[Dict[str, Any]]:
home = os.environ.get("CODEX_HOME", "").strip() or str(Path.home() / ".codex")
auth_path = Path(home).expanduser() / "auth.json"
if not auth_path.is_file():
return None
try:
payload = json.loads(auth_path.read_text(encoding="utf-8"))
except Exception:
return None
tokens = payload.get("tokens") or {}
if not tokens.get("access_token") or not tokens.get("refresh_token"):
return None
return tokens
def _is_jwt_expiring(jwt: str, skew_seconds: int = 60) -> bool:
"""Decode JWT exp claim without verifying signature."""
import base64
try:
_hdr, payload_b64, _sig = jwt.split(".")
pad = "=" * (-len(payload_b64) % 4)
payload = json.loads(base64.urlsafe_b64decode(payload_b64 + pad))
exp = int(payload.get("exp", 0))
return exp and (time.time() + skew_seconds) >= exp
except Exception:
return False # treat unknown as not-expiring; refresh on actual 401
class CodexAuth:
name = "codex"
def resolve_credentials(self) -> SubscriptionCredentials:
# EXPERIMENTAL: Codex uses Responses API, not Chat Completions
# OpenAI chat-completions requests will 404 against this endpoint
raise AuthError(
"Codex auth is experimental and requires custom Responses API transport. "
"Use a standard OpenAI API key or wait for proper Codex support in a future release."
)
def refresh(self) -> SubscriptionCredentials:
# OpenAI Codex tokens are refreshed by the Codex CLI itself; we rely
# on the CLI being run periodically. A direct refresh would require
# replicating their device-flow client — out of scope for v1.
return self.resolve_credentials()
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
return {
"openai-beta": "responses=v1",
"user-agent": "codex-cli/0.1.0 (external)",
}
register_subscription_provider("codex", lambda: CodexAuth())
@@ -0,0 +1,51 @@
"""Gemini CLI OAuth — read ~/.gemini/oauth_creds.json."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, Optional
from .protocols import AuthError, SubscriptionCredentials
from .registry import register_subscription_provider
_GEMINI_BASE_URL = "https://cloudcode-pa.googleapis.com/v1internal"
def _read_gemini_tokens() -> Optional[Dict[str, Any]]:
"""Read Gemini CLI OAuth credentials."""
auth_path = Path.home() / ".gemini" / "oauth_creds.json"
if not auth_path.is_file():
return None
try:
payload = json.loads(auth_path.read_text(encoding="utf-8"))
except Exception:
return None
if not payload.get("access_token"):
return None
return payload
class GeminiCliAuth:
name = "gemini-cli"
def resolve_credentials(self) -> SubscriptionCredentials:
# EXPERIMENTAL: Gemini CLI uses Code Assist API, not OpenAI chat format
# Standard OpenAI chat-completions requests will not work with cloudcode-pa endpoints
raise AuthError(
"Gemini CLI auth is experimental and requires custom Code Assist transport. "
"Use standard Gemini API keys or wait for proper Code Assist support in a future release."
)
def refresh(self) -> SubscriptionCredentials:
# Gemini CLI handles refresh internally
return self.resolve_credentials()
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
return {
"user-agent": "gemini-cli/1.0.0 (external)",
}
register_subscription_provider("gemini-cli", lambda: GeminiCliAuth())
@@ -0,0 +1,41 @@
"""Subscription-auth protocol — what every provider must implement."""
from __future__ import annotations
from dataclasses import dataclass, field
from typing import Any, Dict, Optional, Protocol, runtime_checkable
@dataclass
class SubscriptionCredentials:
"""Resolved credentials ready to inject into an LLM client."""
api_key: str # access_token (or "" for SDK-default chains like AWS)
base_url: str = "" # provider endpoint
headers: Dict[str, str] = field(default_factory=dict) # extra headers (UA, x-app, betas)
auth_scheme: str = "bearer" # "bearer" | "x-api-key" | "sdk-default"
expires_at_ms: Optional[int] = None # epoch ms; None = no expiry tracked
source: str = "" # "claude-code-keychain", "codex-cli-file", etc.
@runtime_checkable
class SubscriptionAuthProtocol(Protocol):
"""Discover, refresh, and inject subscription credentials."""
def resolve_credentials(self) -> SubscriptionCredentials:
"""Return current valid credentials, refreshing if expiring soon.
Raises:
AuthError: if no usable credentials can be resolved.
"""
...
def refresh(self) -> SubscriptionCredentials:
"""Force a refresh and return the new credentials."""
...
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
"""Provider-specific headers (UA, beta flags) keyed off endpoint/model."""
...
class AuthError(RuntimeError):
"""Raised when subscription credentials cannot be resolved."""
@@ -0,0 +1,58 @@
"""Qwen CLI OAuth — read ~/.qwen/oauth_creds.json."""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Dict, Optional
from .protocols import AuthError, SubscriptionCredentials
from .registry import register_subscription_provider
_QWEN_BASE_URL = "https://portal.qwen.ai/v1"
def _read_qwen_tokens() -> Optional[Dict[str, Any]]:
"""Read Qwen CLI OAuth credentials."""
auth_path = Path.home() / ".qwen" / "oauth_creds.json"
if not auth_path.is_file():
return None
try:
payload = json.loads(auth_path.read_text(encoding="utf-8"))
except Exception:
return None
if not payload.get("access_token"):
return None
return payload
class QwenCliAuth:
name = "qwen-cli"
def resolve_credentials(self) -> SubscriptionCredentials:
tokens = _read_qwen_tokens()
if not tokens:
raise AuthError(
"No Qwen CLI credentials at ~/.qwen/oauth_creds.json. "
"Install Qwen CLI and run 'qwen login'."
)
return SubscriptionCredentials(
api_key=tokens["access_token"],
base_url=os.environ.get("PRAISONAI_QWEN_BASE_URL", "").strip() or _QWEN_BASE_URL,
headers=self.headers_for(_QWEN_BASE_URL, ""),
auth_scheme="bearer",
source="qwen-cli-file",
)
def refresh(self) -> SubscriptionCredentials:
# Qwen CLI handles refresh internally
return self.resolve_credentials()
def headers_for(self, base_url: str, model: str) -> Dict[str, str]:
return {
"user-agent": "qwen-cli/1.0.0 (external)",
}
register_subscription_provider("qwen-cli", lambda: QwenCliAuth())
@@ -0,0 +1,64 @@
"""Thread-safe provider registry for subscription auth."""
from __future__ import annotations
import threading
from typing import Callable, Dict, List, Optional
from .protocols import AuthError, SubscriptionAuthProtocol, SubscriptionCredentials
_REGISTRY: Dict[str, Callable[[], SubscriptionAuthProtocol]] = {}
_LOCK = threading.RLock()
_BUILTIN_REGISTERED = False
def register_subscription_provider(
provider_id: str,
factory: Callable[[], SubscriptionAuthProtocol],
) -> None:
"""Register a new subscription-auth provider.
Args:
provider_id: short id e.g. "claude-code", "codex", "gemini-cli".
factory: zero-arg callable returning a SubscriptionAuthProtocol impl.
"""
with _LOCK:
_REGISTRY[provider_id] = factory
def list_subscription_providers() -> List[str]:
_ensure_builtins()
with _LOCK:
return sorted(_REGISTRY)
def resolve_subscription_credentials(provider_id: str) -> SubscriptionCredentials:
"""Look up provider by id and resolve its credentials."""
_ensure_builtins()
with _LOCK:
factory = _REGISTRY.get(provider_id)
if factory is None:
raise AuthError(
f"Unknown subscription provider {provider_id!r}. "
f"Registered: {list_subscription_providers()}"
)
return factory().resolve_credentials()
def get_subscription_provider(provider_id: str) -> Optional[SubscriptionAuthProtocol]:
"""Return a new provider instance for *provider_id*, or None if not registered."""
_ensure_builtins()
with _LOCK:
factory = _REGISTRY.get(provider_id)
return factory() if factory is not None else None
def _ensure_builtins() -> None:
"""Lazy register built-in providers on first use."""
global _BUILTIN_REGISTERED
with _LOCK:
if _BUILTIN_REGISTERED:
return
# Import modules inside the lock to prevent race conditions
from . import claude_code, codex, gemini_cli, qwen_cli # noqa: F401
# Each module registers itself at import time.
_BUILTIN_REGISTERED = True
@@ -42,6 +42,7 @@ class MemoryBackend(str, Enum):
FILE = "file"
SQLITE = "sqlite"
REDIS = "redis"
VALKEY = "valkey"
POSTGRES = "postgres"
MEM0 = "mem0"
MONGODB = "mongodb"
@@ -7,7 +7,7 @@ Inspired by Agno's SessionContextStore pattern.
from dataclasses import dataclass, field, asdict
from typing import Any, Dict, List, Optional
from datetime import datetime
from datetime import datetime, timezone
import json
import logging
from praisonaiagents._logging import get_logger
@@ -97,8 +97,8 @@ class SessionContextTracker:
self._state = SessionState(
session_id=self.session_id,
created_at=datetime.utcnow().isoformat() + "Z",
updated_at=datetime.utcnow().isoformat() + "Z",
created_at=datetime.now(timezone.utc).isoformat(),
updated_at=datetime.now(timezone.utc).isoformat(),
)
def _generate_session_id(self) -> str:
@@ -163,7 +163,7 @@ class SessionContextTracker:
def _mark_updated(self) -> None:
"""Update the updated_at timestamp."""
self._state.updated_at = datetime.utcnow().isoformat() + "Z"
self._state.updated_at = datetime.now(timezone.utc).isoformat()
self._state.turn_count += 1
def to_context_string(self) -> str:
@@ -257,7 +257,7 @@ Current messages take precedence if there's any conflict with this summary.
self._state = SessionState(
session_id=self.session_id,
created_at=self._state.created_at,
updated_at=datetime.utcnow().isoformat() + "Z",
updated_at=datetime.now(timezone.utc).isoformat(),
)
def to_dict(self) -> Dict[str, Any]:
@@ -12,7 +12,7 @@ import os
import logging
from praisonaiagents._logging import get_logger
from dataclasses import dataclass, field, asdict
from datetime import datetime
from datetime import datetime, timezone
from typing import Any, Dict, List, Optional
logger = get_logger(__name__)
@@ -39,7 +39,7 @@ class GradeResult:
input_text: str = ""
output: str = ""
expected_output: Optional[str] = None
timestamp: str = field(default_factory=lambda: datetime.utcnow().isoformat())
timestamp: str = field(default_factory=lambda: datetime.now(timezone.utc).isoformat())
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
@@ -55,7 +55,7 @@ class GradeResult:
input_text=data.get("input_text", ""),
output=data.get("output", ""),
expected_output=data.get("expected_output"),
timestamp=data.get("timestamp", datetime.utcnow().isoformat()),
timestamp=data.get("timestamp", datetime.now(timezone.utc).isoformat()),
)
class BaseLLMGrader:
@@ -71,7 +71,8 @@ class Mem0Adapter:
from mem0 import Memory
except ImportError:
raise ImportError(
"mem0 is not installed. Install with: pip install mem0ai"
"mem0ai is not installed. "
"Run: pip install 'praisonaiagents[memory]'"
)
if self._config:
@@ -361,6 +361,7 @@ Respond with ONLY a valid JSON tool call in this format:
prompt_caching: Optional[bool] = None,
claude_memory: Optional[Union[bool, Any]] = None,
failover_manager: Optional[FailoverManagerProtocol] = None,
auth: Optional[str] = None, # NEW: "claude-code", "codex", "gemini-cli", "qwen-cli"
**extra_settings
):
# Configure logging only once at the class level
@@ -437,6 +438,10 @@ Respond with ONLY a valid JSON tool call in this format:
self._max_retries = extra_settings.get('max_retries', 3)
self._retry_delay = extra_settings.get('retry_delay', 60) # Default 60 seconds
# Subscription auth
self._auth_provider_id = auth
self._cached_subscription_creds = None # SubscriptionCredentials | None
# Failover management
self._failover_manager = failover_manager
self._current_profile = None # Track current auth profile for failover
@@ -715,6 +720,35 @@ Respond with ONLY a valid JSON tool call in this format:
logging.info(f"Failover: switching from {self.model} to {profile.model}")
self.model = profile.model
def _resolve_subscription_creds(self):
"""Lazy resolve + cache subscription credentials, checking expiration."""
if not self._auth_provider_id:
return None
# Check if cached credentials are expired
if (self._cached_subscription_creds and
self._cached_subscription_creds.expires_at_ms and
self._cached_subscription_creds.expires_at_ms <= int(time.time() * 1000)):
self._cached_subscription_creds = None
if self._cached_subscription_creds is None:
from ..auth import resolve_subscription_credentials
self._cached_subscription_creds = resolve_subscription_credentials(
self._auth_provider_id,
)
return self._cached_subscription_creds
def _refresh_subscription_creds(self):
"""Force refresh of subscription credentials."""
if not self._auth_provider_id:
return None
from ..auth.subscription.registry import get_subscription_provider
provider = get_subscription_provider(self._auth_provider_id)
if provider is None:
return None
self._cached_subscription_creds = provider.refresh()
return self._cached_subscription_creds
def _call_with_retry(self, func, *args, **kwargs):
"""Call a function with automatic retry on rate limit errors and failover support.
@@ -751,6 +785,21 @@ Respond with ONLY a valid JSON tool call in this format:
last_error = e
error_str = str(e)
# Check for auth errors and try refreshing subscription credentials
if category == "auth" and self._auth_provider_id and attempt == 0:
try:
logging.info("Authentication error detected - attempting credential refresh")
refreshed_creds = self._refresh_subscription_creds()
if refreshed_creds:
# Update parameters with refreshed credentials (don't clear cache)
kwargs = self._build_completion_params(**kwargs)
# Retry immediately with refreshed credentials
can_retry = True
retry_delay = 0.0
logging.info("Subscription credentials refreshed, retrying...")
except Exception as refresh_error:
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
# Failover: mark failure and try next profile (do this before early exit)
if self._failover_manager and self._current_profile:
is_rate_limit = (category == "rate_limit")
@@ -840,6 +889,21 @@ Respond with ONLY a valid JSON tool call in this format:
last_error = e
error_str = str(e)
# Check for auth errors and try refreshing subscription credentials
if category == "auth" and self._auth_provider_id and attempt == 0:
try:
logging.info("Authentication error detected - attempting credential refresh")
refreshed_creds = self._refresh_subscription_creds()
if refreshed_creds:
# Update parameters with refreshed credentials (don't clear cache)
kwargs = self._build_completion_params(**kwargs)
# Retry immediately with refreshed credentials
can_retry = True
retry_delay = 0.0
logging.info("Subscription credentials refreshed, retrying...")
except Exception as refresh_error:
logging.warning(f"Failed to refresh subscription credentials: {refresh_error}")
# Failover: mark failure and try next profile (do this before early exit)
if self._failover_manager and self._current_profile:
is_rate_limit = (category == "rate_limit")
@@ -4593,6 +4657,18 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
filtered_extra_settings = {k: v for k, v in self.extra_settings.items() if k != 'metrics'}
params.update(filtered_extra_settings)
# Inject subscription credentials if auth provider is set
creds = self._resolve_subscription_creds()
if creds:
# Use litellm's native OAuth detection (auto-detects sk-ant-oat-* and switches to Bearer)
params["api_key"] = creds.api_key
if creds.base_url:
params["base_url"] = creds.base_url
if creds.headers:
extra_headers = dict(params.get("extra_headers") or {})
extra_headers.update(creds.headers)
params["extra_headers"] = extra_headers
# Override with any provided parameters
params.update(override_params)
@@ -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:
@@ -40,7 +40,8 @@ def create_mem0_memory_adapter(**kwargs) -> MemoryProtocol:
import mem0
except ImportError:
raise ImportError(
"mem0 is required for mem0 adapter. Install with: pip install mem0ai"
"mem0ai is not installed. "
"Run: pip install 'praisonaiagents[memory]'"
)
return Mem0MemoryAdapter(mem0_config=kwargs)
@@ -363,7 +364,7 @@ class MongoDBMemoryAdapter:
def store_short_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
"""Store in MongoDB short-term collection."""
from datetime import datetime
from datetime import datetime, timezone
import time
doc_id = str(time.time_ns())
@@ -371,7 +372,7 @@ class MongoDBMemoryAdapter:
"_id": doc_id,
"content": text,
"metadata": metadata or {},
"created_at": datetime.now(datetime.timezone.utc),
"created_at": datetime.now(timezone.utc),
"memory_type": "short_term"
}
@@ -395,7 +396,7 @@ class MongoDBMemoryAdapter:
def store_long_term(self, text: str, metadata: Optional[Dict[str, Any]] = None, **kwargs) -> str:
"""Store in MongoDB long-term collection."""
from datetime import datetime
from datetime import datetime, timezone
import time
doc_id = str(time.time_ns())
@@ -403,7 +404,7 @@ class MongoDBMemoryAdapter:
"_id": doc_id,
"content": text,
"metadata": metadata or {},
"created_at": datetime.now(datetime.timezone.utc),
"created_at": datetime.now(timezone.utc),
"memory_type": "long_term"
}
@@ -7,7 +7,7 @@ import threading
from typing import Any, Dict, List, Optional, Union, Literal
import logging
from praisonaiagents._logging import get_logger
from datetime import datetime
from datetime import datetime, timezone
# Decomposed memory functionality - imported as mixins for backward compatibility
from .storage import StorageMixin
@@ -143,6 +143,9 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
adapter_name = provider_mapping.get(provider, provider)
# Try to get preferred adapter, fallback to available ones
adapter = None
provider_explicitly_requested = provider != "rag" # "rag" is default, others are explicit
try:
adapter_config = self._get_adapter_config_for_provider(adapter_name)
adapter = get_memory_adapter(adapter_name, **adapter_config)
@@ -154,6 +157,37 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
adapter = None
if adapter is None:
# If the provider was explicitly requested, try to give a helpful error message
if provider_explicitly_requested:
try:
# Try the factory function directly to get the specific ImportError message
from .adapters.factories import (
create_mem0_memory_adapter,
create_chroma_memory_adapter,
create_mongodb_memory_adapter,
)
factory_map = {
"mem0": create_mem0_memory_adapter,
"chroma": create_chroma_memory_adapter,
"mongodb": create_mongodb_memory_adapter,
}
if adapter_name in factory_map:
# Call the factory to get ImportError with installation hint
# or capture successful adapter creation
adapter_config = self._get_adapter_config_for_provider(adapter_name)
adapter = factory_map[adapter_name](**adapter_config)
except ImportError:
# Re-raise ImportError with installation instructions
raise
# All other exceptions (e.g., ValueError for missing API key)
# propagate naturally, preserving backward compatibility
# If factory succeeded, use that adapter
if adapter is not None:
self.memory_adapter = adapter
self.provider = adapter_name
return
# Fallback to first available adapter
self._log_verbose(f"Provider '{adapter_name}' not available, trying fallbacks")
# Try each fallback preference individually
@@ -658,7 +692,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
"_id": ident,
"content": text,
"metadata": metadata,
"created_at": datetime.utcnow(),
"created_at": datetime.now(timezone.utc),
"memory_type": "short_term"
}
self.mongo_short_term.insert_one(doc)
@@ -884,7 +918,7 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
"_id": ident,
"content": text,
"metadata": metadata,
"created_at": datetime.utcnow(),
"created_at": datetime.now(timezone.utc),
"memory_type": "long_term"
}
@@ -1407,14 +1441,14 @@ class Memory(StorageMixin, SearchMixin, MemoryCoreMixin):
self.mem0_client.add(text, user_id=user_id, metadata=meta)
elif self.use_mongodb and hasattr(self, "mongo_users"):
try:
from datetime import datetime
from datetime import datetime, timezone
ident = str(time.time_ns())
doc = {
"_id": ident,
"user_id": user_id,
"content": text,
"metadata": meta,
"created_at": datetime.utcnow()
"created_at": datetime.now(timezone.utc)
}
self.mongo_users.insert_one(doc)
self._log_verbose(f"Successfully stored user memory for {user_id}")
@@ -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,
}
@@ -90,7 +90,43 @@ def __getattr__(name: str):
from .hierarchy import ExtendedSessionData
_module_cache[name] = ExtendedSessionData
return ExtendedSessionData
# W1 — Identity resolver
if name == "IdentityResolverProtocol":
from .identity import IdentityResolverProtocol
_module_cache[name] = IdentityResolverProtocol
return IdentityResolverProtocol
if name == "IdentityLink":
from .identity import IdentityLink
_module_cache[name] = IdentityLink
return IdentityLink
if name == "InMemoryIdentityResolver":
from .identity import InMemoryIdentityResolver
_module_cache[name] = InMemoryIdentityResolver
return InMemoryIdentityResolver
if name == "FileIdentityResolver":
from .identity import FileIdentityResolver
_module_cache[name] = FileIdentityResolver
return FileIdentityResolver
# W1 — Task-local session context
if name == "SessionContext":
from .context import SessionContext
_module_cache[name] = SessionContext
return SessionContext
if name == "set_session_context":
from .context import set_session_context
_module_cache[name] = set_session_context
return set_session_context
if name == "get_session_context":
from .context import get_session_context
_module_cache[name] = get_session_context
return get_session_context
if name == "clear_session_context":
from .context import clear_session_context
_module_cache[name] = clear_session_context
return clear_session_context
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -105,4 +141,13 @@ __all__ = [
"get_hierarchical_session_store",
"SessionSnapshot",
"ExtendedSessionData",
# W1
"IdentityResolverProtocol",
"IdentityLink",
"InMemoryIdentityResolver",
"FileIdentityResolver",
"SessionContext",
"set_session_context",
"get_session_context",
"clear_session_context",
]
@@ -0,0 +1,112 @@
"""Task-local session context for concurrent message handlers.
W1 — Replaces process-global ``os.environ`` based session metadata
with ``contextvars.ContextVar`` so that two messages handled
concurrently never overwrite each other's platform / chat / user IDs.
Each ``set_session_context`` call returns a token; pass it to
``clear_session_context`` in a ``finally`` block to restore the
previous state.
Usage in a bot handler::
from praisonaiagents.session.context import (
set_session_context,
clear_session_context,
get_session_context,
)
token = set_session_context(
platform="telegram",
chat_id="100",
user_id="alice",
unified_user_id="alice-global",
)
try:
# ... agent handles message; tools can call get_session_context()
ctx = get_session_context()
finally:
clear_session_context(token)
"""
from __future__ import annotations
from contextvars import ContextVar, Token
from dataclasses import asdict, dataclass
@dataclass(frozen=True)
class SessionContext:
"""Snapshot of the current message-handling context."""
platform: str = ""
chat_id: str = ""
chat_name: str = ""
thread_id: str = ""
user_id: str = ""
user_name: str = ""
unified_user_id: str = ""
def to_dict(self) -> dict:
return asdict(self)
@classmethod
def from_dict(cls, d: dict) -> "SessionContext":
return cls(
platform=d.get("platform", ""),
chat_id=d.get("chat_id", ""),
chat_name=d.get("chat_name", ""),
thread_id=d.get("thread_id", ""),
user_id=d.get("user_id", ""),
user_name=d.get("user_name", ""),
unified_user_id=d.get("unified_user_id", ""),
)
_CTX: ContextVar[SessionContext] = ContextVar(
"praisonai_session_context", default=SessionContext()
)
def set_session_context(
platform: str = "",
chat_id: str = "",
chat_name: str = "",
thread_id: str = "",
user_id: str = "",
user_name: str = "",
unified_user_id: str = "",
) -> Token:
"""Set the task-local session context. Returns a token for ``clear``."""
ctx = SessionContext(
platform=platform,
chat_id=chat_id,
chat_name=chat_name,
thread_id=thread_id,
user_id=user_id,
user_name=user_name,
unified_user_id=unified_user_id,
)
return _CTX.set(ctx)
def get_session_context() -> SessionContext:
"""Return the current task's session context (empty if unset)."""
return _CTX.get()
def clear_session_context(token: Token) -> None:
"""Restore the previous session context using the token from ``set``."""
try:
_CTX.reset(token)
except (LookupError, ValueError):
# Token from a different context; reset to empty
_CTX.set(SessionContext())
__all__ = [
"SessionContext",
"set_session_context",
"get_session_context",
"clear_session_context",
]
@@ -0,0 +1,231 @@
"""Cross-platform identity resolution for agents.
W1 — Optional, opt-in mapping from ``(platform, platform_user_id)``
to a single ``unified_user_id`` so the same human is recognised across
Telegram, Discord, Slack, etc.
By default (no links registered) ``resolve()`` returns the
platform-prefixed string unchanged — no surprises, no privacy leak.
Linking is performed explicitly, typically after a DM-based pairing
flow has cryptographically verified ownership of both accounts.
Usage::
from praisonaiagents.session.identity import InMemoryIdentityResolver
resolver = InMemoryIdentityResolver()
resolver.link("telegram", "12345", "alice")
resolver.link("discord", "snowflake-1", "alice")
assert resolver.resolve("telegram", "12345") == "alice"
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
import threading
from dataclasses import dataclass
from pathlib import Path
from typing import List, Optional, Protocol, runtime_checkable
logger = logging.getLogger(__name__)
@dataclass(frozen=True)
class IdentityLink:
"""Immutable mapping from a platform-scoped user ID to a unified ID."""
platform: str
platform_user_id: str
unified_user_id: str
@runtime_checkable
class IdentityResolverProtocol(Protocol):
"""Maps platform-scoped user IDs to a unified identity.
Implementations MUST be thread-safe. Implementations SHOULD return
``f"{platform}:{platform_user_id}"`` for unlinked users so callers
never need to special-case the unlinked path.
"""
def resolve(self, platform: str, platform_user_id: str) -> str:
"""Return ``unified_user_id`` for the given platform user.
If no link is registered, return ``f"{platform}:{platform_user_id}"``.
"""
...
def link(
self, platform: str, platform_user_id: str, unified_user_id: str
) -> None:
"""Register a mapping. Overwrites any existing mapping for the same
``(platform, platform_user_id)`` pair.
"""
...
def unlink(self, platform: str, platform_user_id: str) -> None:
"""Remove a mapping. No-op when no link exists."""
...
def links_for(self, unified_user_id: str) -> List[IdentityLink]:
"""Return all links pointing to the given unified ID."""
...
class InMemoryIdentityResolver:
"""Thread-safe in-memory ``IdentityResolverProtocol`` implementation.
Suitable for tests and single-process deployments. Production
deployments with multiple processes should use a persistent
implementation backed by SQLite, Redis, or a database.
"""
def __init__(self) -> None:
self._links: dict[tuple[str, str], str] = {}
self._lock = threading.RLock()
def resolve(self, platform: str, platform_user_id: str) -> str:
with self._lock:
return self._links.get(
(platform, platform_user_id),
f"{platform}:{platform_user_id}",
)
def link(
self, platform: str, platform_user_id: str, unified_user_id: str
) -> None:
with self._lock:
self._links[(platform, platform_user_id)] = unified_user_id
def unlink(self, platform: str, platform_user_id: str) -> None:
with self._lock:
self._links.pop((platform, platform_user_id), None)
def links_for(self, unified_user_id: str) -> List[IdentityLink]:
with self._lock:
return [
IdentityLink(p, u, unified_user_id)
for (p, u), uid in self._links.items()
if uid == unified_user_id
]
_DEFAULT_IDENTITY_PATH = Path(
os.environ.get(
"PRAISONAI_IDENTITY_PATH",
os.path.expanduser("~/.praisonai/identity.json"),
)
)
class FileIdentityResolver(InMemoryIdentityResolver):
"""JSON-file-backed ``IdentityResolverProtocol`` implementation.
Loads links from disk on construction; persists on every mutation
via atomic temp-file + ``os.replace``. Thread-safe.
Default path: ``$PRAISONAI_IDENTITY_PATH`` or ``~/.praisonai/identity.json``.
Storage format::
{
"links": {
"telegram::12345": "alice",
"discord::snowflake-1": "alice"
}
}
"""
def __init__(self, path: Optional[Path | str] = None) -> None:
super().__init__()
self._path: Path = Path(path) if path else _DEFAULT_IDENTITY_PATH
# Serialises concurrent disk writes so that two simultaneous
# link()/unlink() calls cannot interleave their os.replace() and
# lose the second writer's data.
self._flush_lock = threading.Lock()
self._load()
@staticmethod
def _encode_key(platform: str, platform_user_id: str) -> str:
return f"{platform}::{platform_user_id}"
@staticmethod
def _decode_key(encoded: str) -> tuple[str, str]:
platform, _, user = encoded.partition("::")
return platform, user
def _load(self) -> None:
try:
if not self._path.exists():
return
data = json.loads(self._path.read_text(encoding="utf-8"))
links = data.get("links", {}) if isinstance(data, dict) else {}
with self._lock:
self._links = {
self._decode_key(k): v
for k, v in links.items()
if isinstance(v, str)
}
except (OSError, json.JSONDecodeError) as e:
logger.warning("FileIdentityResolver: failed to load %s: %s", self._path, e)
def _flush(self) -> None:
try:
self._path.parent.mkdir(parents=True, exist_ok=True)
# Hold _flush_lock for the entire write (snapshot + disk I/O) so
# that concurrent calls always write the latest in-memory state and
# the last os.replace() wins rather than an older snapshot.
with self._flush_lock:
with self._lock:
payload = {
"links": {
self._encode_key(p, u): uid
for (p, u), uid in self._links.items()
}
}
# Keep the _lock held across the entire write operation to prevent
# TOCTOU race where a concurrent link() could modify _links between
# payload snapshot and os.replace() completion.
fd, tmp = tempfile.mkstemp(
dir=str(self._path.parent), prefix=".identity-", suffix=".tmp"
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f, indent=2, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, self._path)
try:
os.chmod(self._path, 0o600)
except OSError:
pass
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except OSError as e:
logger.warning("FileIdentityResolver: failed to flush %s: %s", self._path, e)
def link(
self, platform: str, platform_user_id: str, unified_user_id: str
) -> None:
super().link(platform, platform_user_id, unified_user_id)
self._flush()
def unlink(self, platform: str, platform_user_id: str) -> None:
super().unlink(platform, platform_user_id)
self._flush()
__all__ = [
"IdentityLink",
"IdentityResolverProtocol",
"InMemoryIdentityResolver",
"FileIdentityResolver",
]
@@ -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:
@@ -45,6 +45,10 @@ __all__ = [
# Redaction
"redact_dict",
"REDACT_KEYS",
# C7 — PII redaction for LLM egress
"scrub_pii_text",
"enable_pii_redaction",
"disable_pii_redaction",
# Context events (for replay)
"ContextEvent",
"ContextEventType",
@@ -79,6 +83,10 @@ def __getattr__(name: str):
if name in ("redact_dict", "REDACT_KEYS"):
from .redact import redact_dict, REDACT_KEYS
return locals()[name]
if name in ("scrub_pii_text", "enable_pii_redaction", "disable_pii_redaction"):
from .redact import scrub_pii_text, enable_pii_redaction, disable_pii_redaction
return locals()[name]
# Context events (for replay)
if name in ("ContextEvent", "ContextEventType"):
@@ -5,8 +5,9 @@ Provides deterministic redaction of sensitive data in tool arguments
and results. Uses only stdlib for zero dependencies.
"""
from typing import Any, Dict, Set
from typing import Any, Dict, Optional, Set
import re
import threading
# Keys that should always be redacted (case-insensitive matching)
REDACT_KEYS: Set[str] = {
@@ -119,6 +120,109 @@ def _redact_key_value(key: str, value: Any) -> Any:
return _redact_value(value)
# ────────────────────────────────────────────────────────────────────────────
# C7 — PII redaction for LLM egress (opt-in BEFORE_LLM middleware)
# ────────────────────────────────────────────────────────────────────────────
# Additional value-pattern rules for strings that look like secrets
# even when no key=value pair surrounds them.
_VALUE_PATTERNS = (
# OpenAI-style API keys
(re.compile(r"\bsk-[A-Za-z0-9]{12,}\b"), "[REDACTED]"),
# US SSN
(re.compile(r"\b\d{3}-\d{2}-\d{4}\b"), "[REDACTED-SSN]"),
# Credit card — canonical 4-group format only (e.g. "4111 1111 1111 1111").
# @claude: The bare \b\d{16}\b branch was removed — it matches ANY 16-digit sequence:
# UNIX microsecond timestamps, distributed trace IDs, order/invoice numbers, phone
# numbers — causing false-positive redactions in LLM tool outputs and RAG results.
# Unformatted 16-digit strings are indistinguishable from non-card IDs at regex level.
# The 4-group pattern covers real-world formatted card numbers with near-zero FP rate.
(re.compile(r"\b(?:\d{4}[ -]){3}\d{4}\b"), "[REDACTED-CC]"),
# Email (optional — often safe, but default-scrub for compliance)
(re.compile(r"\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b"), "[REDACTED-EMAIL]"),
)
def scrub_pii_text(text: str) -> str:
"""Redact API keys, passwords, SSNs, credit cards, and emails from free text.
Reuses REDACT_KEYS for ``key=value`` pairs and adds value-pattern rules
for naked secrets. Zero dependency (stdlib re only).
Args:
text: Input string — returned unchanged if empty or None.
Returns:
Scrubbed string with literal secrets replaced by ``[REDACTED*]``.
"""
if not text:
return text
result = redact_string(text, enabled=True)
for pat, repl in _VALUE_PATTERNS:
result = pat.sub(repl, result)
return result
# Module-level state for idempotent enable/disable
_PII_HOOK_ID: Optional[str] = None
_PII_HOOK_LOCK = threading.Lock()
def _pii_before_llm_hook(event_data):
"""BEFORE_LLM hook that scrubs every message's content in-place."""
try:
messages = getattr(event_data, "messages", None) or []
modified = []
for m in messages:
if isinstance(m, dict) and isinstance(m.get("content"), str):
m = {**m, "content": scrub_pii_text(m["content"])}
modified.append(m)
# Import locally to avoid module-level hooks dep (keeps redact.py lightweight)
from ..hooks.types import HookResult
return HookResult(decision="allow", modified_input={"messages": modified})
except Exception:
# Never block the LLM call on a scrubber bug
from ..hooks.types import HookResult
return HookResult(decision="allow")
def enable_pii_redaction() -> str:
"""Register a BEFORE_LLM hook that scrubs secrets from every message.
Idempotent — calling twice leaves exactly one hook registered.
Returns:
The hook id (useful for :func:`disable_pii_redaction`).
"""
global _PII_HOOK_ID
with _PII_HOOK_LOCK:
if _PII_HOOK_ID is not None:
return _PII_HOOK_ID
from ..hooks.registry import get_default_registry
from ..hooks.types import HookEvent
reg = get_default_registry()
_PII_HOOK_ID = reg.register_function(
event=HookEvent.BEFORE_LLM,
func=_pii_before_llm_hook,
name="praisonaiagents.pii_redactor",
)
return _PII_HOOK_ID
def disable_pii_redaction() -> bool:
"""Unregister the PII-redaction hook. No-op if never enabled."""
global _PII_HOOK_ID
if _PII_HOOK_ID is None:
return False
from ..hooks.registry import get_default_registry
reg = get_default_registry()
try:
reg.unregister(_PII_HOOK_ID)
finally:
_PII_HOOK_ID = None
return True
def redact_string(text: str, enabled: bool = True) -> str:
"""
Redact potential secrets from a string.
@@ -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")
+24 -4
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.35"
version = "1.6.43"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -30,7 +30,7 @@ mcp = [
memory = [
"chromadb>=1.0.0",
"litellm>=1.81.0,<=1.82.6",
"litellm>=1.83.14",
]
knowledge = [
@@ -48,7 +48,7 @@ graph = [
# Add LLM dependencies
llm = [
"litellm>=1.81.0,<=1.82.6",
"litellm>=1.83.14",
"pydantic>=2.10.0"
]
@@ -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,8 +128,12 @@ all = [
"praisonaiagents[telemetry]",
"praisonaiagents[mongodb]",
"praisonaiagents[auth]",
"praisonaiagents[sandbox]",
"praisonaiagents[search]",
"praisonaiagents[crawl]"
"praisonaiagents[crawl]",
"praisonaiagents[autonomy]",
"praisonaiagents[os]",
"praisonaiagents[a2ui]",
]
[tool.setuptools.packages.find]
@@ -0,0 +1,158 @@
#!/usr/bin/env python3
"""
Validate subscription auth fixes without pytest dependency.
"""
import sys
import os
# Add current directory to Python path
sys.path.insert(0, '.')
sys.path.insert(0, os.path.join(os.getcwd()))
def test_imports():
"""Test that imports work correctly."""
print("Testing imports...")
try:
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials, AuthError
print("✅ Protocol imports work")
from praisonaiagents.auth.subscription.claude_code import ClaudeCodeAuth
print("✅ Claude Code auth imports work")
from praisonaiagents.auth.subscription.registry import resolve_subscription_credentials
print("✅ Registry imports work")
except Exception as e:
print(f"❌ Import test failed: {e}")
return False
return True
def test_codex_experimental():
"""Test that Codex raises experimental error."""
print("\nTesting Codex experimental error...")
try:
from praisonaiagents.auth.subscription.codex import CodexAuth
auth = CodexAuth()
try:
auth.resolve_credentials()
print("❌ Codex should have raised experimental error")
return False
except Exception as e:
if "experimental" in str(e).lower():
print("✅ Codex correctly raises experimental error")
return True
else:
print(f"❌ Codex raised wrong error: {e}")
return False
except Exception as e:
print(f"❌ Codex test failed: {e}")
return False
def test_gemini_experimental():
"""Test that Gemini CLI raises experimental error."""
print("\nTesting Gemini CLI experimental error...")
try:
from praisonaiagents.auth.subscription.gemini_cli import GeminiCliAuth
auth = GeminiCliAuth()
try:
auth.resolve_credentials()
print("❌ Gemini CLI should have raised experimental error")
return False
except Exception as e:
if "experimental" in str(e).lower():
print("✅ Gemini CLI correctly raises experimental error")
return True
else:
print(f"❌ Gemini CLI raised wrong error: {e}")
return False
except Exception as e:
print(f"❌ Gemini CLI test failed: {e}")
return False
def test_header_simplification():
"""Test that Claude Code headers are simplified."""
print("\nTesting Claude Code header simplification...")
try:
from praisonaiagents.auth.subscription.claude_code import ClaudeCodeAuth
auth = ClaudeCodeAuth()
headers = auth.headers_for("https://api.anthropic.com", "claude-3-haiku")
# Should only have interleaved-thinking, not oauth header (litellm handles that)
if "anthropic-beta" in headers:
beta_header = headers["anthropic-beta"]
if beta_header == "interleaved-thinking-2025-05-14":
print("✅ Claude Code headers correctly simplified")
return True
else:
print(f"❌ Unexpected beta header value: {beta_header}")
return False
else:
print("❌ anthropic-beta header missing")
return False
except Exception as e:
print(f"❌ Header test failed: {e}")
return False
def test_agent_construction():
"""Test basic agent construction with auth parameter."""
print("\nTesting Agent construction with auth...")
try:
from praisonaiagents import Agent
# This should work (though credential resolution will fail without actual creds)
agent = Agent(name="test", auth="claude-code")
print("✅ Agent construction with auth parameter works")
# Test invalid provider
try:
agent = Agent(name="test", auth="invalid-provider")
print("⚠️ Invalid provider didn't fail at construction (will fail at runtime)")
except Exception:
print("✅ Invalid provider handling works")
return True
except Exception as e:
print(f"❌ Agent construction test failed: {e}")
return False
def main():
"""Run all validation tests."""
print("=== Validating Subscription Auth Fixes ===\n")
tests = [
test_imports,
test_codex_experimental,
test_gemini_experimental,
test_header_simplification,
test_agent_construction,
]
passed = 0
total = len(tests)
for test in tests:
if test():
passed += 1
else:
print()
print(f"\n=== Results: {passed}/{total} tests passed ===")
if passed == total:
print("🎉 All validation tests passed!")
return True
else:
print("💥 Some tests failed!")
return False
if __name__ == "__main__":
success = main()
sys.exit(0 if success else 1)
@@ -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,69 @@
"""Integration tests for Agent with subscription auth."""
import pytest
# Skip entire module when LLM dependencies (e.g. pydantic) are not installed.
# CI runs without the [llm] extra, so these are skipped there.
pytest.importorskip("pydantic", reason="requires praisonaiagents[llm]")
from unittest.mock import patch, MagicMock
from praisonaiagents import Agent
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
@pytest.fixture
def mock_subscription_auth():
"""Mock subscription auth that returns test credentials."""
def mock_resolve_credentials(provider_id):
if provider_id == "claude-code":
return SubscriptionCredentials(
api_key="sk-ant-oat-stub",
base_url="https://api.anthropic.com",
headers={
"anthropic-beta": "oauth-2025-04-20,interleaved-thinking-2025-05-14",
"user-agent": "claude-cli/2.1.0 (external, cli)",
"x-app": "cli",
},
auth_scheme="bearer",
source="test-mock"
)
else:
raise Exception(f"Unknown provider: {provider_id}")
return mock_resolve_credentials
def test_agent_accepts_auth_kwarg(mock_subscription_auth):
"""Test that Agent accepts auth parameter and passes it to LLM."""
with patch('praisonaiagents.auth.resolve_subscription_credentials', mock_subscription_auth):
agent = Agent(
name="test-agent",
llm="anthropic/claude-3-haiku-20240307",
auth="claude-code"
)
assert hasattr(agent, 'llm_instance')
assert agent.llm_instance._auth_provider_id == "claude-code"
def test_llm_kwargs_carry_oauth_headers(mock_subscription_auth):
"""Test that LLM completion params include OAuth headers."""
with patch('praisonaiagents.auth.resolve_subscription_credentials', mock_subscription_auth):
from praisonaiagents.llm import LLM
llm = LLM(model="anthropic/claude-3-haiku-20240307", auth="claude-code")
params = llm._build_completion_params()
assert params["api_key"] == "sk-ant-oat-stub"
assert params["base_url"] == "https://api.anthropic.com"
assert params["extra_headers"]["x-app"] == "cli"
assert params["extra_headers"]["user-agent"] == "claude-cli/2.1.0 (external, cli)"
def test_agent_without_auth_works_normally():
"""Test that Agent without auth parameter works as before."""
agent = Agent(name="test-agent", llm="anthropic/claude-3-haiku-20240307")
if hasattr(agent, 'llm_instance'):
assert agent.llm_instance._auth_provider_id is None
assert agent.llm_instance._cached_subscription_creds is None
@@ -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,167 @@
"""
Smoke test for subscription authentication - exercises real auth flows.
"""
import pytest
from unittest.mock import patch, Mock
try:
import litellm
except ImportError:
pytest.skip("litellm not available", allow_module_level=True)
from praisonaiagents import Agent
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials
def test_claude_code_auth_flow():
"""Test that Claude Code auth properly injects OAuth credentials."""
# Mock litellm completion to capture the actual params sent
mock_response = Mock()
mock_response.choices = [Mock()]
mock_response.choices[0].message = Mock()
mock_response.choices[0].message.content = "Hello! This is a test response."
with patch('litellm.completion') as mock_completion:
mock_completion.return_value = mock_response
# Mock the credential resolution to return OAuth token
mock_creds = SubscriptionCredentials(
api_key="sk-ant-oat-test-oauth-token", # OAuth token format
base_url="https://api.anthropic.com",
headers={"user-agent": "claude-cli/2.1.0", "x-app": "cli"},
auth_scheme="bearer",
source="claude-code-test"
)
with patch('praisonaiagents.auth.resolve_subscription_credentials') as mock_resolve:
mock_resolve.return_value = mock_creds
# Create agent with Claude Code auth
agent = Agent(
name="test-agent",
instructions="You are a test assistant.",
auth="claude-code"
)
# Make a real request
response = agent.start("Say hello")
# Verify the call was made
assert mock_completion.called
call_args = mock_completion.call_args
params = call_args.kwargs if call_args.kwargs else {}
# Verify OAuth token was passed as api_key (litellm will auto-detect and use Bearer)
assert params.get("api_key") == "sk-ant-oat-test-oauth-token"
assert params.get("base_url") == "https://api.anthropic.com"
# Verify provider headers are included
extra_headers = params.get("extra_headers", {})
assert "user-agent" in extra_headers
assert "x-app" in extra_headers
# Verify we got a response (just check that completion was called with right params)
assert params.get("api_key") == "sk-ant-oat-test-oauth-token"
def test_auth_refresh_on_401():
"""Test that auth errors trigger credential refresh and retry."""
# First call: return 401, second call: success
mock_response = Mock()
mock_response.choices = [Mock()]
mock_response.choices[0].message = Mock()
mock_response.choices[0].message.content = "Success after refresh"
auth_error = Exception("AuthenticationError: Invalid API key")
with patch('litellm.completion') as mock_completion:
# First call raises auth error, second succeeds
mock_completion.side_effect = [auth_error, mock_response]
# Mock credential resolution and refresh
initial_creds = SubscriptionCredentials(
api_key="sk-ant-oat-expired-token",
base_url="https://api.anthropic.com",
headers={},
auth_scheme="bearer",
source="claude-code-test"
)
refreshed_creds = SubscriptionCredentials(
api_key="sk-ant-oat-fresh-token",
base_url="https://api.anthropic.com",
headers={},
auth_scheme="bearer",
source="claude-code-refreshed"
)
with patch('praisonaiagents.auth.resolve_subscription_credentials') as mock_resolve:
mock_resolve.return_value = initial_creds
with patch('praisonaiagents.auth.subscription.registry.get_subscription_provider') as mock_provider:
mock_auth_provider = Mock()
mock_auth_provider.refresh.return_value = refreshed_creds
mock_provider.return_value = mock_auth_provider
# Mock error classification to detect auth error
from praisonaiagents.llm.llm import LLM
with patch.object(LLM, '_classify_error_and_should_retry') as mock_classify:
# First call: auth error, can retry
# Second call: shouldn't be called since retry succeeds
mock_classify.return_value = ("auth", True, 0.0)
agent = Agent(
name="test-agent",
instructions="Test agent",
auth="claude-code"
)
# This should trigger refresh on first 401 and succeed on retry
response = agent.start("Test refresh flow")
# Verify refresh was called
assert mock_auth_provider.refresh.called
# Verify we got success response
assert "Success after refresh" in response
def test_invalid_auth_provider():
"""Test that invalid auth providers raise clear errors."""
with pytest.raises(Exception) as exc_info:
agent = Agent(
name="test-agent",
auth="nonexistent-provider"
)
agent.start("This should fail")
error_msg = str(exc_info.value).lower()
assert "unknown" in error_msg or "provider" in error_msg
def test_codex_experimental_error():
"""Test that Codex auth raises experimental error."""
from praisonaiagents.auth.subscription.protocols import AuthError
with pytest.raises(AuthError) as exc_info:
agent = Agent(name="test", auth="codex")
agent.start("test")
assert "experimental" in str(exc_info.value).lower()
def test_gemini_experimental_error():
"""Test that Gemini CLI auth raises experimental error."""
from praisonaiagents.auth.subscription.protocols import AuthError
with pytest.raises(AuthError) as exc_info:
agent = Agent(name="test", auth="gemini-cli")
agent.start("test")
assert "experimental" in str(exc_info.value).lower()
if __name__ == "__main__":
# Manual test runner for development
test_claude_code_auth_flow()
test_auth_refresh_on_401()
test_invalid_auth_provider()
test_codex_experimental_error()
test_gemini_experimental_error()
print("✅ All subscription auth smoke tests passed!")
@@ -0,0 +1,131 @@
#!/usr/bin/env python3
"""Real agentic E2E — Phase A (C1/C2/C4/C7).
Exercises all four gap closures in a single live LLM run:
* C1 seed — request deterministic output
* C2 cancel — pass a non-tripped InterruptController
* C4 validator — install a passthrough ToolValidator (asserts it was invoked)
* C7 PII — enable_pii_redaction() so secrets in the prompt are scrubbed
Run::
ANTHROPIC_API_KEY=... python tests/smoke_core_phase_a_real.py
"""
from __future__ import annotations
import os
import sys
def _pick_model() -> str:
if os.getenv("ANTHROPIC_API_KEY"):
return "anthropic/claude-haiku-4-5"
if os.getenv("GOOGLE_API_KEY"):
return "gemini/gemini-2.5-flash"
return "gpt-4o-mini"
def main() -> int:
from praisonaiagents import Agent
from praisonaiagents.agent.interrupt import InterruptController
from praisonaiagents.tools.validators import ValidationResult
from praisonaiagents.trace.redact import (
enable_pii_redaction,
disable_pii_redaction,
)
model = _pick_model()
print(f"Model: {model}\n")
# C7 — register the PII scrubber for the whole run
enable_pii_redaction()
# A trivial tool the LLM may or may not call — mainly to exercise the
# validator wiring. We track how often it is asked to validate.
validated: list[str] = []
class LoggingValidator:
def validate_args(self, tool_name, args, context=None):
validated.append(tool_name)
return ValidationResult(valid=True)
def validate_result(self, tool_name, result, context=None):
return ValidationResult(valid=True)
def echo(text: str) -> str:
"""Echo a string back."""
return f"echoed: {text}"
agent = Agent(
name="PhaseATester",
instructions=(
"You are a concise assistant. Answer with one short sentence. "
"Feel free to call the echo tool if asked."
),
llm={"model": model},
tools=[echo],
)
agent._tool_validator = LoggingValidator() # C4 wiring
tok = InterruptController() # C2 — not set, must not affect anything
# Prompt contains a fake secret — C7 must scrub it before LLM egress.
prompt = (
"My api_key=sk-XYZSECRET12345 — please answer: what is 2 + 2? "
"Reply with a single digit."
)
print(f"[prompt raw] {prompt}")
reply = agent.chat(prompt, seed=42, cancel_token=tok)
print(f"[reply] {reply}\n")
# Validate outcomes
ok = True
if not reply:
print("FAIL: LLM returned empty reply")
ok = False
elif "4" not in reply:
print("FAIL: reply does not contain expected '4'")
ok = False
else:
print("PASS C1 — seed threaded and LLM returned a valid reply")
# Confirm the scrubber registered its hook (C7)
# (chat_history stores the user's raw prompt by design — scrubbing happens
# only on egress to the LLM, verified here via hook registry state.)
from praisonaiagents.hooks import get_default_registry
reg = get_default_registry()
has_pii = any(
(getattr(h, "name", "") or "").startswith("praisonaiagents.pii_redactor")
for ev in getattr(reg, "_hooks", {}).values()
for h in (ev if isinstance(ev, list) else [])
)
if has_pii:
print("PASS C7 — PII redactor hook active during LLM call")
else:
print("FAIL C7 — PII redactor hook missing from registry")
ok = False
# Cancel-token path did not trip (C2)
if tok.is_set():
print("FAIL C2 — cancel token unexpectedly set")
ok = False
else:
print("PASS C2 — cancel_token honoured without tripping")
# Validator invocation (C4) is exercised only if LLM actually called echo;
# don't fail the run if it chose not to call a tool.
if validated:
print(f"PASS C4 — validator invoked for: {validated}")
else:
print("SKIP C4 — LLM chose not to call a tool this run (non-fatal)")
disable_pii_redaction()
return 0 if ok else 1
if __name__ == "__main__":
sys.exit(main())
@@ -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"
@@ -0,0 +1,184 @@
"""Phase A gap-closure tests: C1 seed, C2 cancel_token, C4 tool validator, C7 PII.
These tests exercise the four gap closures identified in the core-features gap
analysis. They target the existing infrastructure (InterruptController,
ToolValidatorProtocol, trace/redact rules) which was defined but not wired into
`Agent.chat()` / tool execution paths.
"""
from __future__ import annotations
from praisonaiagents import Agent
from praisonaiagents.tools.validators import (
ToolValidatorProtocol,
ValidationResult,
)
# ────────────────────────────────────────────────────────────────────────────
# C1 — seed threaded through Agent.chat()
# ────────────────────────────────────────────────────────────────────────────
class TestC1SeedPassthrough:
def test_chat_signature_accepts_seed(self):
"""`Agent.chat` must accept a per-call `seed=` kwarg."""
import inspect
sig = inspect.signature(Agent.chat)
assert "seed" in sig.parameters, (
"Agent.chat() must expose seed= to let callers request determinism"
)
def test_seed_reaches_llm_build_params(self, monkeypatch):
"""A `seed=42` passed to chat() must appear in the LLM kwargs."""
captured = {}
# Use dict-form llm= so the custom-LLM path (with llm_instance) is taken.
agent = Agent(name="C1", instructions="say hi", llm={"model": "gpt-4o-mini"})
assert getattr(agent, "llm_instance", None) is not None, \
"dict llm= must produce a materialised llm_instance"
# Short-circuit the LLM call so nothing hits the network
monkeypatch.setattr(
agent.llm_instance,
"get_response",
lambda **kw: (captured.update(kw) or "ok"),
)
agent.chat("hello", seed=42)
assert captured.get("seed") == 42, (
f"seed=42 did not reach LLM call; kwargs were {list(captured)}"
)
# ────────────────────────────────────────────────────────────────────────────
# C2 — cancel_token wired into chat()
# ────────────────────────────────────────────────────────────────────────────
class TestC2CancelToken:
def test_chat_signature_accepts_cancel_token(self):
import inspect
sig = inspect.signature(Agent.chat)
assert "cancel_token" in sig.parameters, (
"Agent.chat() must expose cancel_token= for cooperative cancellation"
)
def test_preset_cancelled_token_aborts_chat_before_llm(self, monkeypatch):
"""If the cancel token is already set when chat() is called,
the LLM is NEVER invoked and the call returns cleanly (None)."""
from praisonaiagents.agent.interrupt import InterruptController
agent = Agent(name="C2", instructions="irrelevant", llm={"model": "gpt-4o-mini"})
called = {"n": 0}
def must_not_be_called(**kwargs):
called["n"] += 1
return "should not be reached"
monkeypatch.setattr(agent.llm_instance, "get_response", must_not_be_called)
tok = InterruptController()
tok.request("user-cancel")
try:
agent.chat("anything", cancel_token=tok)
except InterruptedError:
pass # acceptable outcome
assert called["n"] == 0, (
"LLM was invoked despite pre-set cancel_token — cancellation not wired"
)
# ────────────────────────────────────────────────────────────────────────────
# C4 — ToolValidatorProtocol wired into tool execution
# ────────────────────────────────────────────────────────────────────────────
class _RejectingValidator:
"""Minimal ToolValidatorProtocol impl that always rejects."""
def validate_args(self, tool_name, args, context=None):
return ValidationResult(
valid=False,
errors=[f"arg-validator rejected {tool_name}"],
remediation="Fix your tool-call schema.",
)
def validate_result(self, tool_name, result, context=None):
return ValidationResult(valid=True)
class TestC4ToolValidator:
def test_validator_implements_protocol(self):
"""Sanity: our fixture validator satisfies the protocol."""
v = _RejectingValidator()
assert isinstance(v, ToolValidatorProtocol)
def test_validator_rejects_before_tool_runs(self):
"""When agent._tool_validator rejects, the tool function is NEVER called
and the returned string contains the validator's error."""
calls = {"n": 0}
def fake_tool(x: int) -> int:
calls["n"] += 1
return x * 2
agent = Agent(name="C4", instructions="…", tools=[fake_tool], llm="gpt-4o-mini")
agent._tool_validator = _RejectingValidator()
result = agent.execute_tool("fake_tool", {"x": 5})
assert calls["n"] == 0, "tool function was executed despite validator rejection"
assert "rejected" in str(result).lower(), (
f"validator error not surfaced: {result!r}"
)
# ────────────────────────────────────────────────────────────────────────────
# C7 — enable_pii_redaction() registers a BEFORE_LLM middleware
# ────────────────────────────────────────────────────────────────────────────
class TestC7PIIRedaction:
def test_enable_pii_redaction_is_importable(self):
from praisonaiagents.trace.redact import enable_pii_redaction
assert callable(enable_pii_redaction)
def test_enable_pii_redaction_is_idempotent(self):
"""Calling enable_pii_redaction() twice must not double-register."""
from praisonaiagents.trace.redact import (
enable_pii_redaction,
disable_pii_redaction,
)
disable_pii_redaction() # ensure clean baseline
enable_pii_redaction()
first_state = _pii_state_snapshot()
enable_pii_redaction()
second_state = _pii_state_snapshot()
assert first_state == second_state, "enable_pii_redaction is not idempotent"
disable_pii_redaction()
def test_pii_scrubber_scrubs_api_keys(self):
"""The scrubber function must redact API-key-shaped strings."""
from praisonaiagents.trace.redact import scrub_pii_text
raw = "my api_key=sk-ABCDEF12345 and password=hunter2"
scrubbed = scrub_pii_text(raw)
assert "sk-ABCDEF12345" not in scrubbed
assert "hunter2" not in scrubbed
assert "[REDACTED]" in scrubbed
# ────────────────────────────────────────────────────────────────────────────
# Helpers
# ────────────────────────────────────────────────────────────────────────────
def _pii_state_snapshot():
"""Snapshot of hook registry state used by C7 idempotency test."""
from praisonaiagents.hooks import get_default_registry
reg = get_default_registry()
# Count hooks registered for BEFORE_LLM — we just want stable identity
hooks = getattr(reg, "_hooks", None) or getattr(reg, "hooks", None) or {}
if isinstance(hooks, dict):
bl = hooks.get("before_llm") or hooks.get("BEFORE_LLM") or []
return len(bl)
return None
@@ -0,0 +1,101 @@
"""Regression tests for GHSA-gmjg-hv98-qggq:
Undeclared __main__ callables must NOT be executed via execute_tool.
"""
import sys
from unittest.mock import MagicMock, patch
from praisonaiagents.agent.tool_execution import ToolExecutionMixin
class _HookRunner:
def execute_sync(self, *args, **kwargs):
return []
def is_blocked(self, results):
return False
class _DummyAgent(ToolExecutionMixin):
def __init__(self, tools=None):
self.name = "test_agent"
self.tools = tools or []
self.chat_history = []
self._hook_runner = _HookRunner()
self.context_manager = None
self._doom_loop_tracker = None
self._perm_deny = frozenset()
self._perm_allow = None
self._approval_backend = None
def _make_approved_registry():
reg = MagicMock()
reg.approve_sync.return_value = MagicMock(
approved=True, reason="mock", modified_args=None
)
reg.mark_approved = MagicMock()
return reg
def _sneaky_undeclared(msg="pwned"):
"""Callable present only in __main__, never declared as a tool."""
return {"ran": msg}
def test_undeclared_main_callable_not_executed():
"""An undeclared callable in __main__ must NOT be invoked."""
# Inject the function into __main__ to simulate the exploit scenario.
original = getattr(sys.modules["__main__"], "_sneaky_undeclared", None)
sys.modules["__main__"]._sneaky_undeclared = _sneaky_undeclared
try:
reg = _make_approved_registry()
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
agent = _DummyAgent(tools=[])
result = agent.execute_tool("_sneaky_undeclared", {"msg": "hello"})
# Should NOT have run the function — result must indicate not-found, not success.
assert result != {"ran": "hello"}, (
"Undeclared __main__ callable was executed — vulnerability still present"
)
finally:
if original is None:
delattr(sys.modules["__main__"], "_sneaky_undeclared")
else:
sys.modules["__main__"]._sneaky_undeclared = original
def test_declared_tool_still_executes():
"""A properly declared tool must still be callable after the fix."""
def my_tool(x: int) -> int:
return x * 2
reg = _make_approved_registry()
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
agent = _DummyAgent(tools=[my_tool])
result = agent.execute_tool("my_tool", {"x": 3})
assert result == 6, f"Declared tool did not execute correctly: {result}"
def test_globals_callable_not_executed():
"""A callable only in tool_execution module globals must NOT be invoked."""
reg = _make_approved_registry()
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
agent = _DummyAgent(tools=[])
# 'logging' is a module-level name in tool_execution; it must not be invocable.
result = agent.execute_tool("logging", {})
assert not isinstance(result, dict) or result.get("ran") is None, (
"globals() fallback executed an unintended name"
)
def test_undeclared_returns_error_or_none():
"""execute_tool with an unknown name should return None or an error dict, not raise."""
reg = _make_approved_registry()
with patch("praisonaiagents.approval.get_approval_registry", return_value=reg):
agent = _DummyAgent(tools=[])
result = agent.execute_tool("completely_nonexistent_xyz", {})
# Result should be None or an error dict — not a successful execution.
assert result is None or isinstance(result, dict), (
f"Unexpected return type: {type(result)}"
)
if isinstance(result, dict):
assert result.get("ran") is None
@@ -0,0 +1,128 @@
"""Test for agent server readiness timeout warning functionality."""
import time
import threading
import logging
import pytest
from unittest.mock import patch, MagicMock
from praisonaiagents.agents.agents import _AgentServerRegistry
def test_server_readiness_timeout_emits_warning(caplog):
"""A server that does not start in time must produce a WARNING log entry."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app that won't signal readiness
mock_app = MagicMock()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8000: mock_app}):
with patch.dict(registry._ready_events, {8000: threading.Event()}):
with patch.dict(registry._started, {8000: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
with caplog.at_level(logging.WARNING):
# Use a very short timeout to ensure the test doesn't hang
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "0.1"}):
result = registry.start_server_if_needed(8000, host="127.0.0.1")
# The method should still return True but log a warning
assert result is True
assert "8000" in caplog.text
assert "did not become ready" in caplog.text.lower()
def test_server_readiness_timeout_configurable_via_env():
"""The timeout should be configurable via PRAISONAI_SERVER_READY_TIMEOUT."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8001: mock_app}):
with patch.dict(registry._ready_events, {8001: threading.Event()}):
with patch.dict(registry._started, {8001: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Test with custom timeout
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "0.05"}):
start_time = time.time()
result = registry.start_server_if_needed(8001, host="127.0.0.1")
duration = time.time() - start_time
# Should respect the custom timeout and complete quickly
assert result is True
assert duration < 1.0 # Should be much less than default 5s
def test_server_readiness_success_no_warning(caplog):
"""A server that starts in time should not produce any warning."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
ready_event = threading.Event()
# Register the app and signal readiness immediately
with patch.dict(registry._apps, {8002: mock_app}):
with patch.dict(registry._ready_events, {8002: ready_event}):
with patch.dict(registry._started, {8002: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Set the event immediately to simulate quick startup
ready_event.set()
with caplog.at_level(logging.WARNING):
result = registry.start_server_if_needed(8002, host="127.0.0.1")
# Should succeed without warnings
assert result is True
assert "did not become ready" not in caplog.text.lower()
def test_default_timeout_value(monkeypatch):
"""Test that the default timeout is 5.0 seconds when env var is not set."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
ready_event = threading.Event()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8003: mock_app}):
with patch.dict(registry._ready_events, {8003: ready_event}):
with patch.dict(registry._started, {8003: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Remove only the specific env var so other env vars (PATH etc.) remain intact
monkeypatch.delenv("PRAISONAI_SERVER_READY_TIMEOUT", raising=False)
with patch("threading.Event.wait") as mock_wait:
mock_wait.return_value = False
result = registry.start_server_if_needed(8003, host="127.0.0.1")
# Should have called wait with default timeout of 5.0
mock_wait.assert_called_once_with(timeout=5.0)
assert result is True
def test_invalid_timeout_env_var_fallback(caplog):
"""Test that invalid PRAISONAI_SERVER_READY_TIMEOUT falls back to default with warning."""
registry = _AgentServerRegistry()
# Create a mock FastAPI app
mock_app = MagicMock()
ready_event = threading.Event()
# Register the app but don't set the ready event
with patch.dict(registry._apps, {8004: mock_app}):
with patch.dict(registry._ready_events, {8004: ready_event}):
with patch.dict(registry._started, {8004: False}):
with patch("threading.Thread.start"): # Prevent real server spawning
# Test with invalid timeout value
with patch.dict("os.environ", {"PRAISONAI_SERVER_READY_TIMEOUT": "invalid"}):
with caplog.at_level(logging.WARNING):
with patch("threading.Event.wait") as mock_wait:
mock_wait.return_value = False
result = registry.start_server_if_needed(8004, host="127.0.0.1")
# Should have fallen back to 5.0 and logged warning
mock_wait.assert_called_once_with(timeout=5.0)
assert result is True
assert "Invalid PRAISONAI_SERVER_READY_TIMEOUT" in caplog.text
@@ -0,0 +1,107 @@
"""Tests for Claude Code subscription auth."""
import os
import sys
import json
import tempfile
import pytest
from unittest.mock import patch, mock_open
from pathlib import Path
from praisonaiagents.auth.subscription.claude_code import (
ClaudeCodeAuth,
_read_keychain_credentials,
_read_file_credentials,
_is_expiring,
AuthError
)
def test_keychain_path_returns_none_off_darwin():
"""Test keychain reading returns None on non-macOS platforms."""
with patch.object(sys, 'platform', 'linux'):
assert _read_keychain_credentials() is None
def test_file_path_reads_credentials(monkeypatch):
"""Test file credential reading."""
# Create a temporary file with Claude Code credentials
credentials_data = {
"claudeAiOauth": {
"accessToken": "sk-ant-oat-test",
"refreshToken": "rt-test",
"expiresAt": 9999999999000,
}
}
with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False) as tmp:
json.dump(credentials_data, tmp)
tmp_path = tmp.name
try:
with patch('praisonaiagents.auth.subscription.claude_code.Path') as mock_path:
mock_home_path = mock_path.home.return_value
mock_claude_path = mock_home_path / ".claude" / ".credentials.json"
mock_claude_path.exists.return_value = True
mock_claude_path.read_text.return_value = json.dumps(credentials_data)
creds = _read_file_credentials()
assert creds["accessToken"] == "sk-ant-oat-test"
assert creds["refreshToken"] == "rt-test"
assert creds["source"] == "claude-code-file"
finally:
os.unlink(tmp_path)
def test_is_expiring():
"""Test token expiry detection."""
import time
current_ms = int(time.time() * 1000)
# Token expires in 30 seconds (should be considered expiring with default 60s skew)
soon_expired = current_ms + 30_000
assert _is_expiring(soon_expired) is True
# Token expires in 2 minutes (should not be expiring)
not_expired = current_ms + 120_000
assert _is_expiring(not_expired) is False
# No expiry time
assert _is_expiring(0) is False
assert _is_expiring(None) is False
def test_resolve_credentials_uses_env_first(monkeypatch):
"""Test that environment variables take precedence."""
monkeypatch.setenv("ANTHROPIC_TOKEN", "sk-ant-oat-from-env")
auth = ClaudeCodeAuth()
creds = auth.resolve_credentials()
assert creds.api_key == "sk-ant-oat-from-env"
assert creds.base_url == "https://api.anthropic.com"
assert creds.headers["x-app"] == "cli"
assert creds.headers["user-agent"].startswith("claude-cli/")
assert creds.source == "env:ANTHROPIC_TOKEN"
def test_resolve_credentials_no_auth_raises_error():
"""Test that missing credentials raises AuthError."""
auth = ClaudeCodeAuth()
with patch.dict(os.environ, {}, clear=True):
with patch('praisonaiagents.auth.subscription.claude_code._read_keychain_credentials', return_value=None):
with patch('praisonaiagents.auth.subscription.claude_code._read_file_credentials', return_value=None):
with pytest.raises(AuthError) as exc_info:
auth.resolve_credentials()
assert "No Claude Code credentials found" in str(exc_info.value)
def test_headers_for_includes_required_oauth_headers():
"""Test that OAuth-specific headers are included."""
auth = ClaudeCodeAuth()
headers = auth.headers_for("https://api.anthropic.com", "claude-3-haiku")
assert headers["anthropic-beta"] == "oauth-2025-04-20,interleaved-thinking-2025-05-14"
assert headers["user-agent"] == "claude-cli/2.1.0 (external, cli)"
assert headers["x-app"] == "cli"
@@ -0,0 +1,38 @@
"""Tests for subscription auth protocols."""
import pytest
from praisonaiagents.auth.subscription.protocols import (
SubscriptionCredentials,
SubscriptionAuthProtocol,
AuthError
)
def test_credentials_dataclass_defaults():
"""Test SubscriptionCredentials dataclass defaults."""
c = SubscriptionCredentials(api_key="test-key")
assert c.api_key == "test-key"
assert c.base_url == ""
assert c.headers == {}
assert c.auth_scheme == "bearer"
assert c.expires_at_ms is None
assert c.source == ""
def test_protocol_runtime_check():
"""Test that classes implementing the protocol are recognized."""
class MockAuth:
def resolve_credentials(self):
return SubscriptionCredentials(api_key="test")
def refresh(self):
return SubscriptionCredentials(api_key="refreshed")
def headers_for(self, base_url, model):
return {"x-test": "header"}
assert isinstance(MockAuth(), SubscriptionAuthProtocol)
def test_auth_error():
"""Test AuthError exception."""
with pytest.raises(AuthError) as exc_info:
raise AuthError("test error")
assert str(exc_info.value) == "test error"
@@ -0,0 +1,54 @@
"""Tests for subscription auth registry."""
import pytest
from praisonaiagents.auth.subscription.protocols import SubscriptionCredentials, AuthError
from praisonaiagents.auth.subscription import registry as _reg
from praisonaiagents.auth.subscription.registry import (
register_subscription_provider,
list_subscription_providers,
resolve_subscription_credentials,
_REGISTRY
)
@pytest.fixture(autouse=True)
def clean_registry():
"""Clean the registry before and after each test, resetting the builtin flag.
Direct access to _REGISTRY and _BUILTIN_REGISTERED is intentional here:
we are testing the registry implementation itself and need full state control
for test isolation.
"""
original_registry = _REGISTRY.copy()
original_builtin_flag = _reg._BUILTIN_REGISTERED
_REGISTRY.clear()
_reg._BUILTIN_REGISTERED = False
yield
_REGISTRY.clear()
_REGISTRY.update(original_registry)
_reg._BUILTIN_REGISTERED = original_builtin_flag
def test_register_and_resolve():
"""Test registering and resolving a provider."""
class MockAuth:
def resolve_credentials(self):
return SubscriptionCredentials(api_key="test-key", source="mock")
def refresh(self):
return self.resolve_credentials()
def headers_for(self, *_):
return {}
register_subscription_provider("test-mock", lambda: MockAuth())
assert "test-mock" in list_subscription_providers()
creds = resolve_subscription_credentials("test-mock")
assert creds.api_key == "test-key"
assert creds.source == "mock"
def test_resolve_unknown_provider():
"""Test resolving an unknown provider raises AuthError."""
with pytest.raises(AuthError) as exc_info:
resolve_subscription_credentials("unknown-provider")
assert "Unknown subscription provider 'unknown-provider'" in str(exc_info.value)
@@ -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,164 @@
"""Tests for IdentityResolverProtocol and InMemoryIdentityResolver.
W1 — Cross-platform identity linking. An IdentityResolver maps
``(platform, platform_user_id)`` → ``unified_user_id`` so that the same
human can be recognised across Telegram, Discord, Slack, etc.
Linking is OPT-IN. By default the resolver returns the platform-prefixed
ID unchanged (no surprises, no privacy leak).
"""
from __future__ import annotations
import pytest
from praisonaiagents.session.identity import (
IdentityLink,
IdentityResolverProtocol,
InMemoryIdentityResolver,
)
class TestIdentityLink:
def test_link_holds_platform_user_unified(self):
link = IdentityLink(
platform="telegram",
platform_user_id="12345",
unified_user_id="alice",
)
assert link.platform == "telegram"
assert link.platform_user_id == "12345"
assert link.unified_user_id == "alice"
def test_link_is_immutable(self):
link = IdentityLink("telegram", "12345", "alice")
with pytest.raises((AttributeError, Exception)):
link.unified_user_id = "bob" # type: ignore[misc]
class TestProtocolConformance:
def test_in_memory_satisfies_protocol(self):
resolver = InMemoryIdentityResolver()
assert isinstance(resolver, IdentityResolverProtocol)
def test_protocol_has_required_methods(self):
proto_methods = {"resolve", "link", "unlink", "links_for"}
assert proto_methods.issubset(set(dir(IdentityResolverProtocol)))
class TestInMemoryResolverDefaults:
def test_unlinked_returns_platform_prefixed_id(self):
"""When no link exists, return ``{platform}:{user_id}`` unchanged."""
r = InMemoryIdentityResolver()
assert r.resolve("telegram", "12345") == "telegram:12345"
def test_unlinked_does_not_create_link(self):
r = InMemoryIdentityResolver()
r.resolve("telegram", "12345")
assert r.links_for("telegram:12345") == []
class TestLinking:
def test_link_two_platforms_resolves_to_same_unified(self):
r = InMemoryIdentityResolver()
r.link("telegram", "12345", "alice")
r.link("discord", "snowflake-1", "alice")
assert r.resolve("telegram", "12345") == "alice"
assert r.resolve("discord", "snowflake-1") == "alice"
def test_unlink_reverts_to_default(self):
r = InMemoryIdentityResolver()
r.link("telegram", "12345", "alice")
r.unlink("telegram", "12345")
assert r.resolve("telegram", "12345") == "telegram:12345"
def test_links_for_returns_all_platforms(self):
r = InMemoryIdentityResolver()
r.link("telegram", "12345", "alice")
r.link("discord", "snowflake-1", "alice")
links = r.links_for("alice")
assert len(links) == 2
platforms = {link.platform for link in links}
assert platforms == {"telegram", "discord"}
def test_link_overwrites_previous(self):
r = InMemoryIdentityResolver()
r.link("telegram", "12345", "alice")
r.link("telegram", "12345", "bob")
assert r.resolve("telegram", "12345") == "bob"
class TestThreadSafety:
def test_concurrent_links_dont_corrupt_state(self):
import threading
r = InMemoryIdentityResolver()
def worker(i):
r.link("telegram", f"u{i}", f"unified-{i}")
threads = [threading.Thread(target=worker, args=(i,)) for i in range(50)]
for t in threads:
t.start()
for t in threads:
t.join()
for i in range(50):
assert r.resolve("telegram", f"u{i}") == f"unified-{i}"
class TestFileIdentityResolver:
def test_persists_across_instances(self, tmp_path):
from praisonaiagents.session.identity import FileIdentityResolver
path = tmp_path / "identity.json"
r1 = FileIdentityResolver(path=path)
r1.link("telegram", "12345", "alice")
r1.link("discord", "snowflake-1", "alice")
# New instance, same file
r2 = FileIdentityResolver(path=path)
assert r2.resolve("telegram", "12345") == "alice"
assert r2.resolve("discord", "snowflake-1") == "alice"
def test_unlink_persists(self, tmp_path):
from praisonaiagents.session.identity import FileIdentityResolver
path = tmp_path / "identity.json"
r1 = FileIdentityResolver(path=path)
r1.link("telegram", "12345", "alice")
r1.unlink("telegram", "12345")
r2 = FileIdentityResolver(path=path)
assert r2.resolve("telegram", "12345") == "telegram:12345"
def test_corrupt_file_does_not_crash(self, tmp_path):
from praisonaiagents.session.identity import FileIdentityResolver
path = tmp_path / "identity.json"
path.write_text("{not valid json", encoding="utf-8")
r = FileIdentityResolver(path=path)
# Falls back to empty state
assert r.resolve("telegram", "12345") == "telegram:12345"
def test_satisfies_protocol(self, tmp_path):
from praisonaiagents.session.identity import (
FileIdentityResolver,
IdentityResolverProtocol,
)
r = FileIdentityResolver(path=tmp_path / "identity.json")
assert isinstance(r, IdentityResolverProtocol)
def test_file_permissions_restricted(self, tmp_path):
import sys
if sys.platform.startswith("win"):
pytest.skip("chmod semantics differ on Windows")
from praisonaiagents.session.identity import FileIdentityResolver
path = tmp_path / "identity.json"
r = FileIdentityResolver(path=path)
r.link("telegram", "12345", "alice")
# 0o600 = owner-only read/write
mode = path.stat().st_mode & 0o777
assert mode == 0o600
@@ -0,0 +1,88 @@
"""Tests for task-local SessionContextVars.
W1 — Concurrent message handlers must NOT clobber each other's
session metadata. Use ``contextvars.ContextVar`` so each asyncio task
has its own copy.
"""
from __future__ import annotations
import asyncio
import pytest
from praisonaiagents.session.context import (
SessionContext,
clear_session_context,
get_session_context,
set_session_context,
)
class TestSetGet:
def test_set_and_get_roundtrips(self):
token = set_session_context(
platform="telegram",
chat_id="100",
user_id="alice",
)
try:
ctx = get_session_context()
assert ctx.platform == "telegram"
assert ctx.chat_id == "100"
assert ctx.user_id == "alice"
finally:
clear_session_context(token)
def test_default_context_is_empty(self):
ctx = get_session_context()
assert ctx.platform == ""
assert ctx.chat_id == ""
assert ctx.user_id == ""
def test_clear_restores_previous(self):
token = set_session_context(platform="telegram", user_id="alice")
clear_session_context(token)
assert get_session_context().platform == ""
class TestAsyncTaskIsolation:
@pytest.mark.asyncio
async def test_concurrent_tasks_dont_clobber(self):
seen = []
async def handler(name, delay):
token = set_session_context(platform=name, user_id=name)
try:
await asyncio.sleep(delay)
seen.append(get_session_context().platform)
finally:
clear_session_context(token)
await asyncio.gather(
handler("telegram", 0.02),
handler("discord", 0.01),
handler("slack", 0.03),
)
assert sorted(seen) == ["discord", "slack", "telegram"]
class TestSessionContextDataclass:
def test_context_has_all_fields(self):
ctx = SessionContext(
platform="telegram",
chat_id="100",
chat_name="DM",
thread_id="t1",
user_id="alice",
user_name="Alice",
unified_user_id="alice-global",
)
assert ctx.unified_user_id == "alice-global"
def test_context_dict_roundtrip(self):
ctx = SessionContext(platform="telegram", user_id="alice")
d = ctx.to_dict()
ctx2 = SessionContext.from_dict(d)
assert ctx2.platform == "telegram"
assert ctx2.user_id == "alice"
Loaded 100 of 259 files, more files were not shown because too many files have changed in this diff. Show more