Compare commits

...
Author SHA1 Message Date
MervinPraison 0a8ce9eb7d Release v4.6.52 2026-06-03 10:07:09 +01:00
MervinPraison 9fcac3a951 Remove deprecated test script for tool search implementation, consolidating testing functionality into existing modules. 2026-06-02 20:17:33 +01:00
MervinPraison 6878780000 Release v4.6.51 2026-06-02 20:07:49 +01:00
e55789e8cf fix: HierarchicalSessionStore stale extended cache after cross-instance writes (#1785)
* fix: reload HierarchicalSessionStore extended cache on reads

PR #1759 fixed stale reads for DefaultSessionStore but left
_extended_cache out of sync. get_extended_session could return
truncated message lists after cross-process or cross-instance writes.

Align extended cache with _read_session_fresh and clear it on
invalidate_cache. Regression test added.

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

* fix: atomic cache invalidation and stale cache writes in HierarchicalSessionStore

- Fix race condition in invalidate_cache() by clearing both caches under single lock
- Fix stale cache overwrites in write methods (set_title, share_session, unshare_session, revert_to_*, create_session parent update, auto_title)
- Replace load-modify-save pattern with _modify_session_locked for atomic updates
- Add regression test for concurrent write preservation

Addresses critical issues identified by code reviewers (Qodo/CodeRabbit)

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

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
2026-06-02 20:06:34 +01:00
Maulanaandulsreall e0fb8e7dd1 fix: harden JWT secret handling and replace eval() in examples (#1793)
Addresses remaining attack surface from security advisories:

GHSA-3qg8-5g3r-79v5 (JWT signing key - CRITICAL):
  auth_service.py: PLATFORM_ENV no longer defaults to 'dev' when unset.
  When PLATFORM_JWT_SECRET is not set and PLATFORM_ENV is not explicitly
  'dev', an ephemeral random secret is auto-generated with a warning.
  This prevents production deployments from silently using the well-known
  'dev-secret-change-me' key.

GHSA-vg22-4gmj-prxw (unauthenticated eval - CRITICAL):
  Replaced all eval() calls in official examples and benchmarks with an
  AST-based safe math evaluator that only allows numeric literals and
  arithmetic operators. Files fixed:
  - examples/python/a2a/a2a-server.py (the primary advisory target)
  - examples/mcp/multi_server.py
  - examples/python/mcp/mcp-tools-server.py
  - examples/python/mcp/mcp-tools-server-sse.py
  - examples/mcp_server/custom_tools_server.py
  - examples/python/mcp_server_example.py
  - examples/python/custom_tools/tools_with_latency.py
  - examples/eval/reliability_example.py
  - src/praisonai-agents/benchmarks/real_benchmark.py

The character-filter approach (allowlist '0123456789+-*/.()') was
insufficient because () enables attribute access chains. AST-based
evaluation only permits numeric literals and binary/unary arithmetic.

GHSA-hvhp-v2gc-268q and GHSA-9cr9-25q5-8prj (file read/write):
  Already fixed in prior commits via _resolve_rule_path() and
  _resolve_cwd_yaml_path() containment helpers.

Co-authored-by: ulsreall <[email protected]>
2026-06-02 20:01:09 +01:00
bc73dd1db9 Issue #1540: Changes from Claude (#1792)
* fix: make session store fcntl import cross-platform compatible (fixes #1540)

- Add conditional fcntl import like file_memory.py already has
- Use _HAS_FCNTL flag to check fcntl availability before usage
- Windows uses msvcrt for locking, Unix uses fcntl when available
- Fixes Windows test collection failure due to missing fcntl module

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

* fix: add one-time warning for degraded file locking

Addresses CodeRabbit feedback about silent no-op locking when fcntl
is unavailable on non-Windows platforms. Adds module-level sentinel
to emit warning only once, following AGENTS.md error handling principles.

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

* fix: improve fcntl import robustness and fix unreachable code

- Replace platform-based fcntl import with try/except for better cross-platform compatibility
- Fix unreachable warning code branch that could never execute
- Remove misleading msvcrt reference in Unix-only warning message
- Now gracefully handles environments where fcntl is unavailable (GAE, Pyodide, etc.)
- All reviewer feedback from Qodo, CodeRabbit, Gemini, Copilot, and Greptile addressed

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

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

* fix: harden optional fcntl import and add regression test

* test: cover missing fcntl import path for session store

---------

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]>
2026-06-02 19:59:48 +01:00
5216274924 fix: Gateway Telegram polling path bypasses security controls (fixes #1747) (#1791)
* fix: Gateway Telegram polling path bypasses security controls (fixes #1747)

- Extract shared process_inbound_telegram_message() security pipeline
- Enforce channel allowlist, user allowlist, pairing, and group policies
- Used by both standalone bot and gateway polling paths
- Add comprehensive tests for security enforcement
- Prevents unauthorized access in gateway mode

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

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

* fix: resolve security pipeline bugs from code review

- Fix case sensitivity bug in bot mention check (lowercase bot_username)
- Fix user allowlist logic to match BotConfig.is_user_allowed semantics
- Fix test failures by using private chat type for allowlist tests
- Clean up unused imports and trailing whitespace

Addresses critical bugs identified by CodeRabbit, Greptile, and Copilot reviewers.

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

* fix: align Telegram security pipeline with BotConfig semantics

* fix: tighten Telegram security checks and test coverage

---------

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]>
2026-06-02 19:58:07 +01:00
2e5eca1db6 fix: disable streaming by default in sync chat methods (fixes #1733) (#1734)
* fix: disable streaming by default in sync chat methods (fixes #1733)

Fixes the 'Streaming is not supported in sync OpenAIAdapter' error when using
multi-agent functionality with Deepseek provider.

Root cause:
- _chat_completion() and _execute_unified_chat_completion() methods in
  chat_mixin.py defaulted to stream=True
- This caused sync OpenAIAdapter to reject requests with streaming enabled
- Multi-agent workflows use sync execution path, triggering the error

Changes:
- Changed stream=True to stream=False as default in both sync methods
- Sync adapters now receive non-streaming requests by default
- Async methods can still use streaming when explicitly requested
- Maintains backward compatibility as streaming was already broken in sync context

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

* test: replace ad-hoc script with unit test for sync stream defaults

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/c4a541cc-a3dc-4637-85d9-53f7090dcb35

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

* test: clarify helper naming in sync stream default tests

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/c4a541cc-a3dc-4637-85d9-53f7090dcb35

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

* test: harden sync stream default AST checks

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/c4a541cc-a3dc-4637-85d9-53f7090dcb35

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

* test: align stream default test names with target methods

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/c4a541cc-a3dc-4637-85d9-53f7090dcb35

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

* docs: clarify async/sync stream default asymmetry

Addresses reviewer feedback about intentional difference between:
- sync methods: stream=False (sync adapters don't support streaming)
- async methods: stream=True (async adapters do support streaming)

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

* feat: implement smart streaming fallback for better UX

- Change sync method defaults: stream=False → stream=None
- Add intelligent fallback: try streaming first, gracefully fall back to non-streaming if unsupported
- Maintains UX benefits of streaming while ensuring compatibility with all adapters
- Benefits: users see real-time progress when possible, silent fallback when needed
- Updated tests to validate new None defaults
- Async methods unchanged (still default stream=True)

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

* cleanup: remove test file

---------

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-06-02 19:56:27 +01:00
f51fe294e7 fix: HierarchicalSessionStore stale cache could wipe session messages (#1781)
* fix: HierarchicalSessionStore stale extended cache could wipe messages

After DefaultSessionStore began reloading on reads (#1759),
HierarchicalSessionStore still served writes from a separate
_extended_cache that could lag disk. set_title, share_session,
and revert paths then saved truncated message lists.

Reload via _read_session_fresh for all extended reads and sync
both caches. Add regression test for set_title after cross-store writes.

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

* perf: optimize HierarchicalSessionStore with smart cache invalidation

- Add file modification time-based cache validation
- Restore performance while maintaining data integrity
- Preserve force_reload API contract
- Add comprehensive regression tests

Fixes performance regression from stale cache fix while
preventing message loss from concurrent writes.

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

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
2026-06-02 19:55:02 +01:00
a1b7ed5c91 fix: wrapper layer architectural gaps (#1763)
* fix: wrapper layer architectural gaps - async-sync bridge, unified registry, framework logic separation (fixes #1762)

- Gap 1: Create separate sync/async SQLite stores, remove problematic sync wrappers
  - Add SyncSQLiteConversationStore with thread-safe operations
  - Remove sync wrappers from AsyncSQLiteConversationStore
  - Update factory to support mode parameter (sync/async/auto)

- Gap 2: Replace manual __getattr__ ladder with unified registry
  - Extend existing PluginRegistry with lazy __getattr__ support
  - Create IntegrationRegistry replacing 70-line manual dispatch
  - Consolidate four parallel registries into data-driven approach

- Gap 3: Move framework logic from orchestrator to adapters
  - Add resolve() and setup() methods to FrameworkAdapter protocol
  - Extract AutoGen version logic to adapter.resolve()
  - Centralize AgentOps init in observability/hooks module
  - Remove framework-specific code from agents_generator.py

Restores protocol-driven core principle and eliminates event loop issues.

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

* fix: restore lazy integration exports and parameterize sync sqlite paging

---------

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]>
2026-06-02 19:48:58 +01:00
aaed51eee8 fix: replace undefined AgentFlow with Workflow in workflow_conditional.py (closes #1750) (#1765)
* fix: replace undefined AgentFlow with Workflow in workflow_conditional.py (closes #1750)

* Update workflow_conditional.py

---------

Co-authored-by: botbikamordehai2-sketch <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-06-02 14:46:31 +01:00
48407be580 fix: architectural gaps in wrapper package - thread safety, async safety, feature parity (#1771)
* fix: architectural gaps in wrapper package - thread safety, async safety, feature parity

- Fix TOCTOU race in jobs server singleton initialization with thread-safe locking
- Replace deprecated asyncio.get_event_loop() calls with asyncio.run() and asyncio.to_thread()
- Restore AsyncAgentScheduler feature parity: add missing timeout and max_cost parameters
- Ensure async/sync scheduler classes have identical safety features

Fixes #1770

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

* fix: address critical architectural issues in PR #1771

- Fix event loop splitting in CLI standardise.py by using single asyncio.run() lifecycle
- Fix budget scheduler stopping issue by setting stop event when budget exceeded
- Complete asyncio migration in e2b.py by replacing remaining get_event_loop() calls
- Update factory function to expose timeout and max_cost parameters

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: Mervin Praison <[email protected]>
2026-06-02 14:45:31 +01:00
0ad42ff2c5 fix: MCP client lifecycle - thin core wiring, OAuth honesty, remote support (#1777)
* fix: MCP client lifecycle - thin core wiring, OAuth honesty, remote support

Implements comprehensive fixes for MCP client integration addressing
broken imports, misleading OAuth documentation, and missing remote
server support. Adds thin wiring between config and agents following
protocol-driven architecture.

Tier A - Critical Fixes:
- Fix MCPConfig.to_mcp_instance() import from praisonaiagents.mcp
- Mark OAuth CLI commands as experimental until real implementation
- Update doctor checks to use canonical ~/.praisonai/mcp/ paths

Tier B - Core Agent Value:
- Add MCPClientProtocol interface for extensibility
- Create load_mcp_tools() helper for agent-centric tool loading
- Wire tool filtering with allowed_tools/disabled_tools parameters
- Add safe environment policy (baseline + explicit config only)
- Prepare tool name collision handling framework

Tier C - Wrapper Improvements:
- Add remote server support to mcp test/sync CLI commands
- Create YAML MCP configuration example

Tests: All smoke tests pass, no performance regressions (13ms import)

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

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

* fix: Critical MCP client lifecycle issues - syntax, env, filters, async

Fixes identified by code reviewers (CodeRabbit, Greptile, Copilot):

1. Fixed syntax error in CLI - orphaned except blocks (CRITICAL)
2. Fixed environment inheritance regression in mcp test command
3. Fixed filter precedence - allowed_tools now properly wins over disabled_tools
4. Added missing SYSTEMROOT/COMSPEC for Windows stdio MCP servers
5. Added async protocol methods for non-blocking MCP operations
6. Fixed typer.Exit propagation in remote server tests
7. Updated YAML example to remove unsupported description fields
8. Fixed canonical path discovery in doctor checks fallback
9. Updated loader docs to clarify prefix_tools not yet implemented

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-06-02 14:45:09 +01:00
2943c3b77e fix: HierarchicalSessionStore stale writes lose concurrent chat messages (#1790)
* fix: prevent HierarchicalSessionStore stale writes on fork and metadata updates

PR #1745 fixed add_message races but fork_session, create_session parent
linking, snapshots, and revert still saved stale in-memory sessions and could
overwrite concurrent add_message writes.

Route parent/field updates through _modify_session_locked and add a regression
test for fork_session concurrent with add_message.

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

* fix: address Qodo reviewer bugs in HierarchicalSessionStore

1. Fix revert_to_message() - validate index before writing to prevent
   unnecessary session writes on invalid indices

2. Fix revert_to_snapshot() - use read-only lookup to avoid double writes
   and unnecessary writes when snapshot not found

3. Fix test_fork_session_preserves_concurrent_messages() - use proper
   concurrent testing with two store instances and threading to simulate
   real race conditions

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

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
2026-06-02 14:43:32 +01:00
84627f593c fix: reload session file on read to prevent stale chat history (#1764)
_load_session returned an in-memory cache without checking disk when a
session JSON file already existed. A second DefaultSessionStore instance
(or direct file edits) could add messages that get_chat_history never saw
until invalidate_cache was called manually.

Always reload from disk under FileLock when the session file exists.
Adds regression test for cross-instance reads.

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
2026-06-02 14:01:40 +01:00
927020cea8 fix: session message loss in managed stream and bot history saves (#1768)
* fix: session message loss in managed stream and bot history saves

- Persist user/assistant messages and state after LocalManagedAgent.stream()
- Add DefaultSessionStore.set_chat_history for atomic locked replace
- BotSessionManager uses set_chat_history instead of clear + re-add loop
- DbSessionAdapter.update_session_metadata accepts **fields like file store
- Add regression tests for stream persistence and metadata API

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

* fix: preserve timestamps and metadata in set_chat_history

- Fix SessionMessage construction in set_chat_history to preserve
  original timestamp when provided in input messages
- Also preserve metadata field for completeness
- Prevents loss of temporal ordering information during history replacement

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

---------

Co-authored-by: Cursor Agent <[email protected]>
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: copilot-swe-agent[bot] <[email protected]>
2026-06-02 14:00:16 +01:00
MervinPraison 23ca2a80d6 docs: auto-update feature parity trackers [skip ci] 2026-06-02 12:56:41 +00:00
f931173e60 feat: implement Tool Search progressive MCP/plugin tool disclosure (#1778)
* feat: implement Tool Search progressive MCP/plugin tool disclosure

Implements Hermes-parity progressive disclosure layer for MCP and non-core plugin tools:

Phase P0 Complete:
- Added ToolSearchConfig with auto/on/off modes and threshold controls
- Implemented BM25 search with substring fallback (no external dependencies)
- Created bridge tools (tool_search, tool_describe, tool_call) with lazy loading
- Integrated into Agent class with tool_search parameter
- Updated chat_mixin for tool assembly and bridge mode detection
- Added tool_execution unwrapping with security validation
- Marked all MCP tools as deferrable (__praisonai_deferrable__ = True)
- Comprehensive unit tests covering all core functionality

Design Invariants (from Hermes):
1. Core tools never defer (PRAISONAI_CORE_TOOLS)
2. Unknown tools stay visible (never silently dropped)
3. Stateless catalog (rebuilt every assembly)
4. Unwrap before trace/stream (hooks see real tool names)
5. Session scoping (agent's own tool list only)
6. Auto threshold (10% context window default)
7. Zero overhead when disabled (opt-in, lazy imports)

Default: tool_search=False (opt-in). Zero overhead when disabled.
Auto mode activates based on token threshold vs context window.
Core SDK tools (file/shell/web/schedule/memory/clarify) never defer.

Fixes #1775

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

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

* fix: address critical Tool Search issues

- Fix unconditional bridge tool hijacking that broke user tools when disabled
- Resolve duplicate ToolSearchConfig classes by consolidating to tools module
- Add missing resolve_tool_search resolver function
- Fix hot path performance issues in BM25 scoring
- Add input validation for tool_call bridge args
- Fix cache metadata mismatches in chat_mixin
- Remove unnecessary f-string prefixes
- Strip __praisonai_deferrable__ from provider payloads
- Preserve tool_search config in clone_for_channel
- Improve type validation in agent constructor
- Fix overly broad MCP name heuristic

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-06-02 13:56:03 +01:00
ba12269d5d fix: wrapper layer gaps - async support, lazy imports, event loop fixes (#1789)
* fix: wrapper layer gaps - add async support, fix heavy imports, stream relay event loop (fixes #1788)

- Add async arun() methods to FrameworkAdapter protocol and BaseFrameworkAdapter
- Override arun() in AutoGenV4Adapter for async-native execution without run_sync wrapper
- Fix AutoGenAdapter.is_available() to use centralized lazy probe instead of full import autogen
- Fix Gateway._make_stream_relay to capture event loop in async context, not threaded callback
- Add agenerate_crew_and_kickoff() async method for user-facing async API
- Promote stream relay errors from debug to warning for better visibility

All fixes address the wrapper's stated principles: async-safe by default, lazy imports, no hot-path performance impact.

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

* fix: align async framework path with sync behavior and add regression tests

* fix: align async framework path with sync behavior and add regression tests

- Fix async _arun_framework() to honor config.get('autogen_version') like sync path
- Stop silently swallowing RuntimeError from run_sync() - re-raise event loop errors
- Remove duplicate import os in autogen_adapter.py
- Enhance stream relay error logging with full traceback info

All critical reviewer bugs addressed. Architecture compliant with AGENTS.md.

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]>
2026-06-02 13:52:34 +01:00
MervinPraison 7839196391 Release v4.6.50 2026-06-02 07:19:27 +01:00
praisonai-triage-agent[bot]andMervin Praison e958968307 fix: wrapper layer gaps - case normalization, lazy loading, async persistence (#1767)
* fix: wrapper layer gaps - case normalization, lazy loading, async persistence (fixes #1766)

- Gap 1: Fix PluginRegistry case normalization inconsistency by normalizing names during storage and using lazy loaders
- Gap 2: Implement lazy loading in PluginRegistry by storing loaders instead of immediately executing them
- Gap 3: Add async persistence support with aon_* methods and achat wrapping in agent hooks

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

* fix: critical bugs in wrapper layer - registry consistency and async event loop blocking

- Fix registry inconsistency: register() now writes to both _loaders and _items
- Fix event loop blocking: PersistentAgent.achat uses asyncio.to_thread for sync fallback
- Fix threading deadlock: use RLock and release lock before calling loader()
- Add async session end methods (aend_session) for proper async lifecycle

Addresses critical P1/P2 issues identified by code reviewers.

🤖 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]>
2026-06-02 07:16:38 +01:00
MervinPraison 1c26a5bf14 docs: auto-update api.md [skip ci] 2026-06-02 06:15:46 +00:00
ba8cd6b3c1 feat: implement Hermes-style persistent kanban system (#1769)
* feat: implement Hermes-style persistent kanban system

Implements comprehensive kanban task management system for PraisonAI wrapper:

Phase 1 - Persistent Store & Host Integration:
- SQLite kanban store with WAL, CAS, and dependency DAG support
- Task models with status enum and dataclasses
- Multi-board support via environment variables
- Host bridge injection for PraisonAIUI integration
- Updated claw navigation with kanban & jobs-board pages

Phase 2 - Agent Tools & Dispatcher:
- Complete agent tool suite (create, list, show, complete, block, etc.)
- Gateway dispatcher for task claiming and worker spawning
- Hook event integration (graceful degradation if unavailable)
- Lazy tool loading in tools module

Phase 3 - CLI & Human Operations:
- Full CLI command suite (praisonai kanban list, create, show, etc.)
- Multi-board management and task lifecycle operations
- Manual dispatcher triggering and task recovery commands
- Integrated with main CLI app registration

Features:
- Compatible with PraisonAIUI kanban API endpoints
- Parent/child task dependencies with automatic promotion
- Atomic operations with optimistic concurrency control
- Comprehensive logging and event trails
- Environment variable configuration support

Testing:
- Unit tests for SQLite store CRUD operations
- Integration tests for host injection and bridge setup
- Smoke tests for basic functionality validation

Fixes #1720

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

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

* fix: address 6 critical issues in kanban system implementation

- Fix subprocess deadlock by redirecting output to temp files instead of PIPE
- Enable foreign key constraints on all SQLite connections for proper cascades
- Replace immediate dispatcher startup with proper event handler registration
- Add persistent CLI board switching via ~/.praisonai/kanban_config.json
- Prevent database locking with connection-reusing internal methods
- Replace deprecated datetime.utcnow() with datetime.now(timezone.utc)
- Fix optimistic locking by incrementing version in claim/release operations

Fixes all issues identified in code review by Gemini.

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-06-02 07:15:18 +01:00
8327f8e8b8 fix: accept multi-channel bot.yaml keys with platform field (#1772)
* fix: accept multi-channel bot.yaml keys with platform field (#1757 regression)

Onboard generates channels like telegram_cfo with platform: telegram.
BotYamlSchema rejected any channel key not exactly matching a platform name,
so praisonai doctor reported invalid bot.yaml after multi-bot onboard.

Validate using channel.platform when present; extend ChannelConfigSchema for
gateway-style fields used in generated configs.

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

* fix: use monkeypatch for env isolation in multi-channel test

- Replace direct os.environ manipulation with pytest monkeypatch
- Ensures test environment isolation and automatic cleanup
- Addresses CodeRabbit review feedback

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

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
2026-06-02 07:13:57 +01:00
praisonai-triage-agent[bot]andMervin Praison f78ba6348c fix: eliminate wrapper layer architecture issues (#1780)
* fix: eliminate wrapper layer architecture issues (fixes #1779)

- Remove redundant framework availability wrappers in auto.py that only delegated to is_available()
- Make agents_generator module-level constants lazy via __getattr__() to avoid import-time probes
- Fix async bridge singleton creation (now lazy) and encapsulation breaks in run_sync()
- Deprecate AutoGen-specific methods in ToolRegistry to guide toward proper separation

Addresses three key architectural violations:
1. DRY violation: 8 wrapper functions that added no value over is_available()
2. Import-time work: 6 package probes ran on every agents_generator import
3. Encapsulation break: run_sync() accessed private _BG._lock directly

Performance impact: Eliminates import-time package probing and eager singleton creation.

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

* fix: address reviewer feedback for architecture issues

1. Fix shutdown() lazy-init defeat - read _BG directly instead of calling _get_bg()
2. Fix tool registry race condition - move _autogen_adapters init inside lock
3. Add __all__/__dir__ for legacy flag discoverability in agents_generator
4. Suppress internal deprecation warnings for backward compatibility paths

Addresses feedback from Greptile and CodeRabbit reviewers.

🤖 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]>
2026-06-02 07:11:40 +01:00
Putra Jadi Muktianddymux 4f3daa8628 docs: fix broken Full Flow docs link in README (#1782)
The link pointed to /docs/concepts/flow which returns 404. The actual
page is published at /docs/concepts/agentflow.

Co-authored-by: dymux <[email protected]>
2026-06-02 07:10:32 +01:00
d7f68a0870 fix: complete AsyncAgentScheduler parity, fail-loud pip installs, remove broken resolver (fixes #1783) (#1784)
* fix: complete AsyncAgentScheduler parity, fail-loud pip installs, remove broken resolver

Fixes #1783

Three critical gap fixes in the PraisonAI wrapper layer:

1. **AsyncAgentScheduler Feature Parity**
   - Add missing timeout and max_cost parameters to match sync version
   - Port budget tracking logic with cost estimation
   - Port timeout support using asyncio.wait_for()
   - Add from_yaml() and from_recipe() class methods for YAML/recipe support
   - Update stats methods to include cost tracking
   - Remove 16 TODO markers now that features are ported

2. **Fail-Loud Pip Install Failures**
   - Convert silent pip install failures to raised RuntimeError exceptions
   - Fix managed_local.py _install_packages() to fail early on pip errors
   - Fix _install_packages_in_compute() to raise on compute pip failures
   - Eliminates confusing ImportError deep in tool execution

3. **Remove Broken reset_default_resolver Definition**
   - Remove second definition that shadows working first definition
   - Eliminates NameError on function call due to undefined globals
   - Preserves working ContextVar-based implementation at line 545

All changes maintain backward compatibility while fixing production-blocking issues
in async scheduling, managed agent bootstrap, and tool resolution.

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

* fix: restore async stats API and safe budget-stop behavior

* fix: tighten async budget checks and stats parity

* fix: remove redundant asyncio import in from_recipe method

Removes the unnecessary nested import asyncio statement inside the
astart method, since asyncio is already imported at module level.

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: Mervin Praison <[email protected]>
2026-06-02 07:09:42 +01:00
Anna Tao 1bf62b0416 feat: add CLIENT SETNAME for connection identification (#1787)
Adds client_name="praisonai_valkey_client" to the shared Valkey client
factory so all PraisonAI connections are identifiable via CLIENT LIST.

Signed-off-by: Anna Tao <[email protected]>
2026-06-02 07:08:46 +01:00
106 changed files with 8055 additions and 905 deletions

No files matched your search

+1 -1
View File
@@ -382,7 +382,7 @@ praisonai flow
Open **http://localhost:7861** — use the **Agent** and **Agent Team** components to create sequential or parallel workflows. Connect Chat Input → Agent Team → Chat Output for instant multi-agent pipelines.
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/flow) — visual agent building, component reference, and deployment
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/agentflow) — visual agent building, component reference, and deployment
### 8. PraisonAI UI 🤖 (Clean Chat)
+12
View File
@@ -911,6 +911,18 @@ Methods:
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai hooks list <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai hooks remove <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
* <code title="cli">praisonai kanban block <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban boards <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban comment <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban complete <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban create <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban dispatch <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban link <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban list <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban move <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban reclaim <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban show <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai kanban unblock <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
* <code title="cli">praisonai knowledge add <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
* <code title="cli">praisonai knowledge index <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
* <code title="cli">praisonai knowledge list <a href="./src/praisonai/praisonai/cli/commands/knowledge.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.48" \
"praisonai>=4.6.52" \
"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.48" \
"praisonai>=4.6.52" \
"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.48" \
"praisonai>=4.6.52" \
"praisonai[ui]" \
"praisonai[crewai]"
+13 -2
View File
@@ -16,8 +16,19 @@ def search_web(query: str) -> str:
def calculate(expression: str) -> str:
"""Calculate a math expression."""
return str(eval(expression))
"""Calculate a math expression safely."""
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
return str(_ev(ast.parse(expression, mode="eval")))
# Check if we have an API key
+31 -8
View File
@@ -19,20 +19,43 @@ import os
def calculate(expression: str) -> str:
"""
A simple calculator tool that evaluates mathematical expressions.
A simple calculator tool that evaluates mathematical expressions safely.
Args:
expression: A mathematical expression to evaluate (e.g., "2 + 2")
Returns:
The result of the calculation
"""
import ast
import operator
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
def _safe_eval(node):
if isinstance(node, ast.Expression):
return _safe_eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.operand))
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
raise ValueError(f"Unsupported expression: {ast.dump(node)}")
try:
# Safe evaluation of mathematical expressions
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return "Error: Invalid characters in expression"
result = eval(expression)
tree = ast.parse(expression, mode="eval")
result = _safe_eval(tree)
return f"Result: {result}"
except Exception as e:
return f"Error: {str(e)}"
+12 -6
View File
@@ -24,12 +24,18 @@ def main():
def calculate(expression: str) -> str:
"""Safely evaluate a mathematical expression."""
try:
# Only allow safe math operations
allowed = set("0123456789+-*/.(). ")
if not all(c in allowed for c in expression):
return "Error: Invalid characters in expression"
result = eval(expression) # Safe due to character filtering
return f"Result: {result}"
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
return f"Result: {_ev(ast.parse(expression, mode='eval'))}"
except Exception as e:
return f"Error: {e}"
+29 -2
View File
@@ -21,9 +21,36 @@ def search_web(query: str) -> str:
return f"Search results for: {query}"
def calculate(expression: str) -> str:
"""Calculate a mathematical expression."""
"""Calculate a mathematical expression safely."""
import ast
import operator
_OPS = {
ast.Add: operator.add,
ast.Sub: operator.sub,
ast.Mult: operator.mul,
ast.Div: operator.truediv,
ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod,
ast.Pow: operator.pow,
ast.USub: operator.neg,
ast.UAdd: operator.pos,
}
def _eval(node):
if isinstance(node, ast.Expression):
return _eval(node.body)
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
return node.value
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.operand))
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
raise ValueError(f"Unsupported expression: {ast.dump(node)}")
try:
return f"Result: {eval(expression)}"
tree = ast.parse(expression, mode="eval")
return f"Result: {_eval(tree)}"
except Exception:
return "Invalid expression"
@@ -49,7 +49,18 @@ def calculate_with_tracking(expression: str) -> str:
time.sleep(0.05)
try:
result = eval(expression)
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
result = _ev(ast.parse(expression, mode="eval"))
return f"Result: {result}"
except Exception as e:
return f"Error: {str(e)}"
+12 -4
View File
@@ -36,10 +36,18 @@ def calculate(expression: str) -> dict:
expression: Math expression (e.g., "2 + 2 * 3")
"""
try:
allowed = set("0123456789+-*/.() ")
if not all(c in allowed for c in expression):
return {"error": "Invalid characters"}
return {"expression": expression, "result": eval(expression)}
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
return {"expression": expression, "result": _ev(ast.parse(expression, mode="eval"))}
except Exception as e:
return {"error": str(e)}
+12 -6
View File
@@ -47,12 +47,18 @@ def calculate(expression: str) -> Dict[str, Any]:
The result of the calculation
"""
try:
# Safe evaluation of mathematical expressions
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return {"error": "Invalid characters in expression"}
result = eval(expression) # Use safer alternative in production
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
result = _ev(ast.parse(expression, mode="eval"))
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
+12 -7
View File
@@ -49,13 +49,18 @@ def calculate(expression: str) -> Dict[str, Any]:
The result of the calculation
"""
try:
# Safe evaluation of mathematical expressions
# In production, use a proper math parser
allowed_chars = set("0123456789+-*/.() ")
if not all(c in allowed_chars for c in expression):
return {"error": "Invalid characters in expression"}
result = eval(expression) # Note: Use safer alternative in production
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
result = _ev(ast.parse(expression, mode="eval"))
return {"expression": expression, "result": result}
except Exception as e:
return {"error": str(e)}
@@ -5,7 +5,7 @@ Demonstrates using should_run to conditionally execute agent steps
based on input or previous results.
"""
from praisonaiagents import Agent, Workflow, Task
from praisonaiagents import Agent, AgentFlow, Task
# Condition functions
def is_technical(ctx) -> bool:
+40
View File
@@ -0,0 +1,40 @@
# Example: Agent with MCP Tool Integration
#
# This example shows how to configure an agent with MCP (Model Context Protocol)
# servers for additional tool capabilities.
#
# Usage:
# praisonai workflow run --file examples/yaml/agent-with-mcp.yaml
agent:
name: mcp_assistant
instructions: |
You are a helpful assistant with access to filesystem and time tools.
Use the MCP tools when needed to help users with file operations or time queries.
llm: gpt-4o-mini
# MCP server configuration
mcp:
servers:
filesystem:
# Filesystem access for the agent
command: npx
args:
- "-y"
- "@modelcontextprotocol/server-filesystem"
- "."
env: {}
enabled: true
time:
# Time and date utilities
command: npx
args:
- "-y"
- "@modelcontextprotocol/server-time"
env: {}
enabled: true
# NOTE: Per-server tool filtering (tools.include/exclude) is not yet supported
# by the MCP server config schema. Tool filtering should be applied after
# calling mcp.get_tools() in your application code.
+2 -1
View File
@@ -95,4 +95,5 @@ chroma_db/
.windsurf
.ruff_cache
*.sfdx
.sfdx/
.sfdx/
.cursor/
@@ -79,10 +79,20 @@ def get_time(timezone: Literal["est", "pst"]) -> str:
def calculate(expression: str) -> str:
"""Calculate a math expression."""
"""Calculate a math expression safely."""
try:
result = eval(expression)
return f"Result: {result}"
import ast, operator
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
ast.Mod: operator.mod, ast.Pow: operator.pow,
ast.USub: operator.neg, ast.UAdd: operator.pos}
def _ev(n):
if isinstance(n, ast.Expression): return _ev(n.body)
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
raise ValueError(f"Unsupported: {ast.dump(n)}")
return f"Result: {_ev(ast.parse(expression, mode='eval'))}"
except Exception:
return "Error: Invalid expression"
@@ -384,6 +384,7 @@ _LAZY_IMPORTS = {
'MultiAgentExecutionConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentExecutionConfig'),
'MultiAgentPlanningConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentPlanningConfig'),
'MultiAgentMemoryConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentMemoryConfig'),
'ToolSearchConfig': ('praisonaiagents.config.feature_configs', 'ToolSearchConfig'),
# Parameter resolver
'resolve': ('praisonaiagents.config.param_resolver', 'resolve'),
@@ -556,6 +556,7 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
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
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
):
"""Initialize an Agent instance.
@@ -660,6 +661,14 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
When provided, agent delegates entire conversation turns to the CLI tool
instead of using the built-in LLM. Enables session continuity and
tool integration through external AI coding assistants.
tool_search: Progressive tool disclosure configuration. Accepts:
- bool: False=disabled (default), True=auto mode
- str: Mode ("auto", "on", "off")
- Dict[str, Any]: Config overrides (e.g. {"threshold_pct": 15})
- ToolSearchConfig: Custom configuration
When enabled, replaces large tool schemas with bridge tools (tool_search,
tool_describe, tool_call) to save context. Core SDK tools never defer.
Auto mode activates based on token threshold. Opt-in feature.
Raises:
ValueError: If all of name, role, goal, backstory, and instructions are None.
@@ -1430,6 +1439,34 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
web_search = False
web_fetch = False
# ─────────────────────────────────────────────────────────────────────
# Resolve TOOL_SEARCH param
# ─────────────────────────────────────────────────────────────────────
# Fast path: None/False -> disabled (zero overhead)
if tool_search is None or tool_search is False:
self._tool_search_config = None
elif tool_search is True:
# True -> auto mode with defaults
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
self._tool_search_config = _ToolSearchConfig(enabled="auto")
elif isinstance(tool_search, str):
# String mode ("auto", "on", "off")
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
self._tool_search_config = _ToolSearchConfig.from_raw(tool_search)
elif isinstance(tool_search, dict):
# Dict -> config overrides
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
self._tool_search_config = _ToolSearchConfig(**tool_search)
else:
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
if isinstance(tool_search, _ToolSearchConfig):
self._tool_search_config = tool_search
else:
raise TypeError(
"tool_search must be False/None, True, a mode string, "
"a dict of ToolSearchConfig fields, or ToolSearchConfig"
)
# ============================================================
# END CONSOLIDATED PARAMS EXTRACTION
# ============================================================
@@ -2026,6 +2063,7 @@ Your Goal: {self.goal}
'skills': getattr(self, '_skills_config', None),
'approval': getattr(self, '_approval_config', None),
'learn': getattr(self, '_learn_config', None),
'tool_search': getattr(self, '_tool_search_config', None),
# Tool configuration
'tool_timeout': getattr(self, '_tool_timeout', None),
@@ -331,6 +331,7 @@ Your Goal: {self.goal}"""
- Callable functions
- String function names
- Objects with to_openai_tool() method
- Tool Search progressive disclosure (if enabled)
Args:
tools: List of tools in various formats or None to use self.tools
@@ -344,10 +345,14 @@ Your Goal: {self.goal}"""
if not tools:
return []
# Check cache first
# Check cache first - include tool_search config in cache key for safety
tools_key = self._get_tools_cache_key(tools)
cached_tools = self._cache_get(self._formatted_tools_cache, tools_key)
if cached_tools is not None:
tool_search_enabled = getattr(self, '_tool_search_config', None) is not None
cache_key = f"{tools_key}:tool_search={tool_search_enabled}"
cached_entry = self._cache_get(self._formatted_tools_cache, cache_key)
if cached_entry is not None:
cached_tools, cached_metadata = cached_entry
self._tool_search_metadata = cached_metadata
return cached_tools
formatted_tools = []
@@ -399,9 +404,46 @@ Your Goal: {self.goal}"""
logging.error(f"Tools are not JSON serializable: {e}")
return []
# Cache the formatted tools with LRU eviction
self._cache_put(self._formatted_tools_cache, tools_key, formatted_tools)
return formatted_tools
# Apply tool search assembly if enabled (after formatting, before caching)
if hasattr(self, '_tool_search_config') and self._tool_search_config is not None:
try:
from ..tools.tool_search import assemble_tool_defs
# Get context length from LLM config if available
context_length = getattr(self, '_context_window_size', None)
# Assemble tools with bridge mode check
assembled_tools, metadata = assemble_tool_defs(
tool_defs=formatted_tools,
config=self._tool_search_config,
context_length=context_length
)
# Store metadata for bridge tool dispatch
self._tool_search_metadata = metadata
formatted_tools = assembled_tools
except ImportError:
# Tool search module not available, continue with original tools
logging.warning("Tool search requested but tool_search module not available")
# Strip __praisonai_deferrable__ from provider-facing tool payloads
# Keep the marker only for internal tool classification
cleaned_tools = []
for tool in formatted_tools:
if isinstance(tool, dict) and "__praisonai_deferrable__" in tool:
tool_copy = tool.copy()
tool_copy.pop("__praisonai_deferrable__", None)
cleaned_tools.append(tool_copy)
else:
cleaned_tools.append(tool)
# Cache the formatted tools with LRU eviction, including tool search metadata
self._cache_put(
self._formatted_tools_cache,
cache_key,
(cleaned_tools, getattr(self, "_tool_search_metadata", None)),
)
return cleaned_tools
def _build_multimodal_prompt(
self,
@@ -507,7 +549,7 @@ Your Goal: {self.goal}"""
emit_events=True
)
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
start_time = time.time()
# --- Context compaction (opt-in via ExecutionConfig.context_compaction) ---
@@ -577,6 +619,39 @@ Your Goal: {self.goal}"""
# Use the new _format_tools_for_completion helper method
formatted_tools = self._format_tools_for_completion(tools)
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
if stream is None:
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
try:
# First attempt: try with streaming enabled for better user experience
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
final_response = self._execute_unified_chat_completion(
messages=messages,
temperature=temperature,
tools=formatted_tools,
stream=True, # Try streaming first
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
response_format=response_format,
stream_callback=stream_callback,
emit_events=True
)
return final_response
except ValueError as e:
if "Streaming is not supported" in str(e):
# Fallback: retry with non-streaming for sync adapters
logging.debug(f"{self.name}: Streaming not supported by adapter, falling back to non-streaming")
stream = False # Set for the main execution below
else:
raise # Re-raise if it's a different ValueError
except Exception:
# For any other exception, fall back to non-streaming
logging.debug(f"{self.name}: Streaming attempt failed, falling back to non-streaming")
stream = False # Set for the main execution below
# If stream was explicitly set or fallback occurred, use the specified/fallback value
try:
# NEW: Unified protocol dispatch path (Issue #1304, #1362)
# UNIFIED: Single protocol-driven dispatch path (fixes DRY violation)
@@ -774,7 +849,7 @@ Your Goal: {self.goal}"""
messages,
temperature=1.0,
tools=None,
stream=True,
stream=None,
reasoning_steps=False,
task_name=None,
task_description=None,
@@ -806,6 +881,47 @@ Your Goal: {self.goal}"""
# Cache the dispatcher
self._unified_dispatcher = dispatcher
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
if stream is None:
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
try:
# First attempt: try with streaming enabled for better user experience
if stream_callback is None and hasattr(self, 'stream_emitter'):
stream_callback = getattr(self.stream_emitter, 'emit', None)
final_response = self._unified_dispatcher.chat_completion(
messages=messages,
tools=tools,
tool_choice=getattr(self, 'tool_choice', None),
temperature=temperature,
max_tokens=getattr(self, 'max_tokens', None),
stream=True, # Try streaming first
response_format=response_format,
execute_tool_fn=getattr(self, 'execute_tool', None),
console=self.console if (self.verbose or True) else None, # Enable console for streaming
display_fn=self._display_generating if self.verbose else None,
stream_callback=stream_callback,
emit_events=emit_events,
verbose=self.verbose,
max_iterations=10,
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
agent_name=getattr(self, 'name', 'assistant')
)
return final_response
except ValueError as e:
if "Streaming is not supported" in str(e):
# Fallback: retry with non-streaming for sync adapters
logging.debug(f"Agent: Streaming not supported by adapter, falling back to non-streaming")
stream = False # Set for the main execution below
else:
raise # Re-raise if it's a different ValueError
except Exception:
# For any other exception, fall back to non-streaming
logging.debug(f"Agent: Streaming attempt failed, falling back to non-streaming")
stream = False # Set for the main execution below
# Execute unified dispatch with all necessary parameters
# Includes all parameters from both legacy paths to ensure full compatibility
try:
@@ -857,7 +973,7 @@ Your Goal: {self.goal}"""
messages,
temperature=1.0,
tools=None,
stream=True,
stream=True, # Async methods keep stream=True default (async adapters support streaming vs sync smart fallback)
reasoning_steps=False,
task_name=None,
task_description=None,
@@ -135,6 +135,12 @@ class ToolExecutionMixin:
"""
logging.debug(f"{self.name} executing tool {function_name} with arguments: {arguments}")
# Handle bridge tool unwrapping BEFORE trace/stream/hooks (design invariant #6)
# Only intercept when tool_search is active; otherwise fall through to real tool execution
if (getattr(self, '_tool_search_config', None) is not None and
function_name in ("tool_search", "tool_describe", "tool_call")):
return self._handle_bridge_tool_call(function_name, arguments, tool_call_id)
# NOTE: tool_call callback is triggered by display_tool_call in openai_client.py
# Do NOT call it here to avoid duplicate output
@@ -954,3 +960,97 @@ class ToolExecutionMixin:
def pending_approval_count(self) -> int:
"""Number of approval requests still waiting."""
return len(self._pending_approvals)
def _handle_bridge_tool_call(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
"""
Handle bridge tool calls (tool_search, tool_describe, tool_call).
This implements the tool search unwrapping logic before trace/stream/hooks
as required by design invariant #6.
Args:
function_name: Bridge tool name
arguments: Arguments passed to bridge tool
tool_call_id: Optional tool call ID
Returns:
Result of bridge tool execution or unwrapped real tool call
"""
# Ensure tool search metadata is available
if not hasattr(self, '_tool_search_metadata') or self._tool_search_metadata is None:
return "Tool search not available or not in bridge mode"
metadata = self._tool_search_metadata
# Check if we're in bridge mode
if not metadata.get("bridge_mode", False):
return "Tool search not in bridge mode"
# Get deferrable tools from metadata
deferrable_tools = metadata.get("deferrable_tools", [])
if function_name == "tool_search":
# Handle tool_search bridge call
try:
from ..tools.tool_search import dispatch_tool_search
query = arguments.get("query", "")
limit = arguments.get("limit", None)
result = dispatch_tool_search(
query=query,
limit=limit,
deferrable_tools=deferrable_tools,
config=self._tool_search_config
)
return json.dumps(result, indent=2)
except ImportError:
return "Tool search module not available"
except Exception as e:
logging.error(f"Error in tool_search: {e}")
return f"Error searching tools: {e}"
elif function_name == "tool_describe":
# Handle tool_describe bridge call
try:
from ..tools.tool_search import dispatch_tool_describe
tool_name = arguments.get("tool_name", "")
result = dispatch_tool_describe(
tool_name=tool_name,
deferrable_tools=deferrable_tools
)
return json.dumps(result, indent=2)
except ImportError:
return "Tool search module not available"
except Exception as e:
logging.error(f"Error in tool_describe: {e}")
return f"Error describing tool: {e}"
elif function_name == "tool_call":
# Handle tool_call bridge - unwrap and recurse with real tool
try:
from ..tools.tool_search import resolve_underlying_call
# Unwrap the real tool call
real_function_name, real_arguments = resolve_underlying_call(function_name, arguments)
# Validate that the real tool is in our deferrable set (security check)
deferrable_names = {
tool_def.get("function", {}).get("name", "")
for tool_def in deferrable_tools
}
if real_function_name not in deferrable_names:
return f"Tool '{real_function_name}' is not available for execution"
# Recursively execute the real tool (this will go through normal execution path)
return self.execute_tool(real_function_name, real_arguments, tool_call_id)
except ImportError:
return "Tool search module not available"
except Exception as e:
logging.error(f"Error in tool_call unwrap: {e}")
return f"Error executing tool: {e}"
else:
return f"Unknown bridge tool: {function_name}"
@@ -39,6 +39,7 @@ __all__ = [
"CachingConfig",
"HooksConfig",
"SkillsConfig",
"ToolSearchConfig",
# Type aliases
"MemoryParam",
"KnowledgeParam",
@@ -122,6 +123,7 @@ _MODULE_MAP = {
"CachingConfig": "feature_configs",
"HooksConfig": "feature_configs",
"SkillsConfig": "feature_configs",
"ToolSearchConfig": "feature_configs",
"MemoryParam": "feature_configs",
"KnowledgeParam": "feature_configs",
"PlanningParam": "feature_configs",
@@ -8,6 +8,7 @@ Provides dataclasses for consolidated feature configuration:
- ReflectionConfig: Self-reflection settings
- GuardrailConfig: Safety and validation
- WebConfig: Web search and fetch
- ToolSearchConfig: Progressive tool disclosure
All configs follow the agent-centric pattern:
- False: Feature disabled (zero overhead)
@@ -16,21 +17,22 @@ All configs follow the agent-centric pattern:
- Instance: Pre-configured manager/engine
Usage:
from praisonaiagents import Agent, MemoryConfig, KnowledgeConfig
from praisonaiagents import Agent, MemoryConfig, KnowledgeConfig, ToolSearchConfig
# Simple enable
agent = Agent(instructions="...", memory=True)
agent = Agent(instructions="...", memory=True, tool_search=True)
# With config
agent = Agent(
instructions="...",
memory=MemoryConfig(backend="redis", user_id="user123"),
knowledge=KnowledgeConfig(sources=["docs/"], rerank=True),
tool_search=ToolSearchConfig(enabled="auto", threshold_pct=15),
)
"""
from dataclasses import dataclass, field
from typing import Dict, List, Any, Optional, Callable, Tuple, Union
from typing import Dict, List, Any, Optional, Callable, Tuple, Union, FrozenSet
from enum import Enum
# Import AutonomyConfig from canonical location (no circular dep)
@@ -1072,6 +1074,25 @@ class MultiAgentMemoryConfig:
}
# Import ToolSearchConfig from tools module to avoid duplication
def __get_tool_search_config():
try:
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
return _ToolSearchConfig
except ImportError:
# Fallback minimal config if tools module not available
@dataclass
class FallbackToolSearchConfig:
enabled: Union[bool, str] = "auto"
threshold_pct: float = 10.0
search_default_limit: int = 5
max_search_limit: int = 20
core_tools: Optional[FrozenSet[str]] = None
return FallbackToolSearchConfig
ToolSearchConfig = __get_tool_search_config()
class AutonomyLevel(str, Enum):
"""Autonomy levels for agent behavior."""
SUGGEST = "suggest"
@@ -1092,6 +1113,7 @@ CachingParam = Union[bool, CachingConfig]
HooksParam = Union[List[Any], HooksConfig]
SkillsParam = Union[List[str], SkillsConfig]
AutonomyParam = Union[bool, Dict[str, Any], "AutonomyConfig"]
ToolSearchParam = Union[bool, str, Dict[str, Any], ToolSearchConfig]
# =============================================================================
@@ -1343,6 +1365,21 @@ def resolve_autonomy(value: AutonomyParam) -> Optional[AutonomyConfig]:
return value
def resolve_tool_search(value: ToolSearchParam) -> Optional[ToolSearchConfig]:
"""Resolve tool_search= parameter following precedence ladder."""
if value is None or value is False:
return None
if value is True:
return ToolSearchConfig()
if isinstance(value, str):
return ToolSearchConfig(enabled=value)
if isinstance(value, dict):
return ToolSearchConfig(**value)
if isinstance(value, ToolSearchConfig):
return value
return value
__all__ = [
# Enums
"MemoryBackend",
@@ -1368,6 +1405,7 @@ __all__ = [
"HooksConfig",
"SkillsConfig",
"AutonomyConfig",
"ToolSearchConfig",
# Config classes (Multi-Agent)
"MultiAgentHooksConfig",
"MultiAgentOutputConfig",
@@ -1388,6 +1426,7 @@ __all__ = [
"HooksParam",
"SkillsParam",
"AutonomyParam",
"ToolSearchParam",
# Precedence ladder resolvers
"resolve_memory",
"resolve_knowledge",
@@ -1399,4 +1438,5 @@ __all__ = [
"resolve_execution",
"resolve_caching",
"resolve_autonomy",
"resolve_tool_search",
]
@@ -83,11 +83,23 @@ def __getattr__(name):
elif name == "OAUTH_CALLBACK_PATH":
from .mcp_oauth_callback import OAUTH_CALLBACK_PATH
return OAUTH_CALLBACK_PATH
# Protocols (lazy loaded)
elif name == "MCPClientProtocol":
from .protocols import MCPClientProtocol
return MCPClientProtocol
# Loader (lazy loaded)
elif name == "load_mcp_tools":
from .loader import load_mcp_tools
return load_mcp_tools
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
__all__ = [
# Client
"MCP",
# Protocols (lazy loaded)
"MCPClientProtocol",
# Loader (lazy loaded)
"load_mcp_tools",
# Server (lazy loaded)
"ToolsMCPServer",
"launch_tools_mcp_server",
@@ -0,0 +1,92 @@
"""
MCP Tools Loader for PraisonAI Agents.
Provides a thin helper function to load MCP tools for agents
following the agent-centric design principles in AGENTS.md.
"""
from typing import List, Optional
from ..memory.mcp_config import MCPConfig, MCPConfigManager
from .mcp import MCP
def load_mcp_tools(
names: Optional[List[str]] = None,
*,
configs: Optional[List[MCPConfig]] = None,
prefix_tools: bool = True,
) -> List[MCP]:
"""
Load MCP tools for use with agents.
This is a thin helper that bridges the gap between configuration
and agent tool setup, following the "few lines to run an agent"
principle from AGENTS.md.
Args:
names: Specific config names to load (None = all enabled)
configs: Optional injected configs from wrapper TOML loader
prefix_tools: Whether to prefix tool names when multiple servers
are loaded to avoid collisions (default: True).
NOTE: Currently not implemented - collisions possible with multiple servers.
Returns:
List of MCP client instances ready for agent use
Examples:
# Load all enabled MCP servers from JSON config
>>> from praisonaiagents import Agent
>>> from praisonaiagents.mcp import load_mcp_tools
>>> agent = Agent(name="assistant", tools=load_mcp_tools())
# Load specific servers only
>>> tools = load_mcp_tools(["filesystem", "github"])
>>> agent = Agent(name="coder", tools=tools)
# Inject configs from wrapper TOML
>>> from praisonai.cli.configuration import get_config_loader
>>> loader = get_config_loader()
>>> config = loader.load()
>>> toml_configs = [MCPConfig(...) for server in config.mcp.servers]
>>> tools = load_mcp_tools(configs=toml_configs)
"""
mcp_clients: List[MCP] = []
if configs is not None:
# Use injected configs from wrapper (TOML loading)
target_configs = configs
if names:
# Filter by names if specified
target_configs = [c for c in configs if c.name in names]
else:
# Load from MCPConfigManager (JSON files)
manager = MCPConfigManager()
if names:
target_configs = [manager.get_config(name) for name in names]
target_configs = [c for c in target_configs if c and c.enabled]
else:
target_configs = manager.get_enabled_configs()
for config in target_configs:
if not config.enabled:
continue
# Convert config to MCP instance
mcp = config.to_mcp_instance()
if mcp:
# Apply tool filtering if configured (B3)
# TODO: Wire include/exclude filters when B3 is implemented
# Apply tool prefix if multiple servers (B4)
if prefix_tools and len(target_configs) > 1:
# Sanitize server name for use as prefix
server_name = config.name.replace('-', '_').replace(' ', '_')
# TODO: Tool name prefixing not yet implemented
# Need to modify tool names in MCP instance to avoid collisions
# e.g., "read_file" -> "filesystem_read_file"
pass
mcp_clients.append(mcp)
return mcp_clients
+115 -21
View File
@@ -187,7 +187,8 @@ class MCP:
```
"""
def __init__(self, command_or_string=None, args=None, *, command=None, timeout=60, debug=False, **kwargs):
def __init__(self, command_or_string=None, args=None, *, command=None, timeout=60, debug=False,
allowed_tools: Optional[List[str]] = None, disabled_tools: Optional[List[str]] = None, **kwargs):
"""
Initialize the MCP connection and get tools.
@@ -201,7 +202,13 @@ class MCP:
command: Alternative parameter name for backward compatibility
timeout: Timeout in seconds for MCP server initialization and tool calls (default: 60)
debug: Enable debug logging for MCP operations (default: False)
allowed_tools: Include whitelist - only these tools will be available (default: None = all tools)
disabled_tools: Exclude blacklist - these tools will be filtered out (default: None = no exclusions)
**kwargs: Additional parameters for StdioServerParameters
Note:
If both allowed_tools and disabled_tools are specified, allowed_tools takes precedence
(include wins over exclude).
"""
# Check if MCP is available
if not MCP_AVAILABLE:
@@ -240,6 +247,8 @@ class MCP:
# Store additional parameters
self.timeout = timeout
self.debug = debug
self.allowed_tools = allowed_tools
self.disabled_tools = disabled_tools
# Check if this is a WebSocket URL (ws:// or wss://)
if isinstance(command_or_string, str) and re.match(r'^wss?://', command_or_string):
@@ -254,7 +263,7 @@ class MCP:
auth_token=auth_token,
options=kwargs
)
self._tools = list(self.websocket_client.tools)
self._tools = self._apply_tool_filters(list(self.websocket_client.tools))
self.is_sse = False
self.is_http_stream = False
self.is_websocket = True
@@ -268,7 +277,7 @@ class MCP:
# Legacy SSE URL - use SSE transport for backward compatibility
from .mcp_sse import SSEMCPClient
self.sse_client = SSEMCPClient(command_or_string, debug=debug, timeout=timeout)
self._tools = list(self.sse_client.tools)
self._tools = self._apply_tool_filters(list(self.sse_client.tools))
self.is_sse = True
self.is_http_stream = False
self.is_npx = False
@@ -295,7 +304,7 @@ class MCP:
timeout=timeout,
options=transport_options
)
self._tools = list(self.http_stream_client.tools)
self._tools = self._apply_tool_filters(list(self.http_stream_client.tools))
self.is_sse = False
self.is_http_stream = True
self.is_npx = False
@@ -325,20 +334,10 @@ class MCP:
self.is_sse = False
self.is_http_stream = False
# Ensure UTF-8 encoding in environment for Docker compatibility
env = kwargs.get('env', {})
if not env:
env = os.environ.copy()
# Always set Python encoding
env['PYTHONIOENCODING'] = 'utf-8'
# Only set locale variables on Unix systems
if platform.system() != 'Windows':
env.update({
'LC_ALL': 'C.UTF-8',
'LANG': 'C.UTF-8'
})
# Build safe environment for stdio MCP servers
# Use safe baseline + explicit env from config (B5 security policy)
custom_env = kwargs.get('env', {})
env = self._build_safe_env(custom_env)
kwargs['env'] = env
@@ -369,7 +368,7 @@ class MCP:
self._initialize_npx_mcp_tools(cmd, arguments)
else:
# Generate tool functions immediately and store them
self._tools = self._generate_tool_functions()
self._tools = self._apply_tool_filters(self._generate_tool_functions())
def _generate_tool_functions(self) -> List[Callable]:
"""
@@ -489,7 +488,7 @@ class MCP:
logging.debug(f"Initializing NPX MCP tools with command: {cmd} {' '.join(arguments)}")
# Generate tool functions using the regular MCP approach
self._tools = self._generate_tool_functions()
self._tools = self._apply_tool_filters(self._generate_tool_functions())
if self.debug:
logging.debug(f"Generated {len(self._tools)} NPX MCP tools")
@@ -499,6 +498,100 @@ class MCP:
logging.error(f"Failed to initialize NPX MCP tools: {e}")
raise RuntimeError(f"Failed to initialize NPX MCP tools: {e}")
def _apply_tool_filters(self, raw_tools: List[Any]) -> List[Any]:
"""
Apply tool filtering based on allowed_tools and disabled_tools.
Args:
raw_tools: Unfiltered tool list
Returns:
Filtered tool list
"""
if not self.allowed_tools and not self.disabled_tools:
return raw_tools
# Import filter functions
from .mcp_utils import filter_tools_by_allowlist, filter_disabled_tools
# Convert tools to the format expected by filter functions (list of dicts with 'name')
# Raw tools might be function objects, so we need to extract names
tool_defs = []
for tool in raw_tools:
if hasattr(tool, '__name__'):
tool_defs.append({"name": tool.__name__, "_tool_obj": tool})
elif isinstance(tool, dict) and 'name' in tool:
tool_defs.append(tool)
else:
# Skip tools that don't have a clear name
continue
# Include wins over exclude - apply allowlist exclusively if provided
if self.allowed_tools:
tool_defs = filter_tools_by_allowlist(tool_defs, self.allowed_tools)
elif self.disabled_tools:
tool_defs = filter_disabled_tools(tool_defs, self.disabled_tools)
# Convert back to original format
filtered_tools = []
for tool_def in tool_defs:
if "_tool_obj" in tool_def:
filtered_tools.append(tool_def["_tool_obj"])
else:
filtered_tools.append(tool_def)
return filtered_tools
def _build_safe_env(self, custom_env: Optional[dict] = None) -> dict:
"""
Build a safe environment for stdio MCP servers.
Merges only safe baseline environment variables with explicit custom ones,
following the security policy outlined in AGENTS.md.
Args:
custom_env: Optional custom environment variables from config
Returns:
Safe environment dictionary
"""
# Safe baseline environment variables
safe_baseline = {
'PATH': os.environ.get('PATH', ''),
'HOME': os.environ.get('HOME', os.path.expanduser('~')),
'USER': os.environ.get('USER', ''),
'LANG': os.environ.get('LANG', 'C.UTF-8'),
'LC_ALL': os.environ.get('LC_ALL', 'C.UTF-8'),
'PYTHONIOENCODING': 'utf-8',
}
# Platform-specific safe variables
if platform.system() == 'Windows':
safe_baseline.update({
'SYSTEMROOT': os.environ.get('SYSTEMROOT', ''),
'COMSPEC': os.environ.get('COMSPEC', ''),
'USERNAME': os.environ.get('USERNAME', ''),
'USERPROFILE': os.environ.get('USERPROFILE', ''),
'APPDATA': os.environ.get('APPDATA', ''),
'LOCALAPPDATA': os.environ.get('LOCALAPPDATA', ''),
'TEMP': os.environ.get('TEMP', ''),
'TMP': os.environ.get('TMP', ''),
})
else:
safe_baseline.update({
'TMPDIR': os.environ.get('TMPDIR', '/tmp'),
'SHELL': os.environ.get('SHELL', '/bin/sh'),
})
# Start with safe baseline
env = {k: v for k, v in safe_baseline.items() if v} # Only include non-empty values
# Add explicit custom environment variables from config
if custom_env:
env.update(custom_env)
return env
def __iter__(self) -> Iterable[Callable]:
"""
Allow the MCP instance to be used directly as an iterable of tools.
@@ -609,7 +702,8 @@ class MCP:
"name": tool.name,
"description": tool.description if hasattr(tool, 'description') else f"Call the {tool.name} tool",
"parameters": parameters
}
},
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
})
return openai_tools
@@ -156,7 +156,8 @@ class HTTPStreamMCPTool:
"name": self.name,
"description": self.description,
"parameters": fixed_schema
}
},
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
}
class HTTPStreamTransport:
@@ -121,7 +121,8 @@ class SSEMCPTool:
"name": self.name,
"description": self.description,
"parameters": fixed_schema
}
},
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
}
class SSEMCPClient:
@@ -363,7 +363,8 @@ class WebSocketMCPTool:
"name": self.name,
"description": self.description,
"parameters": fixed_schema
}
},
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
}
class WebSocketMCPClient:
@@ -0,0 +1,110 @@
"""
Model Context Protocol (MCP) client protocols for PraisonAI Agents.
Defines protocol interfaces for MCP client extensibility following
the protocol-driven design pattern outlined in AGENTS.md.
"""
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
@runtime_checkable
class MCPClientProtocol(Protocol):
"""
Protocol interface for MCP client implementations.
Defines the minimal contract that all MCP clients must follow,
enabling extensibility through different transport mechanisms
(stdio, SSE, HTTP stream, WebSocket) while maintaining a
consistent interface for agents.
"""
def list_tools(self) -> List[Dict[str, Any]]:
"""
List available tools from the MCP server.
Returns:
List of tool definitions from the server
"""
...
def get_tools(self) -> List[Dict[str, Any]]:
"""
Get available tools from the MCP server.
Alias for list_tools() to maintain backward compatibility.
Returns:
List of tool definitions from the server
"""
...
def call_tool(self, name: str, args: Dict[str, Any]) -> Any:
"""
Call a tool on the MCP server.
Args:
name: Tool name to call
args: Tool arguments
Returns:
Tool execution result
Raises:
Exception: If tool call fails
"""
...
def shutdown(self) -> None:
"""
Shutdown the MCP client and clean up resources.
This should gracefully close connections, terminate
subprocesses, and release any held resources.
"""
...
async def async_list_tools(self) -> List[Dict[str, Any]]:
"""
Asynchronously list available tools from the MCP server.
Returns:
List of tool definitions from the server
"""
...
async def async_get_tools(self) -> List[Dict[str, Any]]:
"""
Asynchronously get available tools from the MCP server.
Alias for async_list_tools() to maintain backward compatibility.
Returns:
List of tool definitions from the server
"""
...
async def async_call_tool(self, name: str, args: Dict[str, Any]) -> Any:
"""
Asynchronously call a tool on the MCP server.
Args:
name: Tool name to call
args: Tool arguments
Returns:
Tool execution result
Raises:
Exception: If tool call fails
"""
...
async def async_shutdown(self) -> None:
"""
Asynchronously shutdown the MCP client and clean up resources.
This should gracefully close connections, terminate
subprocesses, and release any held resources.
"""
...
@@ -81,7 +81,7 @@ class MCPConfig:
MCP instance ready for use with agents
"""
try:
from praisonaiagents.tools.mcp import MCP
from praisonaiagents.mcp import MCP
# Resolve environment variables
resolved_env = {}
@@ -144,6 +144,8 @@ class HierarchicalSessionStore(DefaultSessionStore):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._extended_cache: Dict[str, ExtendedSessionData] = {}
self._cache_mtimes: Dict[str, float] = {} # Track file modification times
def _load_session_from_disk(self, session_id: str, filepath: str) -> ExtendedSessionData:
"""Load extended session JSON from disk (caller must hold FileLock)."""
@@ -173,6 +175,43 @@ class HierarchicalSessionStore(DefaultSessionStore):
if isinstance(cached, ExtendedSessionData):
self._extended_cache[session_id] = cached
return result
def _is_cache_valid(self, session_id: str) -> bool:
"""Check if cached session is still valid based on file mtime."""
if session_id not in self._extended_cache:
return False
filepath = self._get_session_path(session_id)
if not os.path.exists(filepath):
return False
try:
current_mtime = os.path.getmtime(filepath)
cached_mtime = self._cache_mtimes.get(session_id, 0)
return current_mtime <= cached_mtime
except (OSError, IOError):
return False
def _read_session_fresh(self, session_id: str) -> ExtendedSessionData:
"""Reload from disk and keep _cache and _extended_cache in sync."""
session = super()._read_session_fresh(session_id)
if not isinstance(session, ExtendedSessionData):
session = ExtendedSessionData.from_session_data(session)
with self._lock:
self._cache[session_id] = session
# Update cache with fresh file mtime
filepath = self._get_session_path(session_id)
try:
mtime = os.path.getmtime(filepath) if os.path.exists(filepath) else time.time()
except (OSError, IOError):
mtime = time.time()
with self._lock:
self._extended_cache[session_id] = session
self._cache_mtimes[session_id] = mtime
return session
def add_message(
self,
@@ -204,35 +243,14 @@ class HierarchicalSessionStore(DefaultSessionStore):
)
def _load_extended_session(self, session_id: str, force_reload: bool = False) -> ExtendedSessionData:
"""Load extended session from disk."""
filepath = self._get_session_path(session_id)
# Check cache first (unless force reload)
if not force_reload:
with self._lock:
if session_id in self._extended_cache:
return self._extended_cache[session_id]
# Load from disk
if not os.path.exists(filepath):
session = ExtendedSessionData(session_id=session_id)
with self._lock:
self._extended_cache[session_id] = session
return session
with FileLock(filepath, self.lock_timeout):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
session = ExtendedSessionData.from_dict(data)
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to load session {session_id}: {e}")
session = ExtendedSessionData(session_id=session_id)
"""Load extended session with smart caching based on file modification time."""
# Force reload bypasses cache validation
if force_reload or not self._is_cache_valid(session_id):
return self._read_session_fresh(session_id)
# Cache is valid, return cached version
with self._lock:
self._extended_cache[session_id] = session
return session
return self._extended_cache[session_id]
def _save_extended_session(self, session: ExtendedSessionData) -> bool:
"""Save extended session to disk."""
@@ -260,8 +278,15 @@ class HierarchicalSessionStore(DefaultSessionStore):
os.replace(temp_path, filepath)
# Update cache with current file mtime after successful write
try:
mtime = os.path.getmtime(filepath)
except (OSError, IOError):
mtime = time.time()
with self._lock:
self._extended_cache[session.session_id] = session
self._cache_mtimes[session.session_id] = mtime
return True
except (IOError, OSError) as e:
@@ -304,12 +329,13 @@ class HierarchicalSessionStore(DefaultSessionStore):
metadata=metadata or {},
)
# Update parent's children list
# Update parent's children list without clobbering concurrent message writes
if parent_id:
parent = self._load_extended_session(parent_id)
if sid not in parent.children_ids:
parent.children_ids.append(sid)
self._save_extended_session(parent)
def _apply(parent_session: SessionData) -> None:
assert isinstance(parent_session, ExtendedSessionData)
if sid not in parent_session.children_ids:
parent_session.children_ids.append(sid)
self._modify_session_locked(parent_id, _apply, error_label="update parent children")
self._save_extended_session(session)
return sid
@@ -356,11 +382,15 @@ class HierarchicalSessionStore(DefaultSessionStore):
metadata=copy.deepcopy(parent.metadata),
)
# Update parent's children list
parent.children_ids.append(new_id)
self._save_extended_session(forked)
self._save_extended_session(parent)
def _register_fork(parent: SessionData) -> None:
if new_id not in parent.children_ids:
parent.children_ids.append(new_id)
self._modify_session_locked(
session_id, _register_fork, error_label="register forked session"
)
return new_id
@@ -411,18 +441,22 @@ class HierarchicalSessionStore(DefaultSessionStore):
Returns:
The snapshot ID
"""
# Force reload to get latest messages
session = self._load_extended_session(session_id, force_reload=True)
snapshot = SessionSnapshot(
session_id=session_id,
message_index=len(session.messages) - 1 if session.messages else -1,
message_index=-1, # Set from fresh session inside locked write
label=label,
metadata=metadata or {},
)
session.snapshots.append(snapshot)
self._save_extended_session(session)
def _record_snapshot(session: SessionData) -> None:
snapshot.message_index = (
len(session.messages) - 1 if session.messages else -1
)
session.snapshots.append(snapshot)
self._modify_session_locked(
session_id, _record_snapshot, error_label="create snapshot"
)
return snapshot.id
@@ -442,26 +476,30 @@ class HierarchicalSessionStore(DefaultSessionStore):
Returns:
True if successful
"""
session = self._load_extended_session(session_id)
# Find the snapshot
snapshot = None
for s in session.snapshots:
if s.id == snapshot_id:
snapshot = s
break
if snapshot is None:
logger.warning(f"Snapshot {snapshot_id} not found")
def _apply(session: SessionData) -> None:
assert isinstance(session, ExtendedSessionData)
# Find the snapshot
snapshot = None
for s in session.snapshots:
if s.id == snapshot_id:
snapshot = s
break
if snapshot is None:
logger.warning(f"Snapshot {snapshot_id} not found")
raise ValueError(f"Snapshot {snapshot_id} not found")
# Revert messages
if snapshot.message_index >= 0:
session.messages = session.messages[:snapshot.message_index + 1]
else:
session.messages = []
try:
return self._modify_session_locked(session_id, _apply, error_label="revert to snapshot")
except ValueError:
return False
# Revert messages
if snapshot.message_index >= 0:
session.messages = session.messages[:snapshot.message_index + 1]
else:
session.messages = []
return self._save_extended_session(session)
def revert_to_message(self, session_id: str, message_index: int) -> bool:
"""
@@ -474,27 +512,33 @@ class HierarchicalSessionStore(DefaultSessionStore):
Returns:
True if successful
"""
# Force reload to get latest messages
session = self._load_extended_session(session_id, force_reload=True)
if message_index < 0 or message_index >= len(session.messages):
logger.warning(f"Invalid message index {message_index}")
def _apply(session: SessionData) -> None:
assert isinstance(session, ExtendedSessionData)
if message_index < 0 or message_index >= len(session.messages):
logger.warning(f"Invalid message index {message_index}")
raise ValueError(f"Invalid message index {message_index}")
session.messages = session.messages[:message_index + 1]
try:
return self._modify_session_locked(session_id, _apply, error_label="revert to message")
except ValueError:
return False
session.messages = session.messages[:message_index + 1]
return self._save_extended_session(session)
def share_session(self, session_id: str) -> bool:
"""Mark a session as shared."""
session = self._load_extended_session(session_id)
session.is_shared = True
return self._save_extended_session(session)
def _apply(session: SessionData) -> None:
assert isinstance(session, ExtendedSessionData)
session.is_shared = True
return self._modify_session_locked(session_id, _apply, error_label="share session")
def unshare_session(self, session_id: str) -> bool:
"""Mark a session as not shared."""
session = self._load_extended_session(session_id)
session.is_shared = False
return self._save_extended_session(session)
def _apply(session: SessionData) -> None:
assert isinstance(session, ExtendedSessionData)
session.is_shared = False
return self._modify_session_locked(session_id, _apply, error_label="unshare session")
def is_shared(self, session_id: str) -> bool:
"""Check if a session is shared."""
@@ -503,9 +547,10 @@ class HierarchicalSessionStore(DefaultSessionStore):
def set_title(self, session_id: str, title: str) -> bool:
"""Set session title."""
session = self._load_extended_session(session_id)
session.title = title
return self._save_extended_session(session)
def _apply(session: SessionData) -> None:
assert isinstance(session, ExtendedSessionData)
session.title = title
return self._modify_session_locked(session_id, _apply, error_label="set session title")
async def auto_title(self, session_id: str) -> bool:
"""Generate and set title automatically from first exchange.
@@ -560,12 +605,19 @@ class HierarchicalSessionStore(DefaultSessionStore):
title = await generate_title_async(user_msg, assistant_msg)
if title and title.strip():
# Reload session to avoid overwriting concurrent updates
fresh_session = await asyncio.to_thread(self._load_extended_session, session_id)
# Only set title if it's still empty
if not fresh_session.title or not fresh_session.title.strip():
fresh_session.title = title.strip()
return await asyncio.to_thread(self._save_extended_session, fresh_session)
# Use locked read-modify-write to avoid overwriting concurrent updates
def _apply(fresh_session: SessionData) -> None:
assert isinstance(fresh_session, ExtendedSessionData)
# Only set title if it's still empty
if not fresh_session.title or not fresh_session.title.strip():
fresh_session.title = title.strip()
return await asyncio.to_thread(
self._modify_session_locked,
session_id,
_apply,
error_label="auto-title session"
)
except Exception as e:
# Title generation failed - log with context instead of silent failure
@@ -577,7 +629,17 @@ class HierarchicalSessionStore(DefaultSessionStore):
def get_extended_session(self, session_id: str) -> ExtendedSessionData:
"""Get extended session data."""
return self._load_extended_session(session_id)
return self._read_session_fresh(session_id)
def invalidate_cache(self, session_id: Optional[str] = None) -> None:
"""Invalidate base and extended in-memory caches atomically."""
with self._lock:
if session_id:
self._cache.pop(session_id, None)
self._extended_cache.pop(session_id, None)
else:
self._cache.clear()
self._extended_cache.clear()
def export_session(self, session_id: str) -> Dict[str, Any]:
"""
@@ -15,10 +15,10 @@ import threading
import time
# fcntl is Unix-only; on Windows, use msvcrt for file locking
if sys.platform != 'win32':
try:
import fcntl
_HAS_FCNTL = True
else:
except ImportError:
_HAS_FCNTL = False
from dataclasses import dataclass, field
from datetime import datetime, timezone
@@ -167,11 +167,11 @@ class FileLock:
if _HAS_FCNTL:
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
else:
# Warn once about degraded locking (should never happen with current platform detection)
# Warn once about degraded locking on non-Windows platforms without fcntl
global _WARNED_NO_FCNTL
if not _WARNED_NO_FCNTL:
logger.warning(
"File locking unavailable on this platform (no fcntl/msvcrt); "
"File locking unavailable on this platform (fcntl not available); "
"concurrent writers may corrupt session files."
)
_WARNED_NO_FCNTL = True
@@ -265,32 +265,22 @@ class DefaultSessionStore:
def _load_session(self, session_id: str) -> SessionData:
"""Load session from disk with file locking."""
filepath = self._get_session_path(session_id)
# Check cache first
with self._lock:
if session_id in self._cache:
return self._cache[session_id]
# Load from disk
if not os.path.exists(filepath):
session = SessionData(session_id=session_id)
# When a session file exists, always reload under FileLock so reads
# from another DefaultSessionStore instance (or process) are visible.
if os.path.exists(filepath):
with FileLock(filepath, self.lock_timeout):
session = self._load_session_from_disk(session_id, filepath)
with self._lock:
self._cache[session_id] = session
return session
with FileLock(filepath, self.lock_timeout):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
session = SessionData.from_dict(data)
except (json.JSONDecodeError, IOError) as e:
logger.warning(f"Failed to load session {session_id}: {e}")
session = SessionData(session_id=session_id)
with self._lock:
if session_id in self._cache:
return self._cache[session_id]
session = SessionData(session_id=session_id)
self._cache[session_id] = session
return session
return session
def _load_session_from_disk(self, session_id: str, filepath: str) -> SessionData:
"""Load session JSON from disk (caller must hold FileLock)."""
@@ -540,6 +530,29 @@ class DefaultSessionStore:
error_label="clear session",
)
def set_chat_history(
self,
session_id: str,
messages: List[Dict[str, str]],
) -> bool:
"""Replace session messages atomically (file-locked read-modify-write)."""
def _apply(session: SessionData) -> None:
session.messages.clear()
for msg in messages:
session.messages.append(
SessionMessage(
role=msg.get("role", "user"),
content=msg.get("content", ""),
timestamp=msg.get("timestamp", time.time()),
metadata=msg.get("metadata", {}),
)
)
return self._modify_session_locked(
session_id, _apply, error_label="set chat history"
)
def update_session_metadata(self, session_id: str, **fields: Any) -> bool:
"""Merge run stats / metadata fields into a persisted session."""
if not fields:
@@ -0,0 +1,584 @@
"""
Tool Search - Progressive disclosure layer for MCP and non-core plugin tools.
Implements Hermes-parity bridge tools with lazy-loaded implementation:
- tool_search(query) → matches
- tool_describe(name) → full schema
- tool_call(name, args) → unwrap → execute_tool(real_name)
Architecture invariants (ported from Hermes):
1. Core tools never defer - PRAISONAI_CORE_TOOLS
2. Unknown tools stay visible - never silently dropped
3. Stateless catalog - rebuilt every assembly from current tool-defs
4. skip_assembly for bridge dispatch - handlers read pre-assembly deferrable set
5. Session scoping - catalog = deferrable subset of agent's own tool list
6. Unwrap before trace/stream - hooks, approval, AG-UI show real tool name
7. Auto threshold - deferrable schema tokens ≥ threshold_pct * context_length
8. BM25 inlined - no new dependency; substring fallback on tool name
"""
import re
import math
from typing import Dict, List, Optional, Union, Protocol, Set, FrozenSet, Tuple, Any
from dataclasses import dataclass, field
from collections import defaultdict
# Type definitions matching Hermes patterns
ToolDef = Dict[str, Any] # OpenAI function schema
ToolDefList = List[ToolDef]
# Core tools that never defer (mirrors Hermes _HERMES_CORE_TOOLS)
PRAISONAI_CORE_TOOLS: FrozenSet[str] = frozenset({
# File operations
"read_file", "write_file", "list_files", "get_file_info",
"copy_file", "move_file", "delete_file",
# Shell operations
"execute_command", "list_processes", "kill_process", "get_system_info",
# Web operations
"search_web", "web_search", "internet_search", "web_crawl", "crawl_web",
# Schedule operations (agent-centric scheduling)
"schedule_add", "schedule_list", "schedule_remove",
# Memory operations (active memory)
"store_memory", "search_memory",
# Clarify tool (human-in-the-loop clarification)
"clarify",
})
@dataclass
class ToolSearchConfig:
"""Configuration for Tool Search feature."""
enabled: Union[bool, str] = "auto" # auto | on | off | True | False
threshold_pct: float = 10.0 # Percentage of context window for deferral threshold
search_default_limit: int = 5 # Default number of search results
max_search_limit: int = 20 # Maximum search results allowed
core_tools: Optional[FrozenSet[str]] = None # Override core tools set
@classmethod
def from_raw(cls, raw_config: Any) -> "ToolSearchConfig":
"""Create config from various input formats (bool, dict, ToolSearchConfig)."""
if isinstance(raw_config, cls):
return raw_config
elif isinstance(raw_config, bool):
return cls(enabled="on" if raw_config else "off")
elif isinstance(raw_config, str):
if raw_config.lower() in ("true", "on", "yes", "1"):
return cls(enabled="on")
elif raw_config.lower() in ("false", "off", "no", "0"):
return cls(enabled="off")
elif raw_config.lower() == "auto":
return cls(enabled="auto")
else:
raise ValueError(f"Invalid tool_search string value: {raw_config}")
elif isinstance(raw_config, dict):
return cls(**raw_config)
else:
raise TypeError(f"Invalid tool_search type: {type(raw_config)}")
class ToolSearchProtocol(Protocol):
"""Protocol for tool search implementations."""
def search_catalog(self, query: str, limit: int = 5) -> List[Dict[str, str]]:
"""Search for tools matching the query."""
...
def describe_tool(self, tool_name: str) -> Optional[Dict[str, Any]]:
"""Get full schema for a specific tool."""
...
def resolve_underlying_call(self, tool_name: str, tool_args: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
"""Unwrap bridge call to real tool name and args."""
...
def classify_tools(tool_defs: ToolDefList, config: ToolSearchConfig) -> Tuple[ToolDefList, ToolDefList]:
"""
Classify tools into core (never defer) vs deferrable.
Args:
tool_defs: List of OpenAI function schemas
config: Tool search configuration
Returns:
Tuple of (core_tools, deferrable_tools)
"""
core_tools = []
deferrable_tools = []
# Use configured core tools or default
core_tool_names = config.core_tools or PRAISONAI_CORE_TOOLS
for tool_def in tool_defs:
tool_name = tool_def.get("function", {}).get("name", "")
# Check if it's a core tool
if tool_name in core_tool_names:
core_tools.append(tool_def)
continue
# Check for deferrable marker from MCP or custom tools
if _is_tool_deferrable(tool_def):
deferrable_tools.append(tool_def)
else:
# Unknown tools stay visible (invariant #2)
core_tools.append(tool_def)
return core_tools, deferrable_tools
def _is_tool_deferrable(tool_def: ToolDef) -> bool:
"""Check if a tool should be deferred based on markers."""
# Check for MCP deferrable marker
if tool_def.get("__praisonai_deferrable__", False):
return True
# Check function metadata for deferrable flag
function_def = tool_def.get("function", {})
if function_def.get("deferrable", False):
return True
# Check tool name patterns for MCP tools (prefix only, to avoid false-positives)
tool_name = function_def.get("name", "")
if tool_name.startswith("mcp_"):
return True
return False
def estimate_tool_schema_tokens(tool_defs: ToolDefList) -> int:
"""
Estimate token count for tool schemas.
Rough approximation: 1 token ≈ 4 characters for English text.
Function schemas are structured JSON so may be more dense.
"""
if not tool_defs:
return 0
# Convert to JSON and estimate
import json
total_chars = 0
for tool_def in tool_defs:
try:
json_str = json.dumps(tool_def, separators=(',', ':'))
total_chars += len(json_str)
except (TypeError, ValueError):
# Fallback estimation
total_chars += len(str(tool_def))
# Conservative estimate: 1 token per 3.5 characters for JSON
return int(total_chars / 3.5)
def should_defer_tools(
deferrable_tools: ToolDefList,
config: ToolSearchConfig,
context_length: Optional[int] = None
) -> bool:
"""
Determine if tools should be deferred based on auto threshold.
Args:
deferrable_tools: List of deferrable tool schemas
config: Tool search configuration
context_length: Model context window size
Returns:
True if tools should be deferred
"""
if config.enabled == "off" or config.enabled is False:
return False
elif config.enabled == "on" or config.enabled is True:
return True
elif config.enabled == "auto":
# Auto mode: check threshold
if not deferrable_tools:
return False
deferrable_tokens = estimate_tool_schema_tokens(deferrable_tools)
# Use fallback if context length unknown
context_limit = context_length or 20000
threshold_tokens = int(context_limit * (config.threshold_pct / 100.0))
return deferrable_tokens >= threshold_tokens
else:
return False
class BM25ToolSearcher:
"""
Simple BM25 implementation for tool search.
No external dependencies - inlined from Hermes approach.
"""
def __init__(self, tool_catalog: List[Dict[str, str]]):
"""
Initialize with tool catalog.
Args:
tool_catalog: List of dicts with 'name', 'description' keys
"""
self.catalog = tool_catalog
self.term_frequencies = []
self.doc_frequencies = defaultdict(int)
self.total_docs = len(tool_catalog)
# Preprocess documents
self._build_index()
def _tokenize(self, text: str) -> List[str]:
"""Simple tokenization - lowercase, alphanumeric + underscores."""
if not text:
return []
return re.findall(r'\b[a-zA-Z0-9_]+\b', text.lower())
def _build_index(self):
"""Build BM25 index from catalog."""
total_length = 0
# Calculate term frequencies for each document
for item in self.catalog:
doc_text = f"{item['name']} {item['description']}"
tokens = self._tokenize(doc_text)
# Term frequency for this doc
tf = defaultdict(int)
for token in tokens:
tf[token] += 1
self.term_frequencies.append(dict(tf))
total_length += len(tokens)
# Document frequency (how many docs contain each term)
unique_tokens = set(tokens)
for token in unique_tokens:
self.doc_frequencies[token] += 1
# Cache average document length to avoid O(n) recomputation in hot path
self._cached_avg_doc_length = total_length / len(self.catalog) if self.catalog else 0.0
def search(self, query: str, limit: int = 5) -> List[Dict[str, str]]:
"""
Search using BM25 scoring.
Args:
query: Search query string
limit: Maximum results to return
Returns:
List of matching tool items, sorted by relevance
"""
query_tokens = self._tokenize(query)
if not query_tokens:
return []
scores = []
k1, b = 1.5, 0.75 # BM25 parameters
for item, tf in zip(self.catalog, self.term_frequencies, strict=True):
score = 0.0
doc_length = sum(tf.values())
for token in query_tokens:
if token in tf:
# BM25 formula
idf = math.log((self.total_docs - self.doc_frequencies[token] + 0.5) /
(self.doc_frequencies[token] + 0.5))
term_freq = tf[token]
score += idf * (term_freq * (k1 + 1)) / (
term_freq + k1 * (1 - b + b * (doc_length / self._cached_avg_doc_length))
)
if score > 0:
scores.append((score, item))
# Sort by score descending and return top results
scores.sort(key=lambda x: x[0], reverse=True)
return [item for _, item in scores[:limit]]
def search_catalog(
deferrable_tools: ToolDefList,
query: str,
limit: int = 5
) -> List[Dict[str, str]]:
"""
Search deferrable tools catalog using BM25.
Args:
deferrable_tools: List of deferrable tool schemas
query: Search query
limit: Maximum results
Returns:
List of matching tools with name/description
"""
if not deferrable_tools or not query.strip():
return []
# Build catalog for searching
catalog = []
for tool_def in deferrable_tools:
function_def = tool_def.get("function", {})
name = function_def.get("name", "")
description = function_def.get("description", "")
if name: # Only include tools with names
catalog.append({
"name": name,
"description": description
})
if not catalog:
return []
# Use BM25 search if query is substantial, otherwise substring fallback
if len(query.strip()) >= 3:
searcher = BM25ToolSearcher(catalog)
results = searcher.search(query, limit)
# If BM25 finds results, use them
if results:
return results
# Fallback: simple substring matching on tool names
query_lower = query.lower()
matches = []
for item in catalog:
if query_lower in item["name"].lower() or query_lower in item["description"].lower():
matches.append(item)
if len(matches) >= limit:
break
return matches
def bridge_tool_schemas() -> ToolDefList:
"""
Generate the three bridge tool schemas.
Returns:
List of OpenAI function schemas for bridge tools
"""
return [
{
"type": "function",
"function": {
"name": "tool_search",
"description": "Search for available tools by name or functionality. Use this to discover what tools are available before using them.",
"parameters": {
"type": "object",
"properties": {
"query": {
"type": "string",
"description": "Search query describing the tool functionality you need"
},
"limit": {
"type": "integer",
"description": "Maximum number of results to return",
"default": 5,
"minimum": 1,
"maximum": 20
}
},
"required": ["query"]
}
}
},
{
"type": "function",
"function": {
"name": "tool_describe",
"description": "Get the full schema and documentation for a specific tool. Use this after tool_search to understand how to use a tool.",
"parameters": {
"type": "object",
"properties": {
"tool_name": {
"type": "string",
"description": "Exact name of the tool to describe"
}
},
"required": ["tool_name"]
}
}
},
{
"type": "function",
"function": {
"name": "tool_call",
"description": "Execute a tool with the given arguments. Use this after tool_describe to actually run the tool.",
"parameters": {
"type": "object",
"properties": {
"tool_name": {
"type": "string",
"description": "Exact name of the tool to execute"
},
"tool_args": {
"type": "object",
"description": "Arguments to pass to the tool",
"additionalProperties": True
}
},
"required": ["tool_name", "tool_args"]
}
}
}
]
def assemble_tool_defs(
tool_defs: ToolDefList,
config: ToolSearchConfig,
context_length: Optional[int] = None
) -> Tuple[ToolDefList, Dict[str, Any]]:
"""
Main assembly function - decide whether to use bridge mode or pass-through.
Args:
tool_defs: Original list of tool schemas
config: Tool search configuration
context_length: Model context window size
Returns:
Tuple of (assembled_tool_defs, metadata)
metadata contains: {"bridge_mode": bool, "deferred_count": int, "catalog": List}
"""
# Always return original tools if disabled
if config.enabled == "off" or config.enabled is False:
return tool_defs, {"bridge_mode": False, "deferred_count": 0, "catalog": []}
# Classify tools
core_tools, deferrable_tools = classify_tools(tool_defs, config)
# Check if we should defer
should_defer = should_defer_tools(deferrable_tools, config, context_length)
if not should_defer or not deferrable_tools:
# Pass-through mode - return original tools
return tool_defs, {"bridge_mode": False, "deferred_count": 0, "catalog": []}
# Bridge mode - replace deferrable tools with bridge schemas
bridge_schemas = bridge_tool_schemas()
assembled_tools = core_tools + bridge_schemas
# Build catalog for search
catalog = []
for tool_def in deferrable_tools:
function_def = tool_def.get("function", {})
name = function_def.get("name", "")
description = function_def.get("description", "")
if name:
catalog.append({"name": name, "description": description})
metadata = {
"bridge_mode": True,
"deferred_count": len(deferrable_tools),
"catalog": catalog,
"deferrable_tools": deferrable_tools # Keep full schemas for describe/call
}
return assembled_tools, metadata
def dispatch_tool_search(
query: str,
limit: Optional[int],
deferrable_tools: ToolDefList,
config: ToolSearchConfig
) -> Dict[str, Any]:
"""
Handle tool_search bridge call.
Returns:
JSON response with search results
"""
# Validate and clamp limit
search_limit = limit or config.search_default_limit
search_limit = min(search_limit, config.max_search_limit)
search_limit = max(search_limit, 1)
# Perform search
results = search_catalog(deferrable_tools, query, search_limit)
return {
"query": query,
"results": results,
"total_available": len(deferrable_tools)
}
def dispatch_tool_describe(
tool_name: str,
deferrable_tools: ToolDefList
) -> Dict[str, Any]:
"""
Handle tool_describe bridge call.
Returns:
JSON response with full tool schema or error
"""
# Find the tool
for tool_def in deferrable_tools:
function_def = tool_def.get("function", {})
if function_def.get("name") == tool_name:
return {
"tool_name": tool_name,
"schema": tool_def,
"found": True
}
return {
"tool_name": tool_name,
"error": f"Tool '{tool_name}' not found in available tools",
"found": False
}
def resolve_underlying_call(tool_name: str, tool_args: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
"""
Unwrap tool_call bridge to get real tool name and args.
Args:
tool_name: Should be "tool_call"
tool_args: Dict with "tool_name" and "tool_args" keys
Returns:
Tuple of (real_tool_name, real_args)
"""
if tool_name != "tool_call":
# Not a bridge call, return as-is
return tool_name, tool_args
# Validate tool_args is a dict
if not isinstance(tool_args, dict):
raise TypeError(
f"tool_call expects a dictionary for tool_args, got {type(tool_args).__name__}. "
"Ensure the LLM output is properly formatted."
)
# Extract real tool call from bridge args
real_tool_name = tool_args.get("tool_name", "")
real_args = tool_args.get("tool_args", {})
if not real_tool_name:
raise ValueError("tool_call requires 'tool_name' parameter")
return real_tool_name, real_args
def scoped_deferrable_names(agent_tool_list: List[str], deferrable_tools: ToolDefList) -> Set[str]:
"""
Get names of deferrable tools that are in the agent's enabled tool list.
Implements session scoping (invariant #5).
Args:
agent_tool_list: List of tool names available to this agent
deferrable_tools: Full list of deferrable tool schemas
Returns:
Set of tool names that are both deferrable and enabled for this agent
"""
agent_tool_set = set(agent_tool_list)
deferrable_names = set()
for tool_def in deferrable_tools:
function_def = tool_def.get("function", {})
tool_name = function_def.get("name", "")
if tool_name and tool_name in agent_tool_set:
deferrable_names.add(tool_name)
return deferrable_names
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.48"
version = "1.6.52"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -349,6 +349,20 @@ class TestDbSessionAdapter:
assert meta["agent_id"] == "abc"
assert meta["total_input_tokens"] == 42
def test_update_session_metadata_accepts_keyword_fields(self):
"""update_session_metadata matches DefaultSessionStore **fields API."""
from praisonai.integrations.db_session_adapter import DbSessionAdapter
mock_db = MagicMock()
adapter = DbSessionAdapter(mock_db)
adapter.update_session_metadata(
"sess1", model="gpt-4o-mini", total_tokens=99, cost=0.01
)
meta = adapter.get_metadata("sess1")
assert meta["model"] == "gpt-4o-mini"
assert meta["total_tokens"] == 99
assert meta["cost"] == 0.01
def test_clear_session_purges_db_history(self):
"""clear_session must not leave stale messages reloadable from the DB."""
from praisonai.integrations.db_session_adapter import DbSessionAdapter
@@ -0,0 +1,48 @@
import ast
from pathlib import Path
def _chat_mixin_path() -> Path:
for candidate in Path(__file__).resolve().parents:
chat_mixin = candidate / "praisonaiagents" / "agent" / "chat_mixin.py"
if chat_mixin.exists():
return chat_mixin
raise FileNotFoundError("Could not locate praisonaiagents/agent/chat_mixin.py")
def get_chat_mixin_stream_parameter_default(function_name: str):
chat_mixin = _chat_mixin_path()
tree = ast.parse(chat_mixin.read_text(encoding="utf-8"))
for node in tree.body:
if isinstance(node, ast.ClassDef) and node.name == "ChatMixin":
for item in node.body:
if isinstance(item, ast.FunctionDef) and item.name == function_name:
args = item.args.args
defaults = item.args.defaults
first_default_index = len(args) - len(defaults)
for idx, arg in enumerate(args):
if arg.arg == "stream":
default_idx = idx - first_default_index
if default_idx < 0:
raise AssertionError(
f"Function '{function_name}' has no default for stream"
)
default = defaults[default_idx]
if isinstance(default, ast.Constant):
return default.value
raise AssertionError(
f"Function '{function_name}' stream default is non-constant"
)
raise AssertionError(f"Function '{function_name}' or stream parameter not found")
def test_chat_completion_stream_default_is_none():
assert get_chat_mixin_stream_parameter_default("_chat_completion") is None
def test_execute_unified_chat_completion_stream_default_is_none():
assert (
get_chat_mixin_stream_parameter_default("_execute_unified_chat_completion")
is None
)
@@ -117,6 +117,44 @@ class TestHierarchicalSessionStore:
assert len(history) == 2
assert history[1]["content"] == "second"
def test_get_extended_session_sees_writes_from_other_store(self):
"""Extended reads must reload from disk, not stale _extended_cache."""
with tempfile.TemporaryDirectory() as tmpdir:
writer = HierarchicalSessionStore(session_dir=tmpdir)
reader = HierarchicalSessionStore(session_dir=tmpdir)
writer.add_user_message("session-1", "first")
reader._load_extended_session("session-1")
writer.add_user_message("session-1", "second")
session = reader.get_extended_session("session-1")
assert len(session.messages) == 2
assert session.messages[1].content == "second"
def test_stale_cache_write_preserves_concurrent_updates(self):
"""Metadata writes must not truncate messages written by other processes."""
with tempfile.TemporaryDirectory() as tmpdir:
writer = HierarchicalSessionStore(session_dir=tmpdir)
reader = HierarchicalSessionStore(session_dir=tmpdir)
# Create session and warm reader's cache
writer.create_session("session-1", title="Original")
reader.get_extended_session("session-1") # Warms cache
# Writer adds messages, reader has stale cache
writer.add_user_message("session-1", "first message")
writer.add_assistant_message("session-1", "first response")
# Reader performs metadata-only write with stale cache
reader.set_title("session-1", "Updated Title")
# Verify messages are preserved
session = writer.get_extended_session("session-1")
assert session.title == "Updated Title"
assert len(session.messages) == 2
assert session.messages[0].content == "first message"
assert session.messages[1].content == "first response"
def test_update_session_metadata_preserves_extended_fields(self):
"""Metadata updates must not strip parent_id, snapshots, etc."""
session_id = self.store.create_session(title="Parent")
@@ -324,6 +362,94 @@ class TestHierarchicalSessionStore:
new_id = self.store.import_session(exported, new_session_id=custom_id)
assert new_id == custom_id
def test_set_title_does_not_drop_messages_after_external_write(self):
"""
Regression test for the stale cache bug.
Reproduces the scenario where:
1. Process A loads session (warms cache)
2. Process B writes new messages
3. Process A calls set_title() → should NOT drop Process B's messages
"""
import json
# Create session with initial messages
session_id = self.store.create_session(title="Test Session")
self.store.add_message(session_id, "user", "Message 1")
self.store.add_message(session_id, "assistant", "Response 1")
# Process A: Load session (warms cache)
session_a = self.store.get_extended_session(session_id)
assert len(session_a.messages) == 2
# Process B: Simulate external write by directly modifying file
# This mimics another process/store instance writing to the same session
filepath = self.store._get_session_path(session_id)
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
# Add messages from "Process B"
data["messages"].extend([
{"role": "user", "content": "Message 2", "timestamp": time.time(), "metadata": {}},
{"role": "assistant", "content": "Response 2", "timestamp": time.time(), "metadata": {}}
])
data["updated_at"] = time.time()
# Write the updated data (simulating external process write)
with open(filepath, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2)
# Brief sleep to ensure file mtime is different
time.sleep(0.1)
# Process A: Call set_title() - this should detect the external write
# and reload fresh data instead of using stale cache
result = self.store.set_title(session_id, "Updated Title")
assert result is True
# Verify no messages were lost - should have all 4 messages
final_session = self.store.get_extended_session(session_id)
assert len(final_session.messages) == 4, f"Expected 4 messages, got {len(final_session.messages)}"
assert final_session.title == "Updated Title"
assert final_session.messages[0].content == "Message 1"
assert final_session.messages[1].content == "Response 1"
assert final_session.messages[2].content == "Message 2"
assert final_session.messages[3].content == "Response 2"
def test_cache_performance_with_unchanged_files(self):
"""
Test that performance optimization works - reads from cache when file hasn't changed.
"""
session_id = self.store.create_session(title="Cache Test")
self.store.add_message(session_id, "user", "Test message")
# First read - loads from disk and caches
session1 = self.store.get_extended_session(session_id)
assert len(session1.messages) == 1
# Second read should use cache (file hasn't changed)
# We can't easily test this directly, but we can verify the cache is valid
assert self.store._is_cache_valid(session_id) is True
session2 = self.store.get_extended_session(session_id)
assert len(session2.messages) == 1
assert session2 is session1 # Should be same cached object
def test_force_reload_bypasses_cache(self):
"""Test that force_reload=True always loads from disk."""
session_id = self.store.create_session(title="Force Reload Test")
self.store.add_message(session_id, "user", "Message 1")
# Load and cache
session1 = self.store._load_extended_session(session_id, force_reload=False)
# Force reload should bypass cache
session2 = self.store._load_extended_session(session_id, force_reload=True)
# Both should have same data but force_reload ensures fresh read
assert len(session1.messages) == len(session2.messages)
assert session1.session_id == session2.session_id
class TestGlobalHierarchicalStore:
@@ -9,8 +9,11 @@ import os
import tempfile
import threading
import time
import builtins
import importlib
import pytest
from concurrent.futures import ThreadPoolExecutor
from unittest.mock import patch
from praisonaiagents.session.store import (
DefaultSessionStore,
@@ -160,6 +163,26 @@ class TestFileLock:
if start_idx < other_start < end_idx:
pytest.fail(f"Lock interleaving detected: {results}")
def test_import_without_fcntl(self):
"""Test module import succeeds when fcntl is unavailable."""
import praisonaiagents.session.store as store_module
import importlib.util
original_import = builtins.__import__
def import_without_fcntl(name, *args, **kwargs):
if name == "fcntl":
raise ImportError("fcntl unavailable")
return original_import(name, *args, **kwargs)
try:
with patch("builtins.__import__", side_effect=import_without_fcntl):
reloaded_module = importlib.reload(store_module)
assert reloaded_module._HAS_FCNTL is False
finally:
restored_module = importlib.reload(store_module)
assert restored_module._HAS_FCNTL is (importlib.util.find_spec("fcntl") is not None)
class TestDefaultSessionStore:
"""Tests for DefaultSessionStore."""
@@ -327,6 +350,21 @@ class TestDefaultSessionStore:
assert session.agent_name == "TestBot"
assert session.user_id == "u-1"
def test_set_chat_history_replaces_under_lock(self, temp_store):
"""History replace is a single locked write (not clear + N adds)."""
temp_store.add_user_message("session-1", "old")
assert temp_store.set_chat_history(
"session-1",
[
{"role": "user", "content": "new"},
{"role": "assistant", "content": "reply"},
],
)
assert temp_store.get_chat_history("session-1") == [
{"role": "user", "content": "new"},
{"role": "assistant", "content": "reply"},
]
def test_get_chat_history_sees_writes_from_other_store(self, temp_store):
"""Reads must reload from disk, not a stale in-memory cache."""
with tempfile.TemporaryDirectory() as tmpdir:
@@ -336,10 +374,10 @@ class TestDefaultSessionStore:
writer.add_user_message("session-1", "first")
reader._load_session("session-1")
writer.add_user_message("session-1", "second")
history = reader.get_chat_history("session-1")
assert len(history) == 2
assert history[1]["content"] == "second"
assert history[1]["content"] == "second"
def test_clear_session_preserves_new_messages(self, temp_store):
"""Clear must reload from disk so concurrent adds are not lost."""
@@ -421,12 +459,26 @@ class TestDefaultSessionStore:
# Reads always reload from disk (no stale cache)
history = temp_store.get_chat_history("session-1")
assert len(history) == 2 # Fresh from disk
# invalidate_cache still works but is now essentially a no-op for reads
assert len(history) == 2
# invalidate_cache still clears in-memory state when a file is missing
temp_store.invalidate_cache("session-1")
history = temp_store.get_chat_history("session-1")
assert len(history) == 2
def test_get_chat_history_sees_other_store_instance(self):
"""Another store on the same session_dir must see new messages without invalidate_cache."""
with tempfile.TemporaryDirectory() as tmpdir:
writer = DefaultSessionStore(session_dir=tmpdir)
reader = DefaultSessionStore(session_dir=tmpdir)
writer.add_user_message("session-1", "first")
reader._load_session("session-1")
writer.add_user_message("session-1", "second")
history = reader.get_chat_history("session-1")
assert len(history) == 2
assert history[1]["content"] == "second"
def test_list_sessions_with_none_updated_at(self, temp_store):
"""Test list_sessions handles None updated_at values without crashing.
@@ -0,0 +1,125 @@
"""
Unit tests for MCP loader functionality.
Tests the smoke functionality for the new MCP client lifecycle
features implemented in this issue.
"""
import pytest
from unittest.mock import Mock, MagicMock
from praisonaiagents.memory.mcp_config import MCPConfig
def test_mcp_config_to_mcp_instance_import_fix():
"""Test that MCPConfig.to_mcp_instance() imports MCP correctly (A1)."""
# Create a minimal config
config = MCPConfig(
name="test",
command="echo",
args=["hello"],
enabled=True
)
# This should not raise ImportError anymore
# Note: It might return None if MCP package is not installed, but the import should work
try:
result = config.to_mcp_instance()
# If we get here without ImportError from wrong path, the fix worked
assert True
except ImportError as e:
if "praisonaiagents.tools.mcp" in str(e):
pytest.fail("Import still using wrong path - A1 fix failed")
else:
# This is expected if mcp package is not installed
assert "praisonaiagents[mcp]" in str(e)
def test_load_mcp_tools_import():
"""Test that load_mcp_tools can be imported (B2)."""
from praisonaiagents.mcp import load_mcp_tools
assert callable(load_mcp_tools)
def test_mcp_client_protocol_import():
"""Test that MCPClientProtocol can be imported (B1)."""
from praisonaiagents.mcp import MCPClientProtocol
# Should be a protocol/type
assert MCPClientProtocol is not None
def test_mcp_filter_parameters():
"""Test that MCP class accepts filter parameters (B3)."""
try:
from praisonaiagents.mcp import MCP
# This should not raise TypeError for unknown parameters
# Note: May raise ImportError if mcp package not installed
mcp = MCP("echo hello", allowed_tools=["test"], disabled_tools=["bad"])
assert mcp.allowed_tools == ["test"]
assert mcp.disabled_tools == ["bad"]
except ImportError as e:
if "praisonaiagents[mcp]" in str(e):
# Expected if MCP package not installed
pytest.skip("MCP package not installed")
else:
raise
def test_filter_tool_functions_exist():
"""Test that filter functions exist and work (B3)."""
from praisonaiagents.mcp.mcp_utils import filter_tools_by_allowlist, filter_disabled_tools
tools = [
{"name": "tool1", "description": "First tool"},
{"name": "tool2", "description": "Second tool"},
{"name": "tool3", "description": "Third tool"},
]
# Test allowlist filtering
filtered = filter_tools_by_allowlist(tools, ["tool1", "tool3"])
assert len(filtered) == 2
assert filtered[0]["name"] == "tool1"
assert filtered[1]["name"] == "tool3"
# Test disabled filtering
filtered = filter_disabled_tools(tools, ["tool2"])
assert len(filtered) == 2
assert all(t["name"] != "tool2" for t in filtered)
# Test precedence: include wins over exclude
# This is handled at MCP class level, but filters should work independently
def test_prefix_collision_handling():
"""Test tool name prefix handling (B4)."""
# The prefix logic is in the loader
from praisonaiagents.mcp.loader import load_mcp_tools
# Test that sanitization logic exists (basic check)
# Full test would require mock MCP configs
assert callable(load_mcp_tools)
def test_safe_env_build():
"""Test safe environment building (B5)."""
try:
from praisonaiagents.mcp import MCP
# Create MCP instance to test env building
# This tests that _build_safe_env method exists
mcp = MCP("echo test")
assert hasattr(mcp, '_build_safe_env')
# Test safe env function
safe_env = mcp._build_safe_env({"CUSTOM_VAR": "test"})
assert isinstance(safe_env, dict)
assert "CUSTOM_VAR" in safe_env
assert safe_env["CUSTOM_VAR"] == "test"
# Should have safe baseline
assert "PATH" in safe_env
assert "HOME" in safe_env
except ImportError as e:
if "praisonaiagents[mcp]" in str(e):
pytest.skip("MCP package not installed")
else:
raise
@@ -0,0 +1,394 @@
"""
Unit tests for Tool Search functionality.
Tests core tool search components including:
- ToolSearchConfig
- Tool classification
- BM25 search
- Bridge tool schemas
- Assembly logic
"""
import pytest
from praisonaiagents.tools.tool_search import (
ToolSearchConfig, classify_tools, search_catalog, bridge_tool_schemas,
assemble_tool_defs, dispatch_tool_search, dispatch_tool_describe,
resolve_underlying_call, PRAISONAI_CORE_TOOLS,
estimate_tool_schema_tokens, should_defer_tools
)
class TestToolSearchConfig:
"""Test ToolSearchConfig creation and validation."""
def test_default_config(self):
"""Test default configuration."""
config = ToolSearchConfig()
assert config.enabled == "auto"
assert config.threshold_pct == 10.0
assert config.search_default_limit == 5
assert config.max_search_limit == 20
assert config.core_tools is None
def test_from_raw_bool(self):
"""Test creating config from boolean values."""
config_true = ToolSearchConfig.from_raw(True)
assert config_true.enabled == "on"
config_false = ToolSearchConfig.from_raw(False)
assert config_false.enabled == "off"
def test_from_raw_string(self):
"""Test creating config from string values."""
config_auto = ToolSearchConfig.from_raw("auto")
assert config_auto.enabled == "auto"
config_on = ToolSearchConfig.from_raw("on")
assert config_on.enabled == "on"
config_off = ToolSearchConfig.from_raw("off")
assert config_off.enabled == "off"
def test_from_raw_dict(self):
"""Test creating config from dictionary."""
config = ToolSearchConfig.from_raw({
"enabled": "on",
"threshold_pct": 15.0,
"search_default_limit": 10
})
assert config.enabled == "on"
assert config.threshold_pct == 15.0
assert config.search_default_limit == 10
def test_from_raw_invalid(self):
"""Test error handling for invalid inputs."""
with pytest.raises(ValueError):
ToolSearchConfig.from_raw("invalid")
with pytest.raises(TypeError):
ToolSearchConfig.from_raw(123)
class TestToolClassification:
"""Test tool classification into core vs deferrable."""
def test_core_tool_classification(self):
"""Test that core tools are never deferred."""
tool_defs = [
{
"type": "function",
"function": {"name": "read_file", "description": "Read a file"}
},
{
"type": "function",
"function": {"name": "search_web", "description": "Search the web"}
}
]
config = ToolSearchConfig()
core_tools, deferrable_tools = classify_tools(tool_defs, config)
assert len(core_tools) == 2
assert len(deferrable_tools) == 0
assert core_tools == tool_defs
def test_mcp_tool_classification(self):
"""Test that MCP tools are marked as deferrable."""
tool_defs = [
{
"type": "function",
"function": {"name": "mcp_weather", "description": "Get weather"},
"__praisonai_deferrable__": True
},
{
"type": "function",
"function": {"name": "read_file", "description": "Read a file"}
}
]
config = ToolSearchConfig()
core_tools, deferrable_tools = classify_tools(tool_defs, config)
assert len(core_tools) == 1
assert len(deferrable_tools) == 1
assert core_tools[0]["function"]["name"] == "read_file"
assert deferrable_tools[0]["function"]["name"] == "mcp_weather"
def test_unknown_tool_stays_visible(self):
"""Test that unknown tools stay visible (design invariant #2)."""
tool_defs = [
{
"type": "function",
"function": {"name": "unknown_tool", "description": "Unknown tool"}
}
]
config = ToolSearchConfig()
core_tools, deferrable_tools = classify_tools(tool_defs, config)
# Unknown tools should stay in core (visible)
assert len(core_tools) == 1
assert len(deferrable_tools) == 0
assert core_tools[0]["function"]["name"] == "unknown_tool"
class TestBM25Search:
"""Test BM25 search functionality."""
def test_empty_search(self):
"""Test search with empty inputs."""
result = search_catalog([], "test query")
assert result == []
result = search_catalog([{"type": "function", "function": {"name": "test"}}], "")
assert result == []
def test_simple_search(self):
"""Test basic search functionality."""
deferrable_tools = [
{
"type": "function",
"function": {
"name": "weather_tool",
"description": "Get current weather information"
}
},
{
"type": "function",
"function": {
"name": "stock_tool",
"description": "Get stock market data"
}
}
]
results = search_catalog(deferrable_tools, "weather", limit=10)
assert len(results) >= 1
assert any("weather_tool" in r["name"] for r in results)
results = search_catalog(deferrable_tools, "stock", limit=10)
assert len(results) >= 1
assert any("stock_tool" in r["name"] for r in results)
class TestBridgeTools:
"""Test bridge tool schemas and assembly."""
def test_bridge_schemas(self):
"""Test bridge tool schema generation."""
schemas = bridge_tool_schemas()
assert len(schemas) == 3
tool_names = {schema["function"]["name"] for schema in schemas}
assert tool_names == {"tool_search", "tool_describe", "tool_call"}
# Validate schema structure
for schema in schemas:
assert schema["type"] == "function"
assert "function" in schema
assert "name" in schema["function"]
assert "description" in schema["function"]
assert "parameters" in schema["function"]
def test_assemble_disabled(self):
"""Test assembly when tool search is disabled."""
tool_defs = [
{"type": "function", "function": {"name": "test_tool", "description": "Test"}}
]
config = ToolSearchConfig(enabled="off")
assembled, metadata = assemble_tool_defs(tool_defs, config)
assert assembled == tool_defs
assert metadata["bridge_mode"] is False
assert metadata["deferred_count"] == 0
def test_assemble_no_deferrable_tools(self):
"""Test assembly when no tools are deferrable."""
tool_defs = [
{"type": "function", "function": {"name": "read_file", "description": "Read file"}}
]
config = ToolSearchConfig(enabled="on")
assembled, metadata = assemble_tool_defs(tool_defs, config)
# Should return original tools since nothing to defer
assert assembled == tool_defs
assert metadata["bridge_mode"] is False
def test_assemble_with_deferrable_tools(self):
"""Test assembly with deferrable tools."""
tool_defs = [
{"type": "function", "function": {"name": "read_file", "description": "Read file"}},
{
"type": "function",
"function": {"name": "mcp_weather", "description": "Get weather"},
"__praisonai_deferrable__": True
}
]
config = ToolSearchConfig(enabled="on")
assembled, metadata = assemble_tool_defs(tool_defs, config)
# Should have core tool + 3 bridge tools
assert len(assembled) == 4
assert metadata["bridge_mode"] is True
assert metadata["deferred_count"] == 1
assert len(metadata["catalog"]) == 1
# Verify core tool remains
core_tool_names = {t["function"]["name"] for t in assembled if t["function"]["name"] != "tool_search" and t["function"]["name"] != "tool_describe" and t["function"]["name"] != "tool_call"}
assert "read_file" in core_tool_names
class TestBridgeDispatchers:
"""Test bridge tool dispatch handlers."""
def test_tool_search_dispatch(self):
"""Test tool_search bridge dispatch."""
deferrable_tools = [
{
"type": "function",
"function": {
"name": "weather_tool",
"description": "Get weather information"
}
}
]
config = ToolSearchConfig()
result = dispatch_tool_search("weather", 5, deferrable_tools, config)
assert "query" in result
assert "results" in result
assert "total_available" in result
assert result["query"] == "weather"
assert result["total_available"] == 1
def test_tool_describe_dispatch(self):
"""Test tool_describe bridge dispatch."""
deferrable_tools = [
{
"type": "function",
"function": {
"name": "weather_tool",
"description": "Get weather information"
}
}
]
result = dispatch_tool_describe("weather_tool", deferrable_tools)
assert "tool_name" in result
assert "found" in result
assert result["found"] is True
assert result["tool_name"] == "weather_tool"
assert "schema" in result
def test_tool_describe_not_found(self):
"""Test tool_describe for non-existent tool."""
result = dispatch_tool_describe("nonexistent_tool", [])
assert "tool_name" in result
assert "found" in result
assert result["found"] is False
assert "error" in result
class TestToolCallUnwrapping:
"""Test tool_call bridge unwrapping."""
def test_resolve_underlying_call(self):
"""Test unwrapping tool_call bridge."""
# Normal tool call - should pass through
name, args = resolve_underlying_call("normal_tool", {"arg1": "value1"})
assert name == "normal_tool"
assert args == {"arg1": "value1"}
# Bridge tool call - should unwrap
bridge_args = {
"tool_name": "real_tool",
"tool_args": {"param1": "test"}
}
name, args = resolve_underlying_call("tool_call", bridge_args)
assert name == "real_tool"
assert args == {"param1": "test"}
def test_resolve_missing_tool_name(self):
"""Test error handling for missing tool_name."""
with pytest.raises(ValueError, match="tool_call requires 'tool_name' parameter"):
resolve_underlying_call("tool_call", {"tool_args": {}})
class TestTokenEstimation:
"""Test token estimation and threshold logic."""
def test_estimate_tool_schema_tokens(self):
"""Test token estimation for tool schemas."""
tool_defs = [
{
"type": "function",
"function": {
"name": "test_tool",
"description": "A test tool with parameters",
"parameters": {
"type": "object",
"properties": {"param1": {"type": "string"}}
}
}
}
]
tokens = estimate_tool_schema_tokens(tool_defs)
assert tokens > 0
assert isinstance(tokens, int)
def test_should_defer_tools(self):
"""Test deferral threshold logic."""
# Create some dummy deferrable tools
deferrable_tools = [
{
"type": "function",
"function": {"name": f"tool_{i}", "description": "Test tool"}
}
for i in range(10)
]
# Test with disabled config
config_off = ToolSearchConfig(enabled="off")
assert should_defer_tools(deferrable_tools, config_off, 20000) is False
# Test with on config
config_on = ToolSearchConfig(enabled="on")
assert should_defer_tools(deferrable_tools, config_on, 20000) is True
# Test auto mode with high threshold (shouldn't defer)
config_auto_high = ToolSearchConfig(enabled="auto", threshold_pct=90)
assert should_defer_tools(deferrable_tools, config_auto_high, 20000) is False
# Test auto mode with low threshold (should defer)
config_auto_low = ToolSearchConfig(enabled="auto", threshold_pct=1)
assert should_defer_tools(deferrable_tools, config_auto_low, 20000) is True
class TestCoreToolsInvariant:
"""Test that core tools never defer."""
def test_core_tools_constant(self):
"""Test that PRAISONAI_CORE_TOOLS is properly defined."""
assert isinstance(PRAISONAI_CORE_TOOLS, frozenset)
assert len(PRAISONAI_CORE_TOOLS) > 0
# Check some expected core tools
expected_core = {"read_file", "execute_command", "search_web", "clarify"}
assert expected_core.issubset(PRAISONAI_CORE_TOOLS)
def test_core_tools_never_deferred(self):
"""Test that core tools are never put in deferrable category."""
# Create tools that are marked as deferrable but are actually core tools
tool_defs = []
for tool_name in list(PRAISONAI_CORE_TOOLS)[:5]: # Test first 5 core tools
tool_defs.append({
"type": "function",
"function": {"name": tool_name, "description": f"Test {tool_name}"},
"__praisonai_deferrable__": True # Try to mark as deferrable
})
config = ToolSearchConfig()
core_tools, deferrable_tools = classify_tools(tool_defs, config)
# All should end up in core tools despite the deferrable marker
assert len(core_tools) == 5
assert len(deferrable_tools) == 0
@@ -0,0 +1,114 @@
#!/usr/bin/env python3
"""
Simple test script to verify tool search implementation.
"""
import sys
def test_tool_search_basic():
"""Test basic tool search functionality."""
print("Testing Tool Search implementation...")
try:
# Test config creation
from praisonaiagents.tools.tool_search import ToolSearchConfig
config = ToolSearchConfig()
print(f"✓ ToolSearchConfig created: enabled={config.enabled}, threshold={config.threshold_pct}%")
# Test config from raw values
config_true = ToolSearchConfig.from_raw(True)
config_false = ToolSearchConfig.from_raw(False)
print(f"✓ Config from bool: True→{config_true.enabled}, False→{config_false.enabled}")
# Test tool classification
from praisonaiagents.tools.tool_search import PRAISONAI_CORE_TOOLS
print(f"✓ Core tools defined: {len(PRAISONAI_CORE_TOOLS)} tools")
print(f" Sample core tools: {list(PRAISONAI_CORE_TOOLS)[:5]}")
# Test bridge tools
from praisonaiagents.tools.tool_search import bridge_tool_schemas
bridge_schemas = bridge_tool_schemas()
bridge_names = [schema["function"]["name"] for schema in bridge_schemas]
print(f"✓ Bridge tools created: {bridge_names}")
# Test assembly logic
from praisonaiagents.tools.tool_search import assemble_tool_defs
# Test with disabled config
tool_defs = [
{"type": "function", "function": {"name": "test_tool", "description": "Test"}}
]
assembled, metadata = assemble_tool_defs(tool_defs, ToolSearchConfig(enabled="off"))
print(f"✓ Disabled mode: bridge={metadata['bridge_mode']}, deferred={metadata['deferred_count']}")
# Test with enabled but no deferrable tools
assembled, metadata = assemble_tool_defs(tool_defs, ToolSearchConfig(enabled="on"))
print(f"✓ No deferrable tools: bridge={metadata['bridge_mode']}, deferred={metadata['deferred_count']}")
# Test with deferrable tools
deferrable_tool_defs = [
{"type": "function", "function": {"name": "read_file", "description": "Read file"}},
{
"type": "function",
"function": {"name": "mcp_weather", "description": "Get weather"},
"__praisonai_deferrable__": True
}
]
assembled, metadata = assemble_tool_defs(deferrable_tool_defs, ToolSearchConfig(enabled="on"))
print(f"✓ With deferrable tools: bridge={metadata['bridge_mode']}, deferred={metadata['deferred_count']}")
print(f" Assembled tools: {len(assembled)}, catalog: {len(metadata['catalog'])}")
# Test search functionality
from praisonaiagents.tools.tool_search import search_catalog
search_results = search_catalog(metadata["deferrable_tools"], "weather", limit=5)
print(f"✓ Search 'weather': {len(search_results)} results")
if search_results:
print(f" First result: {search_results[0]}")
print("\n🎉 All basic tests passed! Tool Search implementation is working.")
return True
except Exception as e:
print(f"❌ Test failed: {e}")
import traceback
traceback.print_exc()
return False
def test_agent_integration():
"""Test Agent class integration."""
print("\nTesting Agent integration...")
try:
# Test config classes are exported
from praisonaiagents.config import ToolSearchConfig
print("✓ ToolSearchConfig available in config module")
# Test Agent parameter (basic import check)
from praisonaiagents.agent.agent import Agent
print("✓ Agent class imports successfully")
print("✓ Agent integration looks good")
return True
except Exception as e:
print(f"❌ Agent integration test failed: {e}")
import traceback
traceback.print_exc()
return False
if __name__ == "__main__":
print("Tool Search Implementation Test")
print("=" * 50)
success1 = test_tool_search_basic()
success2 = test_agent_integration()
if success1 and success2:
print("\n✅ All tests passed!")
sys.exit(0)
else:
print("\n❌ Some tests failed!")
sys.exit(1)
+1 -1
View File
@@ -3125,7 +3125,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.48"
version = "1.6.52"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
@@ -23,15 +23,37 @@ from ..db.models import Member, User
_pwd_context = CryptContext(schemes=["bcrypt"], deprecated="auto")
_DEFAULT_SECRET = "dev-secret-change-me"
JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET", _DEFAULT_SECRET)
JWT_SECRET = os.environ.get("PLATFORM_JWT_SECRET")
JWT_ALGORITHM = "HS256"
JWT_TTL_SECONDS = int(os.environ.get("PLATFORM_JWT_TTL", str(30 * 24 * 3600)))
if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
raise RuntimeError(
"PLATFORM_JWT_SECRET must be set to a strong random value in production. "
"Set PLATFORM_ENV=dev to suppress this check during development."
)
if JWT_SECRET is None or JWT_SECRET == _DEFAULT_SECRET:
# Determine if we are in an explicit dev environment.
# Only trust PLATFORM_ENV when it is explicitly set — an unset value
# must NOT silently fall back to "dev" because production deployments
# that forget the variable would run with a well-known signing key.
_explicit_env = os.environ.get("PLATFORM_ENV")
if _explicit_env == "dev":
# Developer explicitly opted in to insecure defaults — allow it.
JWT_SECRET = _DEFAULT_SECRET
else:
# Either production or environment not specified.
# Auto-generate an ephemeral secret so the service can start, but
# warn loudly because tokens will not survive a restart.
import secrets as _secrets
import warnings as _warnings
JWT_SECRET = _secrets.token_urlsafe(32)
_warnings.warn(
"PLATFORM_JWT_SECRET is not set. Auto-generated an ephemeral "
"signing key — JWT tokens will be invalidated on restart. "
"Set PLATFORM_JWT_SECRET to a stable random value for production "
"use, or set PLATFORM_ENV=dev to use the development default.",
stacklevel=2,
)
# Type-narrow: at this point JWT_SECRET is always a non-None str.
assert JWT_SECRET is not None # noqa: S105
class AuthService:
@@ -113,10 +135,6 @@ class AuthService:
def _issue_token(self, user: User) -> str:
"""Issue a JWT for a user."""
if JWT_SECRET == _DEFAULT_SECRET and os.environ.get("PLATFORM_ENV", "dev") != "dev":
raise RuntimeError(
"Refusing to issue JWT with default PLATFORM_JWT_SECRET outside dev"
)
now = datetime.now(timezone.utc)
payload = {
"sub": user.id,
+14 -4
View File
@@ -1,14 +1,14 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-30",
"lastUpdated": "2026-06-02",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
"summary": {
"pythonCoreFeatures": 335,
"pythonCoreFeatures": 336,
"rustFeatures": 667,
"gapCount": 56,
"parityPercentage": 199.1
"gapCount": 57,
"parityPercentage": 198.5
},
"pythonCoreSDK": {
"exports": [
@@ -245,6 +245,7 @@
"ToolPluginProtocol",
"ToolRegistry",
"ToolResult",
"ToolSearchConfig",
"ToolUseEvent",
"ToolValidationError",
"Tools",
@@ -4161,6 +4162,15 @@
"status": "DONE",
"category": "plugins"
},
{
"feature": "ToolSearchConfig",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "config"
},
{
"feature": "ToolUseEvent",
"python": true,
+5 -4
View File
@@ -1,16 +1,16 @@
# Rust Feature Parity Tracker
> **Python Features:** 335 | **Rust Features:** 667 | **Parity:** 85.1%
> **Python Features:** 336 | **Rust Features:** 667 | **Parity:** 84.8%
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 335 |
| Python Core Features | 336 |
| Rust Features | 667 |
| **Actual Gap Count** | **50** |
| **Actual Gap Count** | **51** |
| Language Limitations (N/A) | 5 |
| **Parity** | **85.1%** |
| **Parity** | **84.8%** |
## Implemented Features
@@ -735,6 +735,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
- ❌ `StopReason`
- ❌ `StructuredFormatter`
- ❌ `ToolExecutionError`
- ❌ `ToolSearchConfig`
- ❌ `ToolUseEvent`
- ❌ `ToolValidationError`
- ❌ `configure_structured_logging`
+15 -4
View File
@@ -1,17 +1,17 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-30",
"lastUpdated": "2026-06-02",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
"pythonCoreFeatures": 335,
"pythonCoreFeatures": 336,
"pythonWrapperFeatures": 99,
"typescriptFeatures": 1192,
"gapCount": 43,
"gapCount": 44,
"priorityP0": 1,
"priorityP1": 0,
"priorityP2": 0,
"priorityP3": 42
"priorityP3": 43
},
"pythonCoreSDK": {
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
@@ -249,6 +249,7 @@
"ToolPluginProtocol",
"ToolRegistry",
"ToolResult",
"ToolSearchConfig",
"ToolUseEvent",
"ToolValidationError",
"Tools",
@@ -475,6 +476,7 @@
"ReflectionConfig",
"SkillsConfig",
"TemplateConfig",
"ToolSearchConfig",
"WEB_PRESETS",
"WebConfig",
"WebSearchProvider",
@@ -5254,6 +5256,15 @@
"status": "DONE",
"category": "plugins"
},
{
"feature": "ToolSearchConfig",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "config"
},
{
"feature": "ToolUseEvent",
"python": true,
+7 -6
View File
@@ -1,20 +1,20 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-05-30
> **Version:** 1.5.87 | **Last Updated:** 2026-06-02
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 335 |
| Python Core Features | 336 |
| Python Wrapper Features | 99 |
| TypeScript Features | 1192 |
| **Gap Count** | **43** |
| **Gap Count** | **44** |
| P0 (Critical) | 1 |
| P1 (High) | 0 |
| P2 (Medium) | 0 |
| P3 (Low) | 42 |
| P3 (Low) | 43 |
## Gap Matrix
@@ -147,7 +147,7 @@
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
### P3_Advanced (179 done, 42 todo)
### P3_Advanced (179 done, 43 todo)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
@@ -189,6 +189,7 @@
| `StopReason` | ✅ | ❌ | high | ⏳ TODO |
| `StructuredFormatter` | ✅ | ❌ | high | ⏳ TODO |
| `ToolExecutionError` | ✅ | ❌ | low | ⏳ TODO |
| `ToolSearchConfig` | ✅ | ❌ | low | ⏳ TODO |
| `ToolUseEvent` | ✅ | ❌ | high | ⏳ TODO |
| `ValidationError` | ✅ | ❌ | low | ⏳ TODO |
| `configure\_structured\_logging` | ✅ | ❌ | low | ⏳ TODO |
@@ -423,7 +424,7 @@ from praisonaiagents import ConditionProtocol, DictCondition, ExpressionConditio
</details>
<details>
<summary><strong>config</strong> (75 exports)</summary>
<summary><strong>config</strong> (76 exports)</summary>
```python
from praisonaiagents import AUTONOMY_PRESETS, ArrayMode, AutonomyLevel, CACHING_PRESETS, CONTEXT_PRESETS, CachingConfig, ChunkingStrategy, ConfigValidationError, DefaultsConfig, EXECUTION_PRESETS...
+1 -1
View File
@@ -382,7 +382,7 @@ praisonai flow
Open **http://localhost:7861** — use the **Agent** and **Agent Team** components to create sequential or parallel workflows. Connect Chat Input → Agent Team → Chat Output for instant multi-agent pipelines.
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/flow) — visual agent building, component reference, and deployment
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/agentflow) — visual agent building, component reference, and deployment
### 8. PraisonAI UI 🤖 (Clean Chat)
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.48.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.48.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.52.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.52.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+22 -15
View File
@@ -31,12 +31,8 @@ class _BackgroundLoop:
server processes should call :func:`shutdown` explicitly to cancel
in-flight tasks before process exit.
"""
# Enforce the documented invariant. ``threading.Lock.locked()``
# is process-wide, not thread-local, but combined with the fact
# that the only callers are :py:meth:`get` and :py:meth:`get_unlocked`
# (the latter only invoked from inside ``run_sync`` while holding
# the lock), this catches the "forgot to hold the lock" mistake.
assert self._lock.locked(), "_spawn_locked() requires self._lock to be held"
# Create the loop+thread; caller must hold self._lock.
# The lock contract is now enforced structurally by submit() method.
if self._loop is None or self._loop.is_closed():
self._loop = asyncio.new_event_loop()
self._thread = threading.Thread(
@@ -51,9 +47,11 @@ class _BackgroundLoop:
with self._lock:
return self._spawn_locked()
def get_unlocked(self) -> asyncio.AbstractEventLoop:
"""Get loop assuming caller holds _lock. For run_sync() use only."""
return self._spawn_locked()
def submit(self, coro):
"""Atomically (re)spawn loop if needed and submit coro. Returns concurrent.futures.Future."""
with self._lock:
loop = self._spawn_locked()
return asyncio.run_coroutine_threadsafe(coro, loop)
def shutdown(self, timeout: float = 5.0) -> None:
with self._lock:
@@ -77,7 +75,16 @@ class _BackgroundLoop:
self._loop = None
self._thread = None
_BG = _BackgroundLoop()
_BG: "_BackgroundLoop | None" = None
_BG_LOCK = threading.Lock()
def _get_bg() -> "_BackgroundLoop":
global _BG
if _BG is None:
with _BG_LOCK:
if _BG is None:
_BG = _BackgroundLoop()
return _BG
def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) -> T:
@@ -109,10 +116,8 @@ def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) ->
"await the coroutine directly instead."
)
# Submit the coroutine inside the lock to prevent shutdown races
with _BG._lock:
loop = _BG.get_unlocked()
fut: Future = asyncio.run_coroutine_threadsafe(coro, loop)
# Submit the coroutine atomically
fut = _get_bg().submit(coro)
try:
return fut.result(timeout=timeout)
@@ -139,4 +144,6 @@ def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) ->
def shutdown() -> None:
"""Public hook for long-running server processes to stop the bridge cleanly."""
_BG.shutdown()
bg = _BG
if bg is not None:
bg.shutdown()
+108 -44
View File
@@ -41,39 +41,28 @@ class PluginRegistry(Generic[T]):
builtins: Dict of name -> loader function for built-in plugins
"""
self._entry_point_group = entry_point_group
self._items: Dict[str, Type[T]] = {}
self._loaders: Dict[str, Callable[[], Type[T]]] = {} # lazy loaders
self._items: Dict[str, Type[T]] = {} # resolved cache
self._aliases: Dict[str, str] = {} # alias -> canonical name
self._lock = threading.Lock()
self._lock = threading.RLock() # Use RLock for re-entrant access
# Load built-in plugins with error handling
# Store built-in loaders (normalized) without calling them
if builtins:
for name, loader in builtins.items():
try:
self._items[name] = loader()
except ImportError:
# Built-in plugin dependencies not available, skip
pass
except Exception:
# Log other errors but don't crash initialization
logger.warning("Failed to load built-in plugin %r", name, exc_info=True)
self._add_loader(name, loader)
self._load_entry_points()
self._discover_entry_points()
def _load_entry_points(self) -> None:
"""Load plugins from entry points."""
def _add_loader(self, name: str, loader: Callable[[], Type[T]]) -> None:
"""Add a loader with normalized name."""
normalized_name = name.lower()
self._loaders[normalized_name] = loader
def _discover_entry_points(self) -> None:
"""Discover entry point loaders without loading them."""
try:
for ep in entry_points(group=self._entry_point_group):
try:
plugin_class = ep.load()
with self._lock:
self._items[ep.name] = plugin_class
except Exception:
# Do not break plugin dispatch because one plugin is broken
logger.warning(
"Failed to load plugin %r from entry point",
ep.name,
exc_info=True,
)
self._add_loader(ep.name, ep.load)
except Exception:
# entry_points() might not be available in older Python versions
logger.debug("Entry points not available for group %s", self._entry_point_group)
@@ -94,6 +83,8 @@ class PluginRegistry(Generic[T]):
"""
with self._lock:
canonical_name = name.lower()
# Store both in loaders (for consistency with discovery) and items (for immediate access)
self._loaders[canonical_name] = lambda: cls
self._items[canonical_name] = cls
# Register aliases
@@ -120,7 +111,7 @@ class PluginRegistry(Generic[T]):
return True
# Check if it's a canonical name
if normalized_name in self._items:
if normalized_name in self._loaders:
# Remove all aliases pointing to this plugin
aliases_to_remove = [
alias for alias, canonical in self._aliases.items()
@@ -129,7 +120,9 @@ class PluginRegistry(Generic[T]):
for alias in aliases_to_remove:
del self._aliases[alias]
del self._items[normalized_name]
# Remove from both loaders and items cache
del self._loaders[normalized_name]
self._items.pop(normalized_name, None)
return True
return False
@@ -146,28 +139,44 @@ class PluginRegistry(Generic[T]):
Raises:
ValueError: If plugin is not found
"""
normalized_name = name.lower()
with self._lock:
normalized_name = name.lower()
# Resolve alias to canonical name
# Check cache first
canonical_name = self._aliases.get(normalized_name, normalized_name)
cls = self._items.get(canonical_name)
# Capture available plugins snapshot while holding lock
# to avoid race condition between check and error message
if cls is None:
available_plugins = sorted(self._items.keys())
if cls is not None:
return cls
# Try to load from loaders
loader = self._loaders.get(canonical_name)
if loader is None:
# Capture available plugins snapshot while holding lock
available_loaders = sorted(self._loaders.keys())
available_aliases = sorted(self._aliases.keys())
available_snapshot = available_plugins + available_aliases
else:
available_snapshot = None
available_snapshot = available_loaders + available_aliases
raise ValueError(
f"Unknown {self._entry_point_group} plugin: {name!r}. "
f"Available: {available_snapshot}"
)
# Load and cache the plugin (release lock during loading to prevent deadlock)
pass
if cls is None:
# Load outside the lock to prevent deadlock if loader calls back into registry
try:
cls = loader()
except ImportError as e:
raise ValueError(
f"Unknown {self._entry_point_group} plugin: {name!r}. "
f"Available: {available_snapshot}"
)
return cls
f"Plugin {name!r} is registered but its dependencies "
f"are not installed: {e}"
) from e
# Re-acquire lock to cache the result
with self._lock:
self._items[canonical_name] = cls
return cls
def create(self, name: str, *args, **kwargs) -> T:
"""Create an instance of the specified plugin.
@@ -192,7 +201,7 @@ class PluginRegistry(Generic[T]):
Sorted list of plugin names
"""
with self._lock:
return sorted(self._items.keys())
return sorted(self._loaders.keys())
def is_available(self, name: str) -> bool:
"""Check if a plugin is available.
@@ -225,4 +234,59 @@ class PluginRegistry(Generic[T]):
Sorted list of all registered names and aliases
"""
with self._lock:
return sorted(list(self._items.keys()) + list(self._aliases.keys()))
return sorted(list(self._loaders.keys()) + list(self._aliases.keys()))
def get_by_attr(self, module_name: str, attr_name: str) -> Type[T]:
"""Get a plugin by attribute name for __getattr__ dispatch.
Args:
module_name: Module name requesting the attribute (for error messages)
attr_name: Attribute name to resolve
Returns:
Plugin class
Raises:
AttributeError: If plugin is not found
"""
try:
return self.resolve(attr_name)
except ValueError:
raise AttributeError(f"module {module_name!r} has no attribute {attr_name!r}")
def create_lazy_getattr(registry: PluginRegistry[T]) -> Callable[[str], T]:
"""Create a __getattr__ function backed by a PluginRegistry.
This replaces manual if/elif ladders in __init__.py files with a data-driven
approach using the plugin registry.
Args:
registry: The plugin registry to use for resolution
Returns:
Function that can be used as __getattr__ in a module
Example:
# In __init__.py:
from ._registry import create_lazy_getattr
# Assuming you have a registry instance
__getattr__ = create_lazy_getattr(my_registry)
"""
def __getattr__(name: str) -> T:
try:
plugin_class = registry.resolve(name)
return plugin_class
except ValueError:
# Get the calling module name for error context
import inspect
frame = inspect.currentframe()
if frame and frame.f_back:
module_name = frame.f_back.f_globals.get('__name__', 'unknown')
else:
module_name = 'unknown'
raise AttributeError(f"module {module_name!r} has no attribute {name!r}")
return __getattr__
+198 -24
View File
@@ -28,14 +28,33 @@ from .tool_registry import ToolRegistry
# BaseTool import is now handled centrally by ToolResolver
# Check for additional framework availability using centralized detection
# Framework availability detection (lazy via __getattr__)
from ._framework_availability import is_available
PRAISONAI_TOOLS_AVAILABLE = is_available("praisonai_tools")
CREWAI_AVAILABLE = is_available("crewai")
AUTOGEN_AVAILABLE = is_available("autogen")
AG2_AVAILABLE = is_available("ag2")
PRAISONAI_AVAILABLE = is_available("praisonaiagents")
AGENTOPS_AVAILABLE = is_available("agentops")
# Lazy constants mapping for backward compatibility
_AVAIL = {
"PRAISONAI_TOOLS_AVAILABLE": "praisonai_tools",
"CREWAI_AVAILABLE": "crewai",
"AUTOGEN_AVAILABLE": "autogen",
"AG2_AVAILABLE": "ag2",
"PRAISONAI_AVAILABLE": "praisonaiagents",
"AGENTOPS_AVAILABLE": "agentops",
}
__all__ = list(_AVAIL.keys())
def __getattr__(name):
"""Lazy attribute access for framework availability constants.
This allows backward compatibility while avoiding import-time probing.
Only probes the framework when the constant is actually accessed.
"""
if name in _AVAIL:
return is_available(_AVAIL[name])
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
def __dir__():
return sorted(set(globals()) | set(_AVAIL))
# Framework adapter registry - now uses proper registry pattern
# This replaces the hardcoded FRAMEWORK_ADAPTERS dict
@@ -282,7 +301,7 @@ class AgentsGenerator:
# Keep tool registry for backward compatibility with autogen adapters
self.tool_registry = ToolRegistry()
self.tool_registry.register_builtin_autogen_adapters()
self.tool_registry.register_builtin_autogen_adapters(_suppress_deprecation_warning=True)
# Initialize tool resolver with the registry wired in (single source of truth for tool resolution)
from .tool_resolver import ToolResolver
@@ -576,7 +595,7 @@ class AgentsGenerator:
tools_dict = {}
# Demand-driven tool resolution - only resolve tools actually used in YAML
if CREWAI_AVAILABLE or AUTOGEN_AVAILABLE or PRAISONAI_AVAILABLE or AG2_AVAILABLE:
if is_available("crewai") or is_available("autogen") or is_available("praisonaiagents") or is_available("ag2"):
try:
# Collect all tool names mentioned in the YAML config
needed_tools: set[str] = set()
@@ -634,14 +653,163 @@ class AgentsGenerator:
self.logger.debug("tools folder exists in the root directory")
framework = self.framework or config.get('framework', 'crewai')
# Get initial adapter and resolve to concrete variant
initial_adapter = self._get_framework_adapter(framework)
adapter = initial_adapter.resolve()
# Validate framework availability early
from .framework_adapters.validators import assert_framework_available
assert_framework_available(adapter.name)
# Initialize observability hooks
from .observability.hooks import init_observability
init_observability(adapter.name)
# Run adapter setup hooks
adapter.setup(framework_tag=adapter.name)
# Update framework reference if resolution changed it
self.framework = adapter.name
self.framework_adapter = adapter
self.logger.info(f"Using framework: {adapter.name}")
return adapter.run(
config,
self.config_list,
topic,
tools_dict=tools_dict,
agent_callback=getattr(self, 'agent_callback', None),
task_callback=getattr(self, 'task_callback', None),
cli_config=getattr(self, 'cli_config', None),
)
# Determine AutoGen version if needed (keeping compatibility logic)
async def agenerate_crew_and_kickoff(self):
"""
Async version of generate_crew_and_kickoff.
Generates a crew of agents and initiates tasks based on the provided configuration.
"""
if self.agent_yaml:
config = yaml.safe_load(self.agent_yaml)
else:
if self.agent_file == '/app/api:app' or self.agent_file == 'api:app':
self.agent_file = 'agents.yaml'
try:
with open(self.agent_file, 'r') as f:
config = yaml.safe_load(f)
except FileNotFoundError:
print(f"File not found: {self.agent_file}")
return
# Apply CLI configuration overrides to YAML config
if self.cli_config:
# Merge CLI configuration with YAML config
self._merge_cli_config(config, self.cli_config)
# Check if this is a workflow-mode YAML (process: workflow or has steps section)
process_type = config.get('process', 'sequential')
has_steps = 'steps' in config
has_workflow_config = 'workflow' in config
if process_type == 'workflow' or (has_steps and has_workflow_config):
return await self._arun_yaml_workflow(config)
else:
return await self._arun_framework(config)
async def _arun_framework(self, config):
"""Async version of _run_framework with shared preparation logic."""
# Canonical format conversion: 'agents' -> 'roles', 'instructions' -> 'backstory'
if 'agents' in config and 'roles' not in config:
config['roles'] = {}
for agent_name, agent_config in config['agents'].items():
role_config = dict(agent_config) if agent_config else {}
if 'instructions' in role_config and 'backstory' not in role_config:
role_config['backstory'] = role_config['instructions']
if 'role' not in role_config:
role_config['role'] = agent_name.replace('_', ' ').title()
if 'goal' not in role_config:
role_config['goal'] = role_config.get('backstory', 'Complete the assigned task')
if 'backstory' not in role_config:
role_config['backstory'] = f'You are a {role_config["role"]}'
config['roles'][agent_name] = role_config
# Get workflow input: 'input' is canonical, 'topic' is alias for backward compatibility
topic = config.get('input', config.get('topic', ''))
# Validate agents configuration for typos in field names
self._validate_agents_config(config)
tools_dict = {}
# Demand-driven tool resolution - only resolve tools actually used in YAML
if is_available("crewai") or is_available("autogen") or is_available("praisonaiagents") or is_available("ag2"):
try:
# Collect all tool names mentioned in the YAML config
needed_tools: set[str] = set()
for role_cfg in config.get('roles', {}).values():
for t in role_cfg.get('tools') or []:
if isinstance(t, str) and t.strip():
needed_tools.add(t.strip())
for task_cfg in (role_cfg.get('tasks') or {}).values():
if not isinstance(task_cfg, dict):
continue
for t in task_cfg.get('tools') or []:
if isinstance(t, str) and t.strip():
needed_tools.add(t.strip())
# Resolve only the tools actually referenced in YAML
for tool_name in needed_tools:
try:
resolved_tool = self.tool_resolver.resolve(tool_name)
if resolved_tool is None:
self.logger.warning(f"Tool '{tool_name}' not found")
continue
tools_dict[tool_name] = (
resolved_tool() if inspect.isclass(resolved_tool) else resolved_tool
)
except Exception as e:
self.logger.warning(f"Failed to initialize tool '{tool_name}': {e}")
continue
except Exception as e:
self.logger.warning(f"Error collecting YAML tool references: {e}")
# Add tools from class names - use tool_resolver to check tool validity
for tool_class in self.tools:
if isinstance(tool_class, type):
try:
tool_instance = tool_class()
tool_name = tool_class.__name__
tools_dict[tool_name] = tool_instance
self.logger.debug(f"Added tool: {tool_name}")
except Exception as e:
self.logger.warning(f"Failed to instantiate tool class {tool_class.__name__}: {e}")
root_directory = os.getcwd()
tools_py_path = os.path.join(root_directory, 'tools.py')
tools_dir_path = Path(root_directory) / 'tools'
# Use consolidated ToolResolver for tools.py loading
tools_dict.update(self.tool_resolver.get_local_tool_classes())
if os.path.isfile(tools_py_path):
self.logger.debug("tools.py exists in the root directory. Loading tools.py and skipping tools folder.")
elif tools_dir_path.is_dir():
tools_dict.update(self.tool_resolver.get_local_tool_classes_from_dir(tools_dir_path))
if tools_dict:
self.logger.debug("tools folder exists in the root directory")
framework = self.framework or config.get('framework', 'crewai')
# AutoGen version selection logic
if framework == "autogen":
autogen_version = os.environ.get("AUTOGEN_VERSION", "auto").lower()
autogen_v4_adapter = self._get_framework_adapter("autogen_v4")
autogen_v2_adapter = self._get_framework_adapter("autogen")
autogen_version = str(
config.get('autogen_version', os.environ.get("AUTOGEN_VERSION", "auto"))
).lower()
use_v4 = False
if autogen_version == "v0.4" and autogen_v4_adapter.is_available():
use_v4 = True
elif autogen_version == "v0.2" and autogen_v2_adapter.is_available():
@@ -652,27 +820,27 @@ class AgentsGenerator:
use_v4 = autogen_v4_adapter.is_available() and not autogen_v2_adapter.is_available()
framework = "autogen_v4" if use_v4 else "autogen"
# Initialize AgentOps if available
try:
import agentops
agentops_api_key = os.getenv("AGENTOPS_API_KEY")
if agentops_api_key:
# Initialize AgentOps if configured
agentops_api_key = os.getenv("AGENTOPS_API_KEY")
if agentops_api_key:
try:
import agentops
agentops.init(agentops_api_key, default_tags=[framework])
except ImportError:
pass
# Update framework adapter if framework changed (e.g., AutoGen version selection)
except ImportError:
pass
# Update framework adapter if framework changed
if framework != self.framework:
self.framework = framework
self.framework_adapter = self._get_framework_adapter(framework)
# Validate framework availability for non-CLI callers
# Validate framework availability
from .framework_adapters.validators import assert_framework_available
assert_framework_available(framework)
self.logger.info(f"Using framework: {framework}")
return self.framework_adapter.run(
return await self.framework_adapter.arun(
config,
self.config_list,
topic,
@@ -682,6 +850,12 @@ class AgentsGenerator:
cli_config=getattr(self, 'cli_config', None),
)
async def _arun_yaml_workflow(self, config):
"""Async placeholder for workflow mode - currently delegates to sync version."""
# For now, delegate to sync version using asyncio.to_thread
import asyncio
return await asyncio.to_thread(self._run_yaml_workflow, config)
def _run_yaml_workflow(self, config):
"""
Run a YAML workflow using the YAMLWorkflowParser.
@@ -696,7 +870,7 @@ class AgentsGenerator:
Returns:
str: Result of the workflow execution
"""
if not PRAISONAI_AVAILABLE:
if not is_available("praisonaiagents"):
raise ImportError("PraisonAI is not installed. Please install it with 'pip install praisonaiagents'")
try:
+6 -36
View File
@@ -65,37 +65,7 @@ def _load_optional(key: str, loader):
# Availability checks now delegated to centralized module
from ._framework_availability import is_available
def _check_crewai_available() -> bool:
"""Check if crewai is available (cached, thread-safe)."""
return is_available("crewai")
def _check_autogen_available() -> bool:
"""Check if autogen v0.2 is available (cached, thread-safe)."""
return is_available("autogen")
def _check_autogen_v4_available() -> bool:
"""Check if autogen v0.4 is available (cached, thread-safe)."""
return is_available("autogen_v4")
def _check_ag2_available() -> bool:
"""Check if AG2 (community fork of AutoGen) is available (cached, thread-safe)."""
return is_available("ag2")
def _check_praisonai_available() -> bool:
"""Check if praisonaiagents is available (cached, thread-safe)."""
return is_available("praisonaiagents")
def _check_praisonai_tools_available() -> bool:
"""Check if praisonai_tools is available (cached, thread-safe)."""
return is_available("praisonai_tools")
def _check_litellm_available() -> bool:
"""Check if litellm is available (cached, thread-safe)."""
return is_available("litellm")
def _check_openai_available() -> bool:
"""Check if openai is available (cached, thread-safe)."""
return is_available("openai")
# Redundant wrapper functions removed - use is_available() directly
def _get_crewai():
@@ -504,7 +474,7 @@ class BaseAutoGenerator:
model_name = self.config_list[0]['model']
# Try LiteLLM first (preferred - supports 100+ providers)
if _check_litellm_available():
if is_available("litellm"):
litellm = _get_litellm()
response = litellm.completion(
model=model_name,
@@ -516,7 +486,7 @@ class BaseAutoGenerator:
return response_model.model_validate_json(content)
# Fallback to OpenAI SDK (uses beta.chat.completions.parse)
if _check_openai_available():
if is_available("openai"):
client = self._get_openai_client()
response = client.beta.chat.completions.parse(
model=model_name,
@@ -554,7 +524,7 @@ class BaseAutoGenerator:
model_name = self.config_list[0]['model']
# Try LiteLLM async first (preferred - supports 100+ providers)
if _check_litellm_available():
if is_available("litellm"):
litellm = _get_litellm()
response = await litellm.acompletion(
model=model_name,
@@ -566,7 +536,7 @@ class BaseAutoGenerator:
return response_model.model_validate_json(content)
# Fallback to OpenAI AsyncSDK (uses beta.chat.completions.parse)
if _check_openai_available():
if is_available("openai"):
if self._async_openai_client is None:
with self._client_lock:
if self._async_openai_client is None:
@@ -728,7 +698,7 @@ class AutoGenerator(BaseAutoGenerator):
raise ImportError(f"{adapter.name} is not installed. Please install with:\n {install_hint}")
# Check tools availability if required by this framework
if requires_tools_extra and not _check_praisonai_tools_available():
if requires_tools_extra and not is_available("praisonai_tools"):
logger.warning(f"Tools are not available for {framework}. To use tools, install:\n {install_hint}")
self.topic = topic
+10 -4
View File
@@ -23,11 +23,14 @@ class AgentConfigSchema(BaseModel):
class ChannelConfigSchema(BaseModel):
"""Schema for a single channel configuration."""
platform: Optional[str] = None
token: str = ""
mode: str = "poll" # poll | ws | webhook | hybrid
group_policy: str = "respond_all" # respond_all, mention_only, command_only
allowlist: List[str] = Field(default_factory=list)
blocklist: List[str] = Field(default_factory=list)
allowed_users: Optional[str] = None
routes: Dict[str, str] = Field(default_factory=dict)
webhook_url: Optional[str] = None
webhook_port: int = 8080
@@ -102,12 +105,15 @@ class BotYamlSchema(BaseModel):
"No channels configured. Add at least one channel "
"(telegram, discord, slack, whatsapp) to your bot.yaml"
)
# Validate channel names
# Channel keys may be platform names (telegram) or custom ids (telegram_cfo)
# when ``platform`` is set on the channel block (gateway / multi-bot YAML).
valid_platforms = {"telegram", "discord", "slack", "whatsapp", "email", "agentmail"}
for name in self.channels:
if name not in valid_platforms:
for name, channel in self.channels.items():
platform = (channel.platform or name).lower()
if platform not in valid_platforms:
raise ValueError(
f"Unknown channel '{name}'. Supported: {', '.join(sorted(valid_platforms))}"
f"Unknown channel '{name}' (platform '{platform}'). "
f"Supported platforms: {', '.join(sorted(valid_platforms))}"
)
return self
+11 -9
View File
@@ -149,15 +149,17 @@ class BotSessionManager:
if self._store is not None:
key = self._session_key(user_id)
try:
# Clear existing and re-write (atomic update)
self._store.clear_session(key)
for msg in history:
self._store.add_message(
key,
msg.get("role", "user"),
msg.get("content", ""),
msg.get("metadata"),
)
if hasattr(self._store, "set_chat_history"):
self._store.set_chat_history(key, history)
else:
self._store.clear_session(key)
for msg in history:
self._store.add_message(
key,
msg.get("role", "user"),
msg.get("content", ""),
msg.get("metadata"),
)
except Exception as e:
logger.warning("Failed to persist session to store: %s", e)
+116 -32
View File
@@ -181,37 +181,10 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
)
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
# Handle text OR audio messages
message_text = None
if update.message:
# Check for voice/audio first
if update.message.voice or update.message.audio:
message_text = await self._transcribe_audio(update)
elif update.message.text:
message_text = update.message.text
if not update.message or not message_text:
return
message = self._convert_update_to_message(update, override_text=message_text)
# Add channel type for pairing system
message._channel_type = "telegram"
self.fire_message_received(message)
# Check if channel is allowed
if not self.config.is_channel_allowed(message.channel.channel_id if message.channel else ""):
return
# Handle unknown users with pairing system
user_id = message.sender.user_id if message.sender else ""
is_explicitly_allowed = bool(self.config.allowed_users) and self.config.is_user_allowed(user_id)
if not is_explicitly_allowed:
user_allowed = await UnknownUserHandler.handle(message, self._bot_context)
if not user_allowed:
return
# Use shared security pipeline for consistent enforcement
message = await process_inbound_telegram_message(update, self)
if not message:
return # Message was dropped by security checks
for handler in self._message_handlers:
try:
@@ -252,7 +225,7 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
update.message.from_user.username or update.message.from_user.first_name or ""
) if update.message.from_user else ""
try:
message_text = await self._debouncer.debounce(user_id, message_text)
message_text = await self._debouncer.debounce(user_id, message.content)
# Show typing indicator with renewal during long operation
if self.config.typing_indicator:
@@ -848,3 +821,114 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
except Exception as e:
logger.error(f"Failed to send reply: {e}")
async def process_inbound_telegram_message(
update, # Telegram Update
bot: TelegramBot,
gateway_context: Optional[Dict] = None
) -> Optional[BotMessage]:
"""
Shared security pipeline for processing inbound Telegram messages.
Used by both standalone bot (TelegramBot.handle_message) and
gateway polling (_start_telegram_bot_polling) to ensure consistent
access control enforcement.
Args:
update: Telegram Update object
bot: TelegramBot instance with config and security settings
gateway_context: Optional dict with gateway-specific context
Returns:
BotMessage if message passes all security checks, None if dropped
"""
if not update.message:
return None
# Extract message text (including audio transcription)
message_text = None
if update.message.voice or update.message.audio:
message_text = await bot._transcribe_audio(update)
elif update.message.text:
message_text = update.message.text
if not message_text:
return None
# Convert to BotMessage for consistent processing
message = bot._convert_update_to_message(update, override_text=message_text)
# Set channel type for pairing system
message._channel_type = "telegram"
# Fire message received event
bot.fire_message_received(message)
# 1. Channel allowlist check
channel_id = message.channel.channel_id if message.channel else ""
if not bot.config.is_channel_allowed(channel_id):
logger.debug(f"Message dropped: channel {channel_id} not in allowed_channels")
return None
# 2. User allowlist and pairing check
user_id = message.sender.user_id if message.sender else ""
is_explicitly_allowed = bot.config.is_user_allowed(user_id)
if not is_explicitly_allowed:
# Check if bot context is available for pairing system
if not hasattr(bot, '_bot_context') or bot._bot_context is None:
# For gateway mode, we need to create bot context on demand
if not hasattr(bot, '_pairing_store'):
from ..gateway.pairing import PairingStore
bot._pairing_store = PairingStore()
bot._bot_context = BotContext(
config=bot.config,
pairing_store=bot._pairing_store,
adapter=bot
)
user_allowed = await UnknownUserHandler.handle(message, bot._bot_context)
if not user_allowed:
logger.debug(f"Message dropped: user {user_id} not allowed by pairing system")
return None
# 3. Group policy enforcement
if message.channel and message.channel.channel_type not in ("dm", "private"):
# This is a group/channel message, check group policies
group_policy = getattr(bot.config, 'group_policy', 'mention_only')
mention_required = getattr(bot.config, 'mention_required', True)
if group_policy == "command_only":
if message.message_type != MessageType.COMMAND:
logger.debug(f"Message dropped: non-command in command_only group {channel_id}")
return None
elif group_policy == "mention_only":
# Check if bot was mentioned in the message
bot_username = bot._bot_user.username.lower() if bot._bot_user and bot._bot_user.username else ""
mention_handle = f"@{bot_username}" if bot_username else ""
bot_mentioned = (
mention_handle and mention_handle in message.content.lower()
) or message.message_type == MessageType.COMMAND # Commands are always allowed
if not bot_mentioned:
logger.debug(f"Message dropped: bot not mentioned in group {channel_id}")
return None
elif group_policy == "respond_all":
# Allow all group messages
pass
elif mention_required:
# Fallback for backward compatibility when group_policy is not set
bot_username = bot._bot_user.username.lower() if bot._bot_user and bot._bot_user.username else ""
mention_handle = f"@{bot_username}" if bot_username else ""
bot_mentioned = (
mention_handle and mention_handle in message.content.lower()
) or message.message_type == MessageType.COMMAND # Commands are always allowed
if not bot_mentioned:
logger.debug(f"Message dropped: bot not mentioned in group {channel_id}")
return None
# All security checks passed
logger.debug(f"Message security checks passed for user {user_id} in channel {channel_id}")
return message
@@ -72,6 +72,8 @@ configure_host(
"guardrails",
"sessions",
"usage",
"kanban", # ADD
"jobs-board", # ADD
"config",
"logs",
"debug",
+2
View File
@@ -305,6 +305,7 @@ def register_commands():
from .commands.mcp import app as mcp_app
from .commands.serve import app as serve_app
from .commands.schedule import app as schedule_app
from .commands.kanban import app as kanban_app
from .commands.run import app as run_app
from .commands.profile import app as profile_app
from .commands.benchmark import app as benchmark_app
@@ -387,6 +388,7 @@ def register_commands():
app.add_typer(mcp_app, name="mcp", help="MCP server management")
app.add_typer(serve_app, name="serve", help="API server management")
app.add_typer(schedule_app, name="schedule", help="Scheduler management")
app.add_typer(kanban_app, name="kanban", help="Kanban task management")
app.add_typer(run_app, name="run", help="Run agents")
app.add_typer(profile_app, name="profile", help="Performance profiling and diagnostics")
app.add_typer(benchmark_app, name="benchmark", help="Comprehensive performance benchmarking")
@@ -0,0 +1,452 @@
"""
Kanban command group for PraisonAI CLI.
Provides kanban task management for multi-agent coordination.
"""
import json
from typing import Optional, List
import typer
from ..output.console import get_output_controller
app = typer.Typer(help="Kanban task management")
def _get_kanban_store():
"""Get kanban store instance."""
try:
from praisonai.kanban.sqlite_store import SQLiteKanbanStore
return SQLiteKanbanStore()
except ImportError as e:
output = get_output_controller()
output.print_error(f"Kanban module not available: {e}")
raise typer.Exit(4)
@app.command("list")
def kanban_list_cmd(
status: Optional[str] = typer.Option(None, "--status", "-s", help="Filter by status (todo, ready, running, blocked, done)"),
assignee: Optional[str] = typer.Option(None, "--assignee", "-a", help="Filter by assignee"),
board: str = typer.Option("default", "--board", "-b", help="Board name"),
limit: int = typer.Option(50, "--limit", "-l", help="Maximum number of tasks to show"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""List kanban tasks with optional filtering."""
output = get_output_controller()
try:
store = _get_kanban_store()
# Build filters
filters = {'board': board}
if status:
filters['status'] = status
if assignee:
filters['assignee'] = assignee
tasks = store.list_tasks(filters)
if len(tasks) > limit:
tasks = tasks[:limit]
if json_output:
result = {
'tasks': [task.to_dict() for task in tasks],
'count': len(tasks),
'filters': filters,
'limited': len(tasks) == limit
}
output.print_json(result)
else:
# Pretty print table
if not tasks:
output.print_info("No tasks found")
return
output.print_info(f"Found {len(tasks)} tasks{' (limited)' if len(tasks) == limit else ''}")
output.print("")
# Table header
print(f"{'ID':<15} {'Status':<10} {'Title':<40} {'Assignee':<15} {'Priority':<8}")
print("-" * 90)
# Task rows
for task in tasks:
title = task.title[:37] + "..." if len(task.title) > 40 else task.title
assignee = task.assignee[:12] + "..." if len(task.assignee) > 15 else task.assignee
print(f"{task.id:<15} {task.status.value:<10} {title:<40} {assignee:<15} {task.priority:<8}")
except Exception as e:
output.print_error(f"Failed to list tasks: {e}")
raise typer.Exit(1)
@app.command("create")
def kanban_create_cmd(
title: str = typer.Argument(..., help="Task title"),
body: str = typer.Option("", "--body", "-b", help="Task description"),
assignee: str = typer.Option("", "--assignee", "-a", help="Username to assign to"),
status: str = typer.Option("todo", "--status", "-s", help="Initial status"),
priority: int = typer.Option(0, "--priority", "-p", help="Task priority (higher = more important)"),
board: str = typer.Option("default", "--board", help="Board name"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Create a new kanban task."""
output = get_output_controller()
try:
store = _get_kanban_store()
task_data = {
'title': title,
'body': body,
'assignee': assignee,
'status': status,
'priority': priority,
'board': board
}
task = store.create_task(task_data)
if json_output:
output.print_json(task.to_dict())
else:
output.print_success(f"Created task {task.id}")
output.print_info(f"Title: {task.title}")
output.print_info(f"Status: {task.status.value}")
if assignee:
output.print_info(f"Assignee: {task.assignee}")
except Exception as e:
output.print_error(f"Failed to create task: {e}")
raise typer.Exit(1)
@app.command("show")
def kanban_show_cmd(
task_id: str = typer.Argument(..., help="Task ID to show"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Show detailed task information."""
output = get_output_controller()
try:
store = _get_kanban_store()
task = store.get_task(task_id)
if not task:
output.print_error(f"Task {task_id} not found")
raise typer.Exit(1)
comments = store.get_comments(task_id)
if json_output:
result = {
'task': task.to_dict(),
'comments': [comment.to_dict() for comment in comments],
'comment_count': len(comments)
}
output.print_json(result)
else:
# Pretty print task details
output.print_info(f"Task: {task.id}")
output.print("")
output.print_info(f"Title: {task.title}")
output.print_info(f"Status: {task.status.value}")
output.print_info(f"Assignee: {task.assignee or '(unassigned)'}")
output.print_info(f"Priority: {task.priority}")
output.print_info(f"Board: {task.board}")
output.print_info(f"Created: {task.created_at.strftime('%Y-%m-%d %H:%M:%S')}")
output.print_info(f"Updated: {task.updated_at.strftime('%Y-%m-%d %H:%M:%S')}")
if task.body:
output.print("")
output.print_info("Description:")
output.print(task.body)
if comments:
output.print("")
output.print_info(f"Comments ({len(comments)}):")
for comment in comments:
output.print(f" [{comment.created_at.strftime('%m-%d %H:%M')}] {comment.author}: {comment.text}")
except Exception as e:
output.print_error(f"Failed to show task: {e}")
raise typer.Exit(1)
@app.command("move")
def kanban_move_cmd(
task_id: str = typer.Argument(..., help="Task ID to move"),
status: str = typer.Argument(..., help="New status (todo, ready, running, blocked, done)"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Move task to a new status."""
output = get_output_controller()
try:
store = _get_kanban_store()
task = store.move_task(task_id, status)
if json_output:
output.print_json(task.to_dict())
else:
output.print_success(f"Moved task {task_id} to {status}")
except Exception as e:
output.print_error(f"Failed to move task: {e}")
raise typer.Exit(1)
@app.command("comment")
def kanban_comment_cmd(
task_id: str = typer.Argument(..., help="Task ID to comment on"),
text: str = typer.Argument(..., help="Comment text"),
author: str = typer.Option("cli", "--author", "-a", help="Comment author"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Add a comment to a task."""
output = get_output_controller()
try:
store = _get_kanban_store()
comment = store.add_comment(task_id, author, text)
if json_output:
output.print_json(comment.to_dict())
else:
output.print_success(f"Added comment to task {task_id}")
except Exception as e:
output.print_error(f"Failed to add comment: {e}")
raise typer.Exit(1)
@app.command("link")
def kanban_link_cmd(
parent_id: str = typer.Argument(..., help="Parent task ID"),
child_id: str = typer.Argument(..., help="Child task ID"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Create dependency link between tasks."""
output = get_output_controller()
try:
store = _get_kanban_store()
link = store.add_link(parent_id, child_id)
if json_output:
result = {
'parent_id': link.parent_id,
'child_id': link.child_id,
'created_at': link.created_at.isoformat()
}
output.print_json(result)
else:
output.print_success(f"Linked {parent_id} -> {child_id}")
except Exception as e:
output.print_error(f"Failed to link tasks: {e}")
raise typer.Exit(1)
@app.command("boards")
def kanban_boards_cmd(
action: str = typer.Argument("list", help="Action: list, switch"),
board: Optional[str] = typer.Argument(None, help="Board name for switch action"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Manage kanban boards."""
output = get_output_controller()
try:
if action == "list":
from praisonai.kanban.paths import list_available_boards
boards = list_available_boards()
if json_output:
output.print_json({"boards": boards})
else:
output.print_info(f"Available boards:")
for board_name in boards:
output.print(f" - {board_name}")
elif action == "switch":
if not board:
output.print_error("Board name required for switch action")
raise typer.Exit(1)
import os
from pathlib import Path
import json
# Set for current session
os.environ['PRAISONAI_KANBAN_BOARD'] = board
# Persist to config file for future sessions
config_dir = Path.home() / ".praisonai"
config_dir.mkdir(exist_ok=True)
config_file = config_dir / "kanban_config.json"
try:
config = {"active_board": board}
with open(config_file, 'w') as f:
json.dump(config, f, indent=2)
output.print_success(f"Switched to board '{board}' (persisted)")
except Exception as e:
output.print_success(f"Switched to board '{board}' (session only)")
output.print_info(f"Could not persist setting: {e}")
else:
output.print_error(f"Unknown action: {action}")
raise typer.Exit(1)
except Exception as e:
output.print_error(f"Board operation failed: {e}")
raise typer.Exit(1)
@app.command("dispatch")
def kanban_dispatch_cmd(
max_spawn: int = typer.Option(3, "--max-spawn", "-m", help="Maximum tasks to spawn"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Manually trigger kanban task dispatcher."""
output = get_output_controller()
try:
import asyncio
from praisonai.gateway.kanban_dispatcher import dispatch_once
# Run dispatcher once
spawned = asyncio.run(dispatch_once(max_spawn))
if json_output:
output.print_json({"spawned": spawned})
else:
if spawned > 0:
output.print_success(f"Spawned {spawned} task workers")
else:
output.print_info("No ready tasks to spawn")
except Exception as e:
output.print_error(f"Dispatch failed: {e}")
raise typer.Exit(1)
@app.command("block")
def kanban_block_cmd(
task_id: str = typer.Argument(..., help="Task ID to block"),
reason: str = typer.Argument(..., help="Reason for blocking"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Block a task with a reason."""
output = get_output_controller()
try:
store = _get_kanban_store()
# Move to blocked and add comment
task = store.move_task(task_id, 'blocked')
store.add_comment(task_id, 'cli', f"Blocked: {reason}")
if json_output:
output.print_json(task.to_dict())
else:
output.print_success(f"Blocked task {task_id}: {reason}")
except Exception as e:
output.print_error(f"Failed to block task: {e}")
raise typer.Exit(1)
@app.command("unblock")
def kanban_unblock_cmd(
task_id: str = typer.Argument(..., help="Task ID to unblock"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Unblock a task (move to ready)."""
output = get_output_controller()
try:
store = _get_kanban_store()
task = store.move_task(task_id, 'ready')
store.add_comment(task_id, 'cli', "Unblocked by CLI")
if json_output:
output.print_json(task.to_dict())
else:
output.print_success(f"Unblocked task {task_id}")
except Exception as e:
output.print_error(f"Failed to unblock task: {e}")
raise typer.Exit(1)
@app.command("complete")
def kanban_complete_cmd(
task_id: str = typer.Argument(..., help="Task ID to complete"),
comment: str = typer.Option("", "--comment", "-c", help="Completion comment"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Mark a task as completed."""
output = get_output_controller()
try:
store = _get_kanban_store()
task = store.move_task(task_id, 'done')
if comment:
store.add_comment(task_id, 'cli', f"Completed: {comment}")
if json_output:
output.print_json(task.to_dict())
else:
output.print_success(f"Completed task {task_id}")
except Exception as e:
output.print_error(f"Failed to complete task: {e}")
raise typer.Exit(1)
@app.command("reclaim")
def kanban_reclaim_cmd(
task_id: str = typer.Argument(..., help="Task ID to reclaim"),
json_output: bool = typer.Option(False, "--json", help="Output as JSON"),
):
"""Reclaim a stuck/abandoned task."""
output = get_output_controller()
try:
store = _get_kanban_store()
# Force release any claim and move to ready
store.update_task(task_id, {'claim_lock': None})
task = store.move_task(task_id, 'ready')
store.add_comment(task_id, 'cli', "Reclaimed from stuck state")
if json_output:
output.print_json(task.to_dict())
else:
output.print_success(f"Reclaimed task {task_id}")
except Exception as e:
output.print_error(f"Failed to reclaim task: {e}")
raise typer.Exit(1)
# Add to main CLI app if this module is being registered
def register_kanban_commands(main_app):
"""Register kanban commands with main CLI app."""
main_app.add_typer(app, name="kanban")
if __name__ == "__main__":
app()
+116 -53
View File
@@ -151,6 +151,8 @@ def mcp_test(
timeout: float = typer.Option(10.0, "--timeout", "-t", help="Timeout in seconds"),
):
"""Test an MCP server connection."""
from ..configuration.schema import MCPLocalConfig, MCPRemoteConfig
output = get_output_controller()
loader = get_config_loader()
@@ -161,59 +163,103 @@ def mcp_test(
server = config.mcp.servers[name]
output.print_info(f"Testing MCP server: {name}")
output.print(f" Command: {server.command} {' '.join(server.args)}")
# Try to start the server and check if it responds
import subprocess
import time
try:
cmd = [server.command] + server.args
env = dict(**server.env) if server.env else None
if isinstance(server, MCPRemoteConfig):
# Test remote server
output.print_info(f"Testing remote MCP server: {name}")
output.print(f" URL: {server.url}")
# Start process
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
# Wait briefly and check if it's running
time.sleep(0.5)
if proc.poll() is None:
# Process is running
proc.terminate()
proc.wait(timeout=2)
try:
import httpx
# Simple HTTP probe to check if server is reachable
with httpx.Client(timeout=timeout) as client:
response = client.get(server.url)
if response.status_code == 200:
if output.is_json_mode:
output.print_json({"name": name, "status": "ok", "message": "Remote server reachable"})
else:
output.print_success(f"Remote server '{name}' is reachable")
else:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": f"HTTP {response.status_code}"})
else:
output.print_error(f"Remote server returned HTTP {response.status_code}")
raise typer.Exit(1)
except ImportError:
if output.is_json_mode:
output.print_json({"name": name, "status": "ok", "message": "Server started successfully"})
output.print_json({"name": name, "status": "error", "message": "httpx not available for remote testing"})
else:
output.print_success(f"Server '{name}' started successfully")
else:
# Process exited
stderr = proc.stderr.read().decode() if proc.stderr else ""
output.print_error("httpx package not available. Install with: pip install httpx")
raise typer.Exit(1)
except typer.Exit:
# Let typer.Exit propagate, don't catch it
raise
except Exception as e:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": stderr or "Server exited immediately"})
output.print_json({"name": name, "status": "error", "message": str(e)})
else:
output.print_error(f"Server exited immediately: {stderr}")
output.print_error(f"Failed to connect to remote server: {e}")
raise typer.Exit(1)
else:
# Test local server
output.print_info(f"Testing local MCP server: {name}")
output.print(f" Command: {server.command} {' '.join(server.args)}")
# Try to start the server and check if it responds
import subprocess
import time
try:
cmd = [server.command] + server.args
# Fix environment inheritance - merge with current env instead of replacing
env = None
if server.env:
import os
env = {**os.environ, **server.env}
# Start process
proc = subprocess.Popen(
cmd,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
env=env,
)
# Wait briefly and check if it's running
time.sleep(0.5)
if proc.poll() is None:
# Process is running
proc.terminate()
proc.wait(timeout=2)
if output.is_json_mode:
output.print_json({"name": name, "status": "ok", "message": "Server started successfully"})
else:
output.print_success(f"Server '{name}' started successfully")
else:
# Process exited
stderr = proc.stderr.read().decode() if proc.stderr else ""
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": stderr or "Server exited immediately"})
else:
output.print_error(f"Server exited immediately: {stderr}")
raise typer.Exit(1)
except FileNotFoundError:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": f"Command not found: {server.command}"})
else:
output.print_error(f"Command not found: {server.command}")
raise typer.Exit(1)
except Exception as e:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": str(e)})
else:
output.print_error(f"Test failed: {e}")
raise typer.Exit(1)
except FileNotFoundError:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": f"Command not found: {server.command}"})
else:
output.print_error(f"Command not found: {server.command}")
raise typer.Exit(1)
except Exception as e:
if output.is_json_mode:
output.print_json({"name": name, "status": "error", "message": str(e)})
else:
output.print_error(f"Test failed: {e}")
raise typer.Exit(1)
@app.command("sync")
@@ -248,15 +294,28 @@ def mcp_sync(
srv_config = config.mcp.servers[srv]
try:
# Import schema types for checking
from ..configuration.schema import MCPLocalConfig, MCPRemoteConfig
# Actually connect to MCP server and fetch tools
from praisonaiagents.mcp import MCP
# Build command string
cmd_parts = [srv_config.command] + srv_config.args
cmd_string = " ".join(cmd_parts)
# Create MCP instance with env vars
mcp = MCP(cmd_string, timeout=int(timeout), env=srv_config.env or {})
# Handle different server types
if isinstance(srv_config, MCPRemoteConfig):
# Remote server - connect via URL
if not srv_config.url:
output.print_error(f" Remote server {srv} has no URL configured")
failed_servers.append(srv)
continue
# Create MCP instance for remote server
mcp = MCP(srv_config.url, timeout=int(timeout))
else:
# Local server - use command
cmd_parts = [srv_config.command] + srv_config.args
cmd_string = " ".join(cmd_parts)
# Create MCP instance with env vars
mcp = MCP(cmd_string, timeout=int(timeout), env=srv_config.env or {})
# Get tools
tools = mcp.get_tools()
@@ -524,7 +583,11 @@ def mcp_auth(
name: str = typer.Argument(..., help="Server name to authenticate"),
timeout: float = typer.Option(300.0, "--timeout", "-t", help="Timeout for OAuth flow in seconds"),
):
"""Authenticate with an OAuth-enabled MCP server.
"""[EXPERIMENTAL] Authenticate with an OAuth-enabled MCP server.
WARNING: OAuth implementation is currently experimental and stores
placeholder tokens only. Real token exchange is not yet implemented.
Use headers/API key authentication for production use.
This command initiates the OAuth 2.1 authorization flow for a remote
MCP server. It will open your browser for authentication and wait
@@ -355,18 +355,10 @@ def _run_init(parsed) -> int:
return 0
def _run_ai(parsed) -> int:
"""Run the AI generation subcommand with ACP/LSP runtime support."""
import asyncio
async def _run_ai_with_runtime(parsed, config, slug):
"""Helper function to run AI generation with proper runtime lifecycle."""
from praisonai.standardise.ai_generator import AIGenerator
from praisonai.standardise.models import ArtifactType, FeatureSlug
config = _create_config(parsed)
slug = FeatureSlug.from_string(parsed.feature)
if not slug.is_valid:
print(f"Error: Invalid feature slug: {slug.validation_error}")
return 2
from praisonai.standardise.models import ArtifactType
# Start ACP/LSP runtime for context gathering
runtime = None
@@ -377,14 +369,30 @@ def _run_ai(parsed) -> int:
lsp=True,
acp=False # ACP not needed for generation
)
# Start runtime in background
asyncio.get_event_loop().run_until_complete(runtime.start())
# Start runtime
await runtime.start()
if runtime.lsp_ready:
print("🔧 LSP server ready for code intelligence")
except Exception:
# Runtime is optional, continue without it
pass
try:
return await _run_generation_logic(parsed, config, slug, runtime)
finally:
# Cleanup runtime
if runtime:
try:
await runtime.stop()
except Exception:
pass
async def _run_generation_logic(parsed, config, slug, runtime):
"""Main AI generation logic."""
from praisonai.standardise.ai_generator import AIGenerator
from praisonai.standardise.models import ArtifactType
generator = AIGenerator(
model=parsed.model,
sdk_root=config.sdk_root,
@@ -485,16 +493,25 @@ def _run_ai(parsed) -> int:
if not parsed.apply:
print("\nRun with --apply to create these files.")
# Cleanup runtime
if runtime:
try:
asyncio.get_event_loop().run_until_complete(runtime.stop())
except Exception:
pass
return 0
def _run_ai(parsed) -> int:
"""Run the AI generation subcommand with ACP/LSP runtime support."""
import asyncio
from praisonai.standardise.models import FeatureSlug
config = _create_config(parsed)
slug = FeatureSlug.from_string(parsed.feature)
if not slug.is_valid:
print(f"Error: Invalid feature slug: {slug.validation_error}")
return 2
# Run the async workflow with single event loop
return asyncio.run(_run_ai_with_runtime(parsed, config, slug))
def _run_checkpoint(parsed) -> int:
"""Run the checkpoint subcommand."""
from praisonai.standardise.undo_redo import UndoRedoManager
@@ -20,12 +20,30 @@ from ..registry import register_check
def _find_mcp_config() -> dict:
"""Find MCP configuration files."""
locations = [
Path.cwd() / ".praison" / "mcp.json",
Path.cwd() / ".praison" / "mcp" / "config.json",
Path.home() / ".praison" / "mcp.json",
Path.home() / ".config" / "praison" / "mcp.json",
]
# Use canonical MCP paths from praisonaiagents.paths
try:
from praisonaiagents.paths import get_mcp_dir, get_project_data_dir
canonical_mcp_dir = get_mcp_dir()
project_mcp_dir = get_project_data_dir() / "mcp"
locations = [
project_mcp_dir / "config.json",
canonical_mcp_dir / "config.json",
# Legacy paths for backward compatibility
Path.cwd() / ".praison" / "mcp.json",
Path.cwd() / ".praison" / "mcp" / "config.json",
Path.home() / ".praison" / "mcp.json",
Path.home() / ".config" / "praison" / "mcp.json",
]
except ImportError:
# Fallback to legacy paths if praisonaiagents not available
locations = [
Path.cwd() / ".praisonai" / "mcp" / "config.json",
Path.cwd() / ".praison" / "mcp.json",
Path.cwd() / ".praison" / "mcp" / "config.json",
Path.home() / ".praison" / "mcp.json",
Path.home() / ".config" / "praison" / "mcp.json",
]
for loc in locations:
if loc.exists():
@@ -70,7 +88,7 @@ def check_mcp_config(config: DoctorConfig) -> CheckResult:
category=CheckCategory.MCP,
status=CheckStatus.SKIP,
message="No MCP configuration found (optional)",
details="Create .praison/mcp.json to configure MCP servers",
details="Create ~/.praisonai/mcp/config.json to configure MCP servers",
)
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.6.48 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.6.52 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -5,6 +5,7 @@ Provides lazy-loaded integration with AutoGen v0.2, AutoGen v0.4, and AG2 framew
"""
import logging
import os
from typing import Dict, List, Any, Optional, Callable
from .base import BaseFrameworkAdapter
@@ -12,7 +13,7 @@ logger = logging.getLogger(__name__)
class AutoGenAdapter(BaseFrameworkAdapter):
"""Adapter for AutoGen v0.2 framework."""
"""Adapter for AutoGen v0.2 framework with version resolution."""
name = "autogen"
install_hint = 'pip install "praisonai[autogen]"' # v0.2 only
@@ -20,11 +21,39 @@ class AutoGenAdapter(BaseFrameworkAdapter):
def is_available(self) -> bool:
"""Check if AutoGen v0.2 is available for import."""
try:
import autogen # noqa: F401
return True
except ImportError:
return False
from .._framework_availability import is_available
return is_available("autogen")
def resolve(self) -> "BaseFrameworkAdapter":
"""Pick the concrete AutoGen adapter variant based on environment and availability."""
autogen_version = os.environ.get("AUTOGEN_VERSION", "auto").lower()
# Import the specific adapters
v4_adapter = AutoGenV4Adapter()
v2_adapter = self # Current instance is v0.2
if autogen_version == "v0.4" and v4_adapter.is_available():
logger.info("AutoGen version resolution: Using v0.4 (explicitly requested)")
return v4_adapter
elif autogen_version == "v0.2" and v2_adapter.is_available():
logger.info("AutoGen version resolution: Using v0.2 (explicitly requested)")
return v2_adapter
elif autogen_version == "auto":
# Auto-detect: prefer v0.4 if available, fallback to v0.2
if v4_adapter.is_available():
logger.info("AutoGen version resolution: Using v0.4 (auto-detected)")
return v4_adapter
else:
logger.info("AutoGen version resolution: Using v0.2 (auto-detected fallback)")
return v2_adapter
else:
# Invalid version or neither available, try both
if v4_adapter.is_available() and not v2_adapter.is_available():
logger.info("AutoGen version resolution: Using v0.4 (only available version)")
return v4_adapter
else:
logger.info("AutoGen version resolution: Using v0.2 (default fallback)")
return v2_adapter
def run(
self,
@@ -149,111 +178,144 @@ class AutoGenV4Adapter(BaseFrameworkAdapter):
# Availability already validated at CLI entry
logger.info("Starting AutoGen v0.4 execution...")
import asyncio
import os
import re
async def run_autogen_v4_async():
from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_agentchat.conditions import TextMentionTermination, MaxMessageTermination
# Create model client for v0.4
model_config = llm_config[0] if llm_config else {}
model_client = OpenAIChatCompletionClient(
model=model_config.get('model', 'gpt-4o-mini'),
api_key=model_config.get('api_key', os.environ.get("OPENAI_API_KEY")),
base_url=model_config.get('base_url', "https://api.openai.com/v1")
)
agents = []
combined_tasks = []
try:
# Create agents from config
for role, details in config['roles'].items():
# For AutoGen v0.4, ensure agent name is a valid Python identifier
agent_name = self._format_template(details['role'], topic=topic)
agent_name = self._sanitize_agent_name_for_autogen_v4(agent_name)
backstory = self._format_template(details['backstory'], topic=topic)
# Convert tools for v0.4 - simplified tool passing
agent_tools = []
for tool_name in details.get('tools', []):
if tools_dict and tool_name in tools_dict:
tool_instance = tools_dict[tool_name]
# For v0.4, we can pass the tool's run method directly if it's callable
if hasattr(tool_instance, 'run') and callable(tool_instance.run):
agent_tools.append(tool_instance.run)
# Create v0.4 AssistantAgent
assistant = AssistantAgent(
name=agent_name,
system_message=backstory + ". Must reply with 'TERMINATE' when the task is complete.",
model_client=model_client,
tools=agent_tools,
reflect_on_tool_use=True
)
agents.append(assistant)
# Collect all task descriptions for sequential execution
for task_name, task_details in details.get('tasks', {}).items():
description_filled = self._format_template(task_details['description'], topic=topic)
combined_tasks.append(description_filled)
if not agents:
return "No agents created from configuration"
# Create termination conditions
text_termination = TextMentionTermination("TERMINATE")
max_messages_termination = MaxMessageTermination(max_messages=20)
termination_condition = text_termination | max_messages_termination
# Create RoundRobinGroupChat for parallel/sequential execution
group_chat = RoundRobinGroupChat(
agents,
termination_condition=termination_condition,
max_turns=len(agents) * 3 # Allow multiple rounds
)
# Combine all tasks into a single task description
task_description = f"Topic: {topic}\n\nTasks to complete:\n" + "\n".join(
f"{i+1}. {task}" for i, task in enumerate(combined_tasks)
)
# Run the group chat
result = await group_chat.run(task=task_description)
# Extract the final message content
if result.messages:
final_message = result.messages[-1]
if hasattr(final_message, 'content'):
return f"### AutoGen v0.4 Output ###\n{final_message.content}"
else:
return f"### AutoGen v0.4 Output ###\n{str(final_message)}"
else:
return "### AutoGen v0.4 Output ###\nNo messages generated"
except Exception as e:
logger.error(f"Error in AutoGen v0.4 execution: {str(e)}")
return f"### AutoGen v0.4 Error ###\n{str(e)}"
finally:
# Close the model client
await model_client.close()
# Run the async function using safe bridge
# Sync entry now goes via the async method via run_sync bridge
try:
from .._async_bridge import run_sync
return run_sync(run_autogen_v4_async())
return run_sync(self.arun(
config, llm_config, topic,
tools_dict=tools_dict,
agent_callback=agent_callback,
task_callback=task_callback,
cli_config=cli_config
))
except ImportError:
# Fallback if _async_bridge is not available
return asyncio.run(run_autogen_v4_async())
# Fallback if _async_bridge is not available
import asyncio
return asyncio.run(self.arun(
config, llm_config, topic,
tools_dict=tools_dict,
agent_callback=agent_callback,
task_callback=task_callback,
cli_config=cli_config
))
except RuntimeError as e:
# Re-raise run_sync event loop errors so users get clear guidance
if "running event loop" in str(e):
raise
logger.error(f"AutoGen v0.4 runtime error: {str(e)}")
return f"### AutoGen v0.4 Runtime Error ###\n{str(e)}"
except Exception as e:
logger.error(f"Error running AutoGen v0.4: {str(e)}")
return f"### AutoGen v0.4 Error ###\n{str(e)}"
async def arun(
self,
config: Dict[str, Any],
llm_config: List[Dict],
topic: str,
*,
tools_dict: Optional[Dict[str, Any]] = None,
agent_callback: Optional[Callable] = None,
task_callback: Optional[Callable] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> str:
"""
Async-native AutoGen v0.4 execution without run_sync wrapper.
"""
# Availability already validated at CLI entry
logger.info("Starting AutoGen v0.4 async execution...")
from autogen_agentchat.agents import AssistantAgent
from autogen_ext.models.openai import OpenAIChatCompletionClient
from autogen_agentchat.teams import RoundRobinGroupChat
from autogen_agentchat.conditions import TextMentionTermination, MaxMessageTermination
# Create model client for v0.4
model_config = llm_config[0] if llm_config else {}
model_client = OpenAIChatCompletionClient(
model=model_config.get('model', 'gpt-4o-mini'),
api_key=model_config.get('api_key', os.environ.get("OPENAI_API_KEY")),
base_url=model_config.get('base_url', "https://api.openai.com/v1")
)
agents = []
combined_tasks = []
try:
# Create agents from config
for role, details in config['roles'].items():
# For AutoGen v0.4, ensure agent name is a valid Python identifier
agent_name = self._format_template(details['role'], topic=topic)
agent_name = self._sanitize_agent_name_for_autogen_v4(agent_name)
backstory = self._format_template(details['backstory'], topic=topic)
# Convert tools for v0.4 - simplified tool passing
agent_tools = []
for tool_name in details.get('tools', []):
if tools_dict and tool_name in tools_dict:
tool_instance = tools_dict[tool_name]
# For v0.4, we can pass the tool's run method directly if it's callable
if hasattr(tool_instance, 'run') and callable(tool_instance.run):
agent_tools.append(tool_instance.run)
# Create v0.4 AssistantAgent
assistant = AssistantAgent(
name=agent_name,
system_message=backstory + ". Must reply with 'TERMINATE' when the task is complete.",
model_client=model_client,
tools=agent_tools,
reflect_on_tool_use=True
)
agents.append(assistant)
# Collect all task descriptions for sequential execution
for task_name, task_details in details.get('tasks', {}).items():
description_filled = self._format_template(task_details['description'], topic=topic)
combined_tasks.append(description_filled)
if not agents:
return "No agents created from configuration"
# Create termination conditions
text_termination = TextMentionTermination("TERMINATE")
max_messages_termination = MaxMessageTermination(max_messages=20)
termination_condition = text_termination | max_messages_termination
# Create RoundRobinGroupChat for parallel/sequential execution
group_chat = RoundRobinGroupChat(
agents,
termination_condition=termination_condition,
max_turns=len(agents) * 3 # Allow multiple rounds
)
# Combine all tasks into a single task description
task_description = f"Topic: {topic}\n\nTasks to complete:\n" + "\n".join(
f"{i+1}. {task}" for i, task in enumerate(combined_tasks)
)
# Run the group chat
result = await group_chat.run(task=task_description)
# Extract the final message content
if result.messages:
final_message = result.messages[-1]
if hasattr(final_message, 'content'):
return f"### AutoGen v0.4 Output ###\n{final_message.content}"
else:
return f"### AutoGen v0.4 Output ###\n{str(final_message)}"
else:
return "### AutoGen v0.4 Output ###\nNo messages generated"
except Exception as e:
logger.error(f"Error in AutoGen v0.4 async execution: {str(e)}")
return f"### AutoGen v0.4 Error ###\n{str(e)}"
finally:
# Close the model client
await model_client.close()
def _sanitize_agent_name_for_autogen_v4(self, name):
"""
@@ -20,6 +20,22 @@ class FrameworkAdapter(Protocol):
"""Check if the framework is available for import."""
...
def resolve(self) -> "FrameworkAdapter":
"""Pick the concrete adapter variant (e.g. autogen v0.2 vs v0.4).
Returns:
The resolved adapter instance (self or a different adapter)
"""
...
def setup(self, *, framework_tag: str) -> None:
"""Framework-specific pre-run hooks (observability, sdk init, etc.).
Args:
framework_tag: Framework name for observability tagging
"""
...
def run(
self,
config: Dict[str, Any],
@@ -47,6 +63,34 @@ class FrameworkAdapter(Protocol):
Execution result as string
"""
...
async def arun(
self,
config: Dict[str, Any],
llm_config: List[Dict],
topic: str,
*,
tools_dict: Optional[Dict[str, Any]] = None,
agent_callback: Optional[Callable] = None,
task_callback: Optional[Callable] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> str:
"""
Async-native execution. Default = offload sync run() to a thread.
Args:
config: Framework configuration
llm_config: LLM configuration list
topic: Topic for the tasks
tools_dict: Available tools dictionary
agent_callback: Callback for agent events
task_callback: Callback for task events
cli_config: CLI configuration
Returns:
Execution result as string
"""
...
def cleanup(self) -> None:
"""Clean up any resources after execution."""
@@ -87,6 +131,37 @@ class BaseFrameworkAdapter:
logger.warning("Template formatting error: %s; returning original template", e)
return template
def resolve(self) -> "FrameworkAdapter":
"""Default implementation returns self."""
return self
def setup(self, *, framework_tag: str) -> None:
"""Default implementation does nothing."""
pass
async def arun(
self,
config: Dict[str, Any],
llm_config: List[Dict],
topic: str,
*,
tools_dict: Optional[Dict[str, Any]] = None,
agent_callback: Optional[Callable] = None,
task_callback: Optional[Callable] = None,
cli_config: Optional[Dict[str, Any]] = None,
) -> str:
"""
Safe default for sync-only adapters (crewai, autogen v0.2):
run the sync implementation in a worker thread, freeing the loop.
"""
import asyncio
return await asyncio.to_thread(
self.run, config, llm_config, topic,
tools_dict=tools_dict,
agent_callback=agent_callback,
task_callback=task_callback,
cli_config=cli_config
)
def cleanup(self) -> None:
"""Clean up resources - default implementation does nothing."""
pass
@@ -0,0 +1,417 @@
"""
Kanban task dispatcher for gateway integration.
This module implements the background dispatcher that claims ready kanban tasks
and spawns agent runs to execute them. It integrates with the PraisonAI gateway
system and follows Hermes patterns for worker management.
"""
import asyncio
import logging
import os
import subprocess
import time
from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__)
# Global dispatcher state
_dispatcher_running = False
_dispatcher_task = None
class KanbanDispatcher:
"""Kanban task dispatcher that claims ready tasks and spawns workers."""
def __init__(self, max_concurrent: int = 3, poll_interval: float = 5.0):
"""
Initialize kanban dispatcher.
Args:
max_concurrent: Maximum concurrent task executions
poll_interval: Seconds between task polling
"""
self.max_concurrent = max_concurrent
self.poll_interval = poll_interval
self.running_tasks: Dict[str, subprocess.Popen] = {}
self.worker_id = f"gateway_{os.getpid()}"
def _get_kanban_store(self):
"""Get kanban store instance."""
try:
from praisonai.kanban.sqlite_store import SQLiteKanbanStore
return SQLiteKanbanStore()
except ImportError:
logger.error("Kanban store not available")
return None
def _fire_hook_event(self, event_type: str, task_data: Dict[str, Any]):
"""Fire kanban hook events (when available)."""
try:
# Try to import and fire hook events from praisonaiagents
from praisonaiagents.hooks import fire_hook
fire_hook(event_type, task_data)
except (ImportError, AttributeError):
# Hook system not available, just log
logger.debug(f"Hook event {event_type}: {task_data.get('task_id', 'unknown')}")
async def dispatch_once(self) -> int:
"""
Single dispatch cycle: claim ready tasks and spawn workers.
Returns:
Number of tasks spawned in this cycle
"""
store = self._get_kanban_store()
if not store:
return 0
# Clean up completed processes first
self._cleanup_completed_tasks(store)
# Check how many slots we have available
available_slots = self.max_concurrent - len(self.running_tasks)
if available_slots <= 0:
logger.debug(f"No available slots (running: {len(self.running_tasks)})")
return 0
# Get ready tasks
ready_tasks = store.list_tasks({'status': 'ready'})
if not ready_tasks:
logger.debug("No ready tasks found")
return 0
spawned = 0
for task in ready_tasks[:available_slots]:
try:
# Try to claim the task atomically
claimed = store.claim_task(task.id, self.worker_id)
if not claimed:
logger.debug(f"Failed to claim task {task.id} (already claimed)")
continue
# Spawn worker process
success = await self._spawn_worker(task, store)
if success:
spawned += 1
# Fire claimed hook event
self._fire_hook_event('KANBAN_TASK_CLAIMED', {
'task_id': task.id,
'worker_id': self.worker_id,
'task': task.to_dict()
})
else:
# Failed to spawn, release claim
store.release_claim(task.id, self.worker_id)
except Exception as e:
logger.error(f"Error processing task {task.id}: {e}")
# Release claim on error
try:
store.release_claim(task.id, self.worker_id)
except:
pass
if spawned > 0:
logger.info(f"Spawned {spawned} kanban task workers")
return spawned
async def _spawn_worker(self, task: Any, store: Any) -> bool:
"""
Spawn a worker process for the task.
Args:
task: Task object to execute
store: Kanban store instance
Returns:
True if worker was spawned successfully
"""
try:
# Prepare environment variables
env = os.environ.copy()
env.update({
'PRAISONAI_KANBAN_TASK': task.id,
'PRAISONAI_KANBAN_BOARD': task.board,
'PRAISONAI_KANBAN_WORKER': self.worker_id,
})
# Build command to execute task
# This could be configurable, but for now use a simple approach
cmd = self._build_execution_command(task)
logger.info(f"Spawning worker for task {task.id}: {' '.join(cmd)}")
# Start process with output redirect to avoid deadlock
import tempfile
with tempfile.NamedTemporaryFile(mode='w+', delete=False, suffix='.log') as temp_log:
temp_log_path = temp_log.name
process = subprocess.Popen(
cmd,
env=env,
stdout=open(temp_log_path, 'w'),
stderr=subprocess.STDOUT,
text=True
)
# Store log path for later cleanup
if not hasattr(self, '_temp_logs'):
self._temp_logs = {}
self._temp_logs[task.id] = temp_log_path
# Track the running task
self.running_tasks[task.id] = process
return True
except Exception as e:
logger.error(f"Failed to spawn worker for task {task.id}: {e}")
return False
def _build_execution_command(self, task: Any) -> List[str]:
"""
Build the command to execute a kanban task.
This is a simplified implementation. In a full system, this would:
1. Parse task.body for specific execution instructions
2. Route to different executors based on task metadata
3. Support various agent configurations
Args:
task: Task to build command for
Returns:
Command as list of strings
"""
# Default: use praisonai agent with the task as a prompt
return [
'praisonai',
'agent',
'run',
'--name', f'kanban_worker_{task.id}',
'--prompt', f'Task: {task.title}\n\nDetails: {task.body}',
'--tools', 'kanban_heartbeat,kanban_comment',
'--output-mode', 'quiet'
]
def _cleanup_completed_tasks(self, store: Any):
"""
Clean up completed task processes and update their status.
Args:
store: Kanban store instance
"""
completed = []
for task_id, process in self.running_tasks.items():
poll_result = process.poll()
if poll_result is not None:
# Process completed
completed.append(task_id)
try:
# Get return code
return_code = process.returncode
# Read output from temp log file
stdout_data = ""
if hasattr(self, '_temp_logs') and task_id in self._temp_logs:
try:
with open(self._temp_logs[task_id], 'r') as f:
stdout_data = f.read()
except Exception as e:
logger.warning(f"Failed to read log for task {task_id}: {e}")
stdout_data = f"<log read error: {e}>"
else:
stdout_data = "<no log available>"
# Update task based on exit code
if return_code == 0:
# Success - mark as done
store.move_task(task_id, 'done')
store.add_comment(
task_id,
self.worker_id,
f"Task completed successfully\n\nOutput:\n{stdout_data[:500]}"
)
# Fire completion hook
task = store.get_task(task_id)
self._fire_hook_event('KANBAN_TASK_COMPLETED', {
'task_id': task_id,
'worker_id': self.worker_id,
'return_code': return_code,
'task': task.to_dict() if task else {}
})
logger.info(f"Task {task_id} completed successfully")
else:
# Failed - mark as blocked
store.move_task(task_id, 'blocked')
store.add_comment(
task_id,
self.worker_id,
f"Task failed with exit code {return_code}\n\nOutput:\n{stdout_data[:500]}"
)
# Fire failure hook
task = store.get_task(task_id)
self._fire_hook_event('KANBAN_TASK_FAILED', {
'task_id': task_id,
'worker_id': self.worker_id,
'return_code': return_code,
'error': stdout_data[:500],
'task': task.to_dict() if task else {}
})
logger.warning(f"Task {task_id} failed with code {return_code}")
except Exception as e:
logger.error(f"Error processing completed task {task_id}: {e}")
# Release claim as fallback
try:
store.release_claim(task_id, self.worker_id)
except:
pass
# Remove completed tasks from tracking and clean up temp logs
for task_id in completed:
del self.running_tasks[task_id]
# Clean up temporary log file
if hasattr(self, '_temp_logs') and task_id in self._temp_logs:
try:
import os
os.unlink(self._temp_logs[task_id])
except Exception as e:
logger.warning(f"Failed to cleanup temp log for task {task_id}: {e}")
finally:
del self._temp_logs[task_id]
async def run_forever(self):
"""
Run the dispatcher loop forever.
"""
logger.info(f"Starting kanban dispatcher (max_concurrent={self.max_concurrent})")
while True:
try:
await self.dispatch_once()
await asyncio.sleep(self.poll_interval)
except asyncio.CancelledError:
logger.info("Kanban dispatcher cancelled")
break
except Exception as e:
logger.error(f"Dispatcher error: {e}")
await asyncio.sleep(self.poll_interval)
# Cleanup on exit
await self._shutdown()
async def _shutdown(self):
"""Clean shutdown - wait for running tasks."""
if self.running_tasks:
logger.info(f"Waiting for {len(self.running_tasks)} running tasks to complete...")
# Give tasks 30 seconds to complete gracefully
timeout = 30
start_time = time.time()
while self.running_tasks and (time.time() - start_time) < timeout:
store = self._get_kanban_store()
if store:
self._cleanup_completed_tasks(store)
await asyncio.sleep(1)
# Force terminate remaining tasks
for task_id, process in self.running_tasks.items():
try:
logger.warning(f"Force terminating task {task_id}")
process.terminate()
process.wait(timeout=5)
except:
process.kill()
self.running_tasks.clear()
logger.info("Kanban dispatcher shutdown complete")
# Global dispatcher management functions
async def start_kanban_dispatcher(max_concurrent: int = 3, poll_interval: float = 5.0):
"""
Start the global kanban dispatcher.
Args:
max_concurrent: Maximum concurrent task executions
poll_interval: Seconds between polling cycles
"""
global _dispatcher_running, _dispatcher_task
if _dispatcher_running:
logger.warning("Kanban dispatcher already running")
return
# Check if disabled by environment
if not _is_dispatcher_enabled():
logger.info("Kanban dispatcher disabled by PRAISONAI_KANBAN_DISPATCH=0")
return
dispatcher = KanbanDispatcher(max_concurrent, poll_interval)
_dispatcher_task = asyncio.create_task(dispatcher.run_forever())
_dispatcher_running = True
logger.info("Kanban dispatcher started")
async def stop_kanban_dispatcher():
"""Stop the global kanban dispatcher."""
global _dispatcher_running, _dispatcher_task
if not _dispatcher_running or _dispatcher_task is None:
return
_dispatcher_task.cancel()
try:
await _dispatcher_task
except asyncio.CancelledError:
pass
_dispatcher_running = False
_dispatcher_task = None
logger.info("Kanban dispatcher stopped")
def is_dispatcher_running() -> bool:
"""Check if the kanban dispatcher is running."""
return _dispatcher_running
def _is_dispatcher_enabled() -> bool:
"""Check if kanban dispatcher is enabled via environment variable."""
return os.environ.get('PRAISONAI_KANBAN_DISPATCH', '1').strip().lower() not in ('0', 'false', 'no')
# Manual dispatch function for CLI/testing
async def dispatch_once(max_spawn: int = 3) -> int:
"""
Manually trigger a single dispatch cycle.
Args:
max_spawn: Maximum tasks to spawn in this cycle
Returns:
Number of tasks spawned
"""
dispatcher = KanbanDispatcher(max_spawn)
return await dispatcher.dispatch_once()
+20 -27
View File
@@ -925,13 +925,10 @@ class WebSocketGateway:
def _make_stream_relay(
self, client_id: str, session: "GatewaySession"
) -> Callable:
"""Create a StreamCallback that relays events to a WS client.
The callback is synchronous (called from the LLM streaming thread)
and uses asyncio.run_coroutine_threadsafe to push events into the
gateway's event loop for WS delivery.
"""
"""Create a StreamCallback that relays events to a WS client."""
gateway = self
# Capture the running loop while we are still on it.
loop = asyncio.get_running_loop()
def _relay(event) -> None:
try:
@@ -967,15 +964,13 @@ class WebSocketGateway:
target=client_id,
)
# Thread-safe async send
loop = asyncio.get_event_loop()
if loop.is_running():
asyncio.run_coroutine_threadsafe(
gateway._send_to_client(client_id, gw_event.to_dict()),
loop,
)
except Exception as e:
logger.debug(f"Stream relay error (non-fatal): {e}")
# No get_event_loop() in the threaded callback.
asyncio.run_coroutine_threadsafe(
gateway._send_to_client(client_id, gw_event.to_dict()),
loop,
)
except Exception:
logger.warning("Stream relay error (non-fatal)", exc_info=True)
return _relay
@@ -1653,6 +1648,7 @@ class WebSocketGateway:
allowed_users=list(_raw_allowed),
allowed_channels=list(_raw_channels),
mention_required=mention_required,
group_policy=group_policy,
auto_approve_tools=auto_approve_tools,
)
@@ -1881,19 +1877,16 @@ class WebSocketGateway:
gateway = self
async def handle_message(update: Update, context: Any):
if not update.message:
return
# Import the shared security pipeline from telegram.py
from praisonai.bots.telegram import process_inbound_telegram_message
# Use shared security pipeline for consistent enforcement
message = await process_inbound_telegram_message(update, bot)
if not message:
return # Message was dropped by security checks
message_text = None
if update.message.voice or update.message.audio:
message_text = await bot._transcribe_audio(update)
elif update.message.text:
message_text = update.message.text
if not message_text:
return
user_id = str(update.message.from_user.id) if update.message.from_user else "unknown"
user_id = message.sender.user_id if message.sender else "unknown"
message_text = message.content
# Determine routing context
chat_type = update.message.chat.type if update.message.chat else "private"
@@ -0,0 +1,113 @@
"""Wire kanban store and jobs components for PraisonAIUI integration."""
from __future__ import annotations
from typing import Any, Optional
def get_kanban_store_factory():
"""Return kanban store factory for PraisonAIUI injection."""
try:
from praisonai.kanban.sqlite_store import SQLiteKanbanStore
def _kanban_store_factory():
"""Create SQLite kanban store instance."""
# Reads PRAISONAI_KANBAN_BOARD env var for board selection
return SQLiteKanbanStore()
return _kanban_store_factory
except ImportError:
return None
def get_jobs_store():
"""Return jobs store for PraisonAIUI injection."""
try:
from praisonai.jobs.store import get_store
return get_store
except ImportError:
return None
def get_jobs_executor():
"""Return jobs executor for PraisonAIUI injection."""
try:
from praisonai.jobs.executor import get_executor
return get_executor
except ImportError:
return None
def register_kanban_backends():
"""Register kanban-related backends with PraisonAIUI."""
import logging
log = logging.getLogger(__name__)
try:
import praisonaiui.backends as backends
# Kanban store factory
kanban_factory = get_kanban_store_factory()
if kanban_factory is not None:
backends.set_backend("kanban_store", kanban_factory)
log.debug("kanban_store backend registered")
# Jobs store
jobs_store = get_jobs_store()
if jobs_store is not None:
backends.set_backend("jobs_store", jobs_store)
log.debug("jobs_store backend registered")
# Jobs executor
jobs_executor = get_jobs_executor()
if jobs_executor is not None:
backends.set_backend("jobs_executor", jobs_executor)
log.debug("jobs_executor backend registered")
# Register kanban dispatcher startup handler
register_kanban_startup_handler()
return True
except Exception as exc:
log.debug("kanban backend registration failed: %s", exc)
return False
def register_kanban_startup_handler():
"""Register kanban dispatcher startup handler with the ASGI app."""
import logging
log = logging.getLogger(__name__)
try:
import praisonaiui.backends as backends
from praisonai.gateway.kanban_dispatcher import start_kanban_dispatcher, is_dispatcher_running
async def _startup_handler():
"""Startup handler for kanban dispatcher."""
try:
if not is_dispatcher_running():
await start_kanban_dispatcher()
log.info("kanban dispatcher started")
except Exception as exc:
log.warning("failed to start kanban dispatcher: %s", exc)
# Register startup handler if the backend supports it
if hasattr(backends, 'register_startup_handler'):
backends.register_startup_handler(_startup_handler)
log.debug("kanban dispatcher startup handler registered")
else:
# Fallback: try immediate start if event loop is running
try:
import asyncio
loop = asyncio.get_running_loop()
if loop and not is_dispatcher_running():
asyncio.create_task(start_kanban_dispatcher())
log.debug("kanban dispatcher started via task")
except RuntimeError:
log.debug("kanban dispatcher deferred (no running event loop)")
except Exception as exc:
log.debug("kanban startup handler registration failed: %s", exc)
@@ -165,7 +165,12 @@ def setup_bridges() -> None:
backends.set_backend("approvals_pending", list_pending_approvals)
backends.set_backend("approvals_policies", get_approval_policies)
except ImportError as exc:
# Kanban and jobs backends
from praisonai.integration.bridges.kanban_bridge import register_kanban_backends
register_kanban_backends()
except Exception as exc:
log.debug("aiui backend injection failed: %s", exc)
except Exception as exc:
log.warning("aiui backend injection failed: %s", exc)
@@ -56,73 +56,7 @@ __all__ = [
]
def __getattr__(name):
"""Lazy load integrations to minimize import overhead."""
if name == 'BaseCLIIntegration':
from .base import BaseCLIIntegration
return BaseCLIIntegration
elif name == 'CLIExecutionError':
from .base import CLIExecutionError
return CLIExecutionError
elif name == 'ClaudeCodeIntegration':
from .claude_code import ClaudeCodeIntegration
return ClaudeCodeIntegration
elif name == 'GeminiCLIIntegration':
from .gemini_cli import GeminiCLIIntegration
return GeminiCLIIntegration
elif name == 'CodexCLIIntegration':
from .codex_cli import CodexCLIIntegration
return CodexCLIIntegration
elif name == 'CursorCLIIntegration':
from .cursor_cli import CursorCLIIntegration
return CursorCLIIntegration
elif name in ('ManagedAgent', 'ManagedAgentIntegration'):
from .managed_agents import ManagedAgent
return ManagedAgent
elif name == 'AnthropicManagedAgent':
from .managed_agents import AnthropicManagedAgent
return AnthropicManagedAgent
elif name == 'LocalManagedAgent':
from .managed_local import LocalManagedAgent
return LocalManagedAgent
elif name == 'LocalManagedConfig':
from .managed_local import LocalManagedConfig
return LocalManagedConfig
elif name == 'SandboxedAgent':
from .sandboxed_agent import SandboxedAgent
return SandboxedAgent
elif name == 'SandboxedAgentConfig':
from .sandboxed_agent import SandboxedAgentConfig
return SandboxedAgentConfig
elif name in ('ManagedConfig', 'ManagedBackendConfig'):
from .managed_agents import ManagedConfig
return ManagedConfig
elif name == 'get_available_integrations':
from .base import get_available_integrations
return get_available_integrations
elif name == 'ExternalAgentRegistry':
from .registry import ExternalAgentRegistry
return ExternalAgentRegistry
elif name == 'get_registry':
from .registry import get_registry
return get_registry
elif name == 'register_integration':
from .registry import register_integration
return register_integration
elif name == 'create_integration':
from .registry import create_integration
return create_integration
# New canonical agent backends
elif name == 'HostedAgent':
from .hosted_agent import HostedAgent
return HostedAgent
elif name == 'HostedAgentConfig':
from .hosted_agent import HostedAgentConfig
return HostedAgentConfig
elif name == 'LocalAgent':
from .local_agent import LocalAgent
return LocalAgent
elif name == 'LocalAgentConfig':
from .local_agent import LocalAgentConfig
return LocalAgentConfig
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
def __getattr__(name: str):
"""Lazy load integrations using unified registry."""
from ._unified_registry import INTEGRATIONS_REGISTRY
return INTEGRATIONS_REGISTRY.get_by_attr(__name__, name)
@@ -0,0 +1,193 @@
"""
Unified registry for all integration types in PraisonAI.
This replaces the manual __getattr__ ladder with a data-driven registry approach,
supporting CLI tools, managed agents, framework adapters, and RAG components.
"""
from typing import Any, Callable, Dict
from .._registry import PluginRegistry
class IntegrationRegistry(PluginRegistry[Any]):
"""Registry for all integration types with lazy loading."""
def __init__(self):
super().__init__(entry_point_group="praisonai.integrations")
self._loaders: Dict[str, Callable[[], Any]] = {}
self._register_builtin_integrations()
def register_lazy(
self,
name: str,
loader: Callable[[], Any],
*,
aliases: list[str] | None = None,
) -> None:
"""Register a lazily loaded integration."""
with self._lock:
canonical_name = name.lower()
self._loaders[canonical_name] = loader
if aliases:
for alias in aliases:
self._aliases[alias.lower()] = canonical_name
def resolve(self, name: str) -> Any:
"""Resolve integration name with lazy loader materialization."""
with self._lock:
normalized_name = name.lower()
canonical_name = self._aliases.get(normalized_name, normalized_name)
item = self._items.get(canonical_name)
if item is None:
loader = self._loaders.get(canonical_name)
if loader is not None:
item = loader()
self._items[canonical_name] = item
if item is not None:
return item
available_snapshot = sorted(
set(self._items.keys()) | set(self._loaders.keys()) | set(self._aliases.keys())
)
raise ValueError(
f"Unknown {self._entry_point_group} plugin: {name!r}. "
f"Available: {available_snapshot}"
)
def _register_builtin_integrations(self):
"""Register all built-in integrations with lazy loading."""
# CLI Tools
def _claude_code():
from .claude_code import ClaudeCodeIntegration
return ClaudeCodeIntegration
def _gemini_cli():
from .gemini_cli import GeminiCLIIntegration
return GeminiCLIIntegration
def _codex_cli():
from .codex_cli import CodexCLIIntegration
return CodexCLIIntegration
def _cursor_cli():
from .cursor_cli import CursorCLIIntegration
return CursorCLIIntegration
# Base classes
def _base_cli_integration():
from .base import BaseCLIIntegration
return BaseCLIIntegration
def _cli_execution_error():
from .base import CLIExecutionError
return CLIExecutionError
# Managed agents
def _managed_agent():
from .managed_agents import ManagedAgent
return ManagedAgent
def _anthropic_managed_agent():
from .managed_agents import AnthropicManagedAgent
return AnthropicManagedAgent
def _managed_config():
from .managed_agents import ManagedConfig
return ManagedConfig
# Local agents
def _local_managed_agent():
from .managed_local import LocalManagedAgent
return LocalManagedAgent
def _local_managed_config():
from .managed_local import LocalManagedConfig
return LocalManagedConfig
# Sandboxed agents
def _sandboxed_agent():
from .sandboxed_agent import SandboxedAgent
return SandboxedAgent
def _sandboxed_agent_config():
from .sandboxed_agent import SandboxedAgentConfig
return SandboxedAgentConfig
# Canonical agent backends
def _hosted_agent():
from .hosted_agent import HostedAgent
return HostedAgent
def _hosted_agent_config():
from .hosted_agent import HostedAgentConfig
return HostedAgentConfig
def _local_agent():
from .local_agent import LocalAgent
return LocalAgent
def _local_agent_config():
from .local_agent import LocalAgentConfig
return LocalAgentConfig
# Registry functions
def _get_available_integrations():
from .base import get_available_integrations
return get_available_integrations
def _external_agent_registry():
from .registry import ExternalAgentRegistry
return ExternalAgentRegistry
def _get_registry():
from .registry import get_registry
return get_registry
def _register_integration():
from .registry import register_integration
return register_integration
def _create_integration():
from .registry import create_integration
return create_integration
# Register all with appropriate aliases
self.register_lazy("BaseCLIIntegration", _base_cli_integration)
self.register_lazy("CLIExecutionError", _cli_execution_error)
self.register_lazy("ClaudeCodeIntegration", _claude_code)
self.register_lazy("GeminiCLIIntegration", _gemini_cli)
self.register_lazy("CodexCLIIntegration", _codex_cli)
self.register_lazy("CursorCLIIntegration", _cursor_cli)
self.register_lazy("ManagedAgent", _managed_agent,
aliases=["ManagedAgentIntegration"])
self.register_lazy("AnthropicManagedAgent", _anthropic_managed_agent)
self.register_lazy("ManagedConfig", _managed_config,
aliases=["ManagedBackendConfig"])
self.register_lazy("LocalManagedAgent", _local_managed_agent)
self.register_lazy("LocalManagedConfig", _local_managed_config)
self.register_lazy("SandboxedAgent", _sandboxed_agent)
self.register_lazy("SandboxedAgentConfig", _sandboxed_agent_config)
self.register_lazy("HostedAgent", _hosted_agent)
self.register_lazy("HostedAgentConfig", _hosted_agent_config)
self.register_lazy("LocalAgent", _local_agent)
self.register_lazy("LocalAgentConfig", _local_agent_config)
self.register_lazy("get_available_integrations", _get_available_integrations)
self.register_lazy("ExternalAgentRegistry", _external_agent_registry)
self.register_lazy("get_registry", _get_registry)
self.register_lazy("register_integration", _register_integration)
self.register_lazy("create_integration", _create_integration)
# Global registry instance
INTEGRATIONS_REGISTRY = IntegrationRegistry()
@@ -272,6 +272,12 @@ class DbSessionAdapter:
self.metadata = meta
return _SessionProxy(session_id, self.get_metadata(session_id))
def update_session_metadata(self, session_id: str, metadata: Dict[str, Any]) -> None:
"""Update session metadata (alias for set_metadata)."""
self.set_metadata(session_id, metadata)
def update_session_metadata(self, session_id: str, **fields: Any) -> None:
"""Update session metadata fields (DefaultSessionStore-compatible)."""
if not fields:
return
if session_id not in self._metadata:
self._metadata[session_id] = {}
for key, value in fields.items():
if value is not None:
self._metadata[session_id][key] = value
@@ -599,9 +599,11 @@ class LocalManagedAgent:
subprocess.run(cmd, check=True, capture_output=True, timeout=120)
logger.info("[local_managed] host pip install completed")
except subprocess.CalledProcessError as e:
logger.warning("[local_managed] pip install failed: %s", e.stderr)
except subprocess.TimeoutExpired:
logger.warning("[local_managed] pip install timed out")
raise RuntimeError(
f"pip install failed for {pip_pkgs}: {e.stderr.decode(errors='replace')}"
) from e
except subprocess.TimeoutExpired as e:
raise RuntimeError(f"pip install timed out after 120s for {pip_pkgs}") from e
else:
# Sandbox installation via compute provider
self._install_packages_in_compute(pip_pkgs)
@@ -629,10 +631,14 @@ class LocalManagedAgent:
if result.get("exit_code", 0) == 0:
logger.info("[local_managed] compute pip install completed")
else:
logger.warning("[local_managed] compute pip install failed: %s", result.get("stderr", ""))
raise RuntimeError(
f"pip install failed in compute for {pip_pkgs}: {result.get('stderr', '')}"
)
except Exception as e:
logger.warning("[local_managed] compute pip install error: %s", e)
if "pip install failed in compute" in str(e):
raise # Re-raise RuntimeError from above
raise RuntimeError(f"pip install error in compute for {pip_pkgs}: {e}") from e
def _ensure_agent(self) -> Any:
"""Create or return the inner PraisonAI Agent."""
@@ -775,20 +781,30 @@ class LocalManagedAgent:
q: queue.Queue[Optional[str]] = queue.Queue()
def _producer():
full = ""
try:
agent = self._ensure_agent()
self._ensure_session()
self._persist_message("user", prompt)
gen = agent.chat(prompt, stream=True)
if hasattr(gen, '__iter__'):
for chunk in gen:
if chunk:
q.put(str(chunk))
text = str(chunk)
q.put(text)
full += text
else:
if gen:
q.put(str(gen))
text = str(gen)
q.put(text)
full = text
except Exception as e:
logger.error("[local_managed] stream error: %s", e)
finally:
if full:
self._persist_message("assistant", full)
self._sync_usage()
self._persist_state()
q.put(None)
thread = threading.Thread(target=_producer, daemon=True)
+3 -12
View File
@@ -283,11 +283,7 @@ class JobExecutor:
await self._notify_progress(job)
# Execute the recipe
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: execute_resolved_recipe(resolved)
)
result = await asyncio.to_thread(execute_resolved_recipe, resolved)
# Update progress
job.update_progress(percentage=90.0, step="Finalizing")
@@ -323,11 +319,7 @@ class JobExecutor:
await self._notify_progress(job)
# Run the agent
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(
None,
lambda: agent.start(job.prompt)
)
result = await asyncio.to_thread(agent.start, job.prompt)
# Update progress
job.update_progress(percentage=90.0, step="Finalizing")
@@ -360,8 +352,7 @@ class JobExecutor:
await self._notify_progress(job)
# Run in executor (blocking call)
loop = asyncio.get_event_loop()
result = await loop.run_in_executor(None, praisonai.run)
result = await asyncio.to_thread(praisonai.run)
# Update progress
job.update_progress(percentage=90.0, step="Finalizing")
+13 -6
View File
@@ -6,6 +6,7 @@ Provides FastAPI application setup and server startup.
import logging
import os
import threading
from typing import Optional
from contextlib import asynccontextmanager
@@ -21,13 +22,17 @@ logger = logging.getLogger(__name__)
# Global instances (for single-process deployment)
_store: Optional[JobStore] = None
_executor: Optional[JobExecutor] = None
_store_lock = threading.Lock()
_executor_lock = threading.Lock()
def get_store() -> JobStore:
"""Get or create the job store."""
global _store
if _store is None:
_store = InMemoryJobStore(max_jobs=1000)
with _store_lock:
if _store is None:
_store = InMemoryJobStore(max_jobs=1000)
return _store
@@ -35,11 +40,13 @@ def get_executor() -> JobExecutor:
"""Get or create the job executor."""
global _executor
if _executor is None:
_executor = JobExecutor(
store=get_store(),
max_concurrent=int(os.environ.get("PRAISONAI_MAX_CONCURRENT_JOBS", "10")),
default_timeout=int(os.environ.get("PRAISONAI_JOB_TIMEOUT", "3600"))
)
with _executor_lock:
if _executor is None:
_executor = JobExecutor(
store=get_store(),
max_concurrent=int(os.environ.get("PRAISONAI_MAX_CONCURRENT_JOBS", "10")),
default_timeout=int(os.environ.get("PRAISONAI_JOB_TIMEOUT", "3600"))
)
return _executor
@@ -0,0 +1,12 @@
# Kanban module for PraisonAI - persistent task management
from .sqlite_store import SQLiteKanbanStore
from .models import TaskStatus, Task, TaskEvent
from .paths import get_kanban_db_path
__all__ = [
'SQLiteKanbanStore',
'TaskStatus',
'Task',
'TaskEvent',
'get_kanban_db_path'
]
+198
View File
@@ -0,0 +1,198 @@
"""Task dataclasses and models for kanban system."""
import json
from dataclasses import dataclass, asdict
from datetime import datetime
from enum import Enum
from typing import Optional, Dict, Any, List
class TaskStatus(str, Enum):
"""Task status enum matching Hermes v1."""
TRIAGE = "triage"
TODO = "todo"
SCHEDULED = "scheduled"
READY = "ready"
RUNNING = "running"
BLOCKED = "blocked"
REVIEW = "review"
DONE = "done"
ARCHIVED = "archived"
@dataclass
class Task:
"""Task model for kanban system."""
id: str
title: str
body: str = ""
status: TaskStatus = TaskStatus.TODO
assignee: str = ""
priority: int = 0 # Higher number = higher priority
tenant: str = "default"
board: str = "default"
workspace_kind: str = "default"
claim_lock: Optional[str] = None
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
metadata: Optional[Dict[str, Any]] = None
def __post_init__(self):
from datetime import timezone
if self.created_at is None:
self.created_at = datetime.now(timezone.utc)
if self.updated_at is None:
self.updated_at = datetime.now(timezone.utc)
if self.metadata is None:
self.metadata = {}
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for API/JSON serialization."""
result = asdict(self)
result['created_at'] = self.created_at.isoformat() if self.created_at else None
result['updated_at'] = self.updated_at.isoformat() if self.updated_at else None
result['status'] = self.status.value
result['metadata'] = json.dumps(self.metadata) if self.metadata else None
return result
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> 'Task':
"""Create from dictionary."""
# Remove fields that don't belong to Task model
filtered_data = data.copy()
filtered_data.pop('version', None) # Remove version field used for optimistic locking
# Handle datetime fields
if 'created_at' in filtered_data and filtered_data['created_at']:
filtered_data['created_at'] = datetime.fromisoformat(filtered_data['created_at'].replace('Z', '+00:00'))
if 'updated_at' in filtered_data and filtered_data['updated_at']:
filtered_data['updated_at'] = datetime.fromisoformat(filtered_data['updated_at'].replace('Z', '+00:00'))
# Handle status enum
if 'status' in filtered_data:
filtered_data['status'] = TaskStatus(filtered_data['status'])
# Handle metadata JSON
if 'metadata' in filtered_data and filtered_data['metadata']:
try:
filtered_data['metadata'] = json.loads(filtered_data['metadata']) if isinstance(filtered_data['metadata'], str) else filtered_data['metadata']
except (json.JSONDecodeError, TypeError):
filtered_data['metadata'] = {}
return cls(**filtered_data)
@dataclass
class TaskLink:
"""Task dependency link (DAG)."""
parent_id: str
child_id: str
created_at: Optional[datetime] = None
def __post_init__(self):
if self.created_at is None:
self.created_at = datetime.utcnow()
@dataclass
class TaskComment:
"""Task comment model."""
id: str
task_id: str
author: str
text: str
created_at: Optional[datetime] = None
def __post_init__(self):
if self.created_at is None:
self.created_at = datetime.utcnow()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
'id': self.id,
'task_id': self.task_id,
'author': self.author,
'text': self.text,
'created_at': self.created_at.isoformat() if self.created_at else None
}
@dataclass
class TaskEvent:
"""Task audit event for SSE/WebSocket."""
id: str
task_id: str
event_type: str # created, updated, moved, deleted, commented
data: Dict[str, Any]
created_at: Optional[datetime] = None
def __post_init__(self):
if self.created_at is None:
self.created_at = datetime.utcnow()
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary."""
return {
'id': self.id,
'task_id': self.task_id,
'event_type': self.event_type,
'data': self.data,
'created_at': self.created_at.isoformat() if self.created_at else None
}
# Protocol placeholder - will import from praisonaiagents when available
class KanbanStoreProtocol:
"""Minimal kanban store protocol - matches praisonaiagents when available."""
def create_task(self, task_data: Dict[str, Any]) -> Task:
"""Create a new task."""
...
def get_task(self, task_id: str) -> Optional[Task]:
"""Get task by ID."""
...
def update_task(self, task_id: str, updates: Dict[str, Any]) -> Task:
"""Update task."""
...
def delete_task(self, task_id: str) -> bool:
"""Delete task."""
...
def list_tasks(self, filters: Optional[Dict[str, Any]] = None) -> List[Task]:
"""List tasks with optional filters."""
...
def move_task(self, task_id: str, status: str) -> Task:
"""Move task to new status."""
...
def get_board(self, board: str = "default") -> Dict[str, Any]:
"""Get board layout for UI."""
...
def bulk_update(self, operations: List[Dict[str, Any]]) -> List[Task]:
"""Bulk update operations."""
...
def add_comment(self, task_id: str, author: str, text: str) -> TaskComment:
"""Add comment to task."""
...
def get_comments(self, task_id: str) -> List[TaskComment]:
"""Get task comments."""
...
def add_link(self, parent_id: str, child_id: str) -> TaskLink:
"""Create task dependency."""
...
def remove_link(self, parent_id: str, child_id: str) -> bool:
"""Remove task dependency."""
...
def list_events(self, since: Optional[datetime] = None) -> List[TaskEvent]:
"""List events for SSE."""
...
+71
View File
@@ -0,0 +1,71 @@
"""Path utilities for kanban data storage."""
import os
from pathlib import Path
from typing import Optional
def get_praisonai_home() -> Path:
"""Get PraisonAI home directory."""
return Path.home() / ".praisonai"
def get_kanban_db_path(board: Optional[str] = None) -> Path:
"""Get kanban database path.
Args:
board: Board slug for multi-board support. None for default board.
Returns:
Path to SQLite database file.
"""
home = get_praisonai_home()
# Read from environment variable for override
if board is None:
board = os.environ.get('PRAISONAI_KANBAN_BOARD')
if not board:
# Try to read from persisted config file
try:
import json
config_file = home / "kanban_config.json"
if config_file.exists():
with open(config_file) as f:
config = json.load(f)
board = config.get('active_board', 'default')
else:
board = 'default'
except Exception:
board = 'default'
# Legacy override for single DB file
if db_override := os.environ.get('PRAISONAI_KANBAN_DB'):
return Path(db_override)
# Default board at root
if board == 'default':
return home / "kanban.db"
# Multi-board at boards/<slug>/
boards_dir = home / "kanban" / "boards" / board
boards_dir.mkdir(parents=True, exist_ok=True)
return boards_dir / "kanban.db"
def get_kanban_boards_dir() -> Path:
"""Get kanban boards directory."""
return get_praisonai_home() / "kanban" / "boards"
def list_available_boards() -> list[str]:
"""List all available board slugs."""
boards_dir = get_kanban_boards_dir()
if not boards_dir.exists():
return ["default"]
boards = ["default"] # Always include default
for board_dir in boards_dir.iterdir():
if board_dir.is_dir() and (board_dir / "kanban.db").exists():
boards.append(board_dir.name)
return sorted(set(boards))
@@ -0,0 +1,565 @@
"""SQLite kanban store implementation."""
import sqlite3
import json
import uuid
from contextlib import contextmanager
from datetime import datetime
from typing import Optional, Dict, Any, List
from pathlib import Path
from .models import Task, TaskStatus, TaskComment, TaskLink, TaskEvent, KanbanStoreProtocol
from .paths import get_kanban_db_path
class SQLiteKanbanStore:
"""SQLite implementation of kanban store with WAL mode and CAS operations."""
def __init__(self, board: Optional[str] = None):
"""Initialize store.
Args:
board: Board slug. Uses PRAISONAI_KANBAN_BOARD env var or 'default'.
"""
self.board = board
self.db_path = get_kanban_db_path(board)
# Ensure db_path is a Path object
if not isinstance(self.db_path, Path):
self.db_path = Path(self.db_path)
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._init_db()
def _init_db(self):
"""Initialize database with schema."""
with self._get_connection() as conn:
# Enable WAL mode for better concurrency
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA foreign_keys=ON")
# Tasks table
conn.execute("""
CREATE TABLE IF NOT EXISTS tasks (
id TEXT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT DEFAULT '',
status TEXT DEFAULT 'todo',
assignee TEXT DEFAULT '',
priority INTEGER DEFAULT 0,
tenant TEXT DEFAULT 'default',
board TEXT DEFAULT 'default',
workspace_kind TEXT DEFAULT 'default',
claim_lock TEXT,
metadata TEXT, -- JSON blob
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
version INTEGER DEFAULT 1 -- For optimistic locking
)
""")
# Task links table (DAG)
conn.execute("""
CREATE TABLE IF NOT EXISTS task_links (
parent_id TEXT NOT NULL,
child_id TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (parent_id, child_id),
FOREIGN KEY (parent_id) REFERENCES tasks(id) ON DELETE CASCADE,
FOREIGN KEY (child_id) REFERENCES tasks(id) ON DELETE CASCADE
)
""")
# Task comments table
conn.execute("""
CREATE TABLE IF NOT EXISTS task_comments (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
author TEXT NOT NULL,
text TEXT NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE
)
""")
# Task events table (audit log)
conn.execute("""
CREATE TABLE IF NOT EXISTS task_events (
id TEXT PRIMARY KEY,
task_id TEXT NOT NULL,
event_type TEXT NOT NULL,
data TEXT NOT NULL, -- JSON blob
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)
""")
# Indexes for performance
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_assignee ON tasks(assignee)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_board ON tasks(board)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_tasks_priority ON tasks(priority DESC)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_task_comments_task ON task_comments(task_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_task_events_task ON task_events(task_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_task_events_created ON task_events(created_at)")
@contextmanager
def _get_connection(self):
"""Get database connection with proper cleanup."""
conn = sqlite3.connect(self.db_path, timeout=30.0)
conn.row_factory = sqlite3.Row
# Enable foreign key constraints for this connection
conn.execute("PRAGMA foreign_keys=ON")
try:
yield conn
conn.commit()
except Exception:
conn.rollback()
raise
finally:
conn.close()
def _generate_id(self) -> str:
"""Generate unique task ID."""
return f"task_{uuid.uuid4().hex[:12]}"
def _log_event(self, conn: sqlite3.Connection, task_id: str, event_type: str, data: Dict[str, Any]):
"""Log audit event."""
event_id = f"event_{uuid.uuid4().hex[:12]}"
conn.execute(
"INSERT INTO task_events (id, task_id, event_type, data) VALUES (?, ?, ?, ?)",
(event_id, task_id, event_type, json.dumps(data))
)
def create_task(self, task_data: Dict[str, Any]) -> Task:
"""Create a new task."""
task_id = task_data.get('id', self._generate_id())
now = datetime.utcnow()
task = Task(
id=task_id,
title=task_data['title'],
body=task_data.get('body', ''),
status=TaskStatus(task_data.get('status', 'todo')),
assignee=task_data.get('assignee', ''),
priority=task_data.get('priority', 0),
tenant=task_data.get('tenant', 'default'),
board=task_data.get('board', self.board or 'default'),
workspace_kind=task_data.get('workspace_kind', 'default'),
metadata=task_data.get('metadata', {}),
created_at=now,
updated_at=now
)
with self._get_connection() as conn:
conn.execute("""
INSERT INTO tasks (
id, title, body, status, assignee, priority,
tenant, board, workspace_kind, metadata,
created_at, updated_at
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
""", (
task.id, task.title, task.body, task.status.value,
task.assignee, task.priority, task.tenant, task.board,
task.workspace_kind, json.dumps(task.metadata),
task.created_at.isoformat(), task.updated_at.isoformat()
))
self._log_event(conn, task_id, 'created', task.to_dict())
return task
def get_task(self, task_id: str) -> Optional[Task]:
"""Get task by ID."""
with self._get_connection() as conn:
cursor = conn.execute("SELECT * FROM tasks WHERE id = ?", (task_id,))
row = cursor.fetchone()
if not row:
return None
return Task.from_dict(dict(row))
def update_task(self, task_id: str, updates: Dict[str, Any]) -> Task:
"""Update task with optimistic locking."""
with self._get_connection() as conn:
# Get current version for CAS
cursor = conn.execute("SELECT version FROM tasks WHERE id = ?", (task_id,))
row = cursor.fetchone()
if not row:
raise ValueError(f"Task {task_id} not found")
current_version = row['version']
new_version = current_version + 1
now = datetime.utcnow()
# Build update query dynamically
update_fields = []
values = []
for field, value in updates.items():
if field in ['status', 'title', 'body', 'assignee', 'priority', 'claim_lock', 'metadata']:
if field == 'status' and isinstance(value, str):
value = TaskStatus(value).value
elif field == 'metadata':
value = json.dumps(value) if value else None
update_fields.append(f"{field} = ?")
values.append(value)
if not update_fields:
# No valid updates, return current task
return self.get_task(task_id)
update_fields.append("updated_at = ?")
update_fields.append("version = ?")
values.extend([now.isoformat(), new_version, task_id, current_version])
# CAS update with version check
result = conn.execute(f"""
UPDATE tasks
SET {', '.join(update_fields)}
WHERE id = ? AND version = ?
""", values)
if result.rowcount == 0:
raise ValueError(f"Task {task_id} was modified by another process")
# Log event
self._log_event(conn, task_id, 'updated', updates)
return self.get_task(task_id)
def delete_task(self, task_id: str) -> bool:
"""Delete task."""
with self._get_connection() as conn:
# Log before deletion
self._log_event(conn, task_id, 'deleted', {'task_id': task_id})
result = conn.execute("DELETE FROM tasks WHERE id = ?", (task_id,))
return result.rowcount > 0
def list_tasks(self, filters: Optional[Dict[str, Any]] = None) -> List[Task]:
"""List tasks with optional filters."""
query = "SELECT * FROM tasks WHERE 1=1"
values = []
if filters:
if 'status' in filters:
if isinstance(filters['status'], list):
placeholders = ','.join(['?'] * len(filters['status']))
query += f" AND status IN ({placeholders})"
values.extend(filters['status'])
else:
query += " AND status = ?"
values.append(filters['status'])
if 'assignee' in filters:
query += " AND assignee = ?"
values.append(filters['assignee'])
if 'board' in filters:
query += " AND board = ?"
values.append(filters['board'])
if 'tenant' in filters:
query += " AND tenant = ?"
values.append(filters['tenant'])
query += " ORDER BY priority DESC, created_at ASC"
with self._get_connection() as conn:
cursor = conn.execute(query, values)
return [Task.from_dict(dict(row)) for row in cursor.fetchall()]
def _get_task_with_conn(self, task_id: str, conn: sqlite3.Connection) -> Optional[Task]:
"""Get task using existing connection to avoid nesting."""
cursor = conn.execute(
"SELECT * FROM tasks WHERE id = ?", (task_id,)
)
row = cursor.fetchone()
if row:
return Task.from_dict(dict(row))
return None
def _update_task_with_conn(self, task_id: str, updates: Dict[str, Any], conn: sqlite3.Connection) -> Task:
"""Update task using existing connection to avoid nesting."""
from datetime import timezone
if not updates:
return self._get_task_with_conn(task_id, conn)
# Build update query
set_clauses = []
params = []
for key, value in updates.items():
if key in ['id', 'created_at']: # Don't allow updating immutable fields
continue
set_clauses.append(f"{key} = ?")
params.append(value)
if not set_clauses:
return self._get_task_with_conn(task_id, conn)
# Always update timestamp and version
set_clauses.append("updated_at = ?")
set_clauses.append("version = version + 1")
params.append(datetime.now(timezone.utc).isoformat())
params.append(task_id)
query = f"UPDATE tasks SET {', '.join(set_clauses)} WHERE id = ?"
cursor = conn.execute(query, params)
if cursor.rowcount == 0:
raise ValueError(f"Task {task_id} not found")
# Log the update
self._log_event(conn, task_id, 'updated', updates)
return self._get_task_with_conn(task_id, conn)
def move_task(self, task_id: str, status: str) -> Task:
"""Move task to new status with parent/child promotion logic."""
with self._get_connection() as conn:
# Check if task exists
task = self._get_task_with_conn(task_id, conn)
if not task:
raise ValueError(f"Task {task_id} not found")
new_status = TaskStatus(status)
# Check for blocking parents if moving to ready
if new_status == TaskStatus.READY:
cursor = conn.execute("""
SELECT COUNT(*) as incomplete_parents
FROM task_links tl
JOIN tasks t ON t.id = tl.parent_id
WHERE tl.child_id = ? AND t.status NOT IN ('done', 'archived')
""", (task_id,))
if cursor.fetchone()['incomplete_parents'] > 0:
raise ValueError("Cannot move to ready: incomplete parent tasks")
# Update task status
updated_task = self._update_task_with_conn(task_id, {'status': status}, conn)
# Promote children if moving to done
if new_status == TaskStatus.DONE:
cursor = conn.execute("""
SELECT child_id FROM task_links WHERE parent_id = ?
""", (task_id,))
for row in cursor.fetchall():
child_id = row['child_id']
child_task = self._get_task_with_conn(child_id, conn)
if child_task and child_task.status == TaskStatus.TODO:
# Check if all other parents are done
parent_check = conn.execute("""
SELECT COUNT(*) as incomplete_parents
FROM task_links tl
JOIN tasks t ON t.id = tl.parent_id
WHERE tl.child_id = ? AND t.status NOT IN ('done', 'archived')
""", (child_id,))
if parent_check.fetchone()['incomplete_parents'] == 0:
self._update_task_with_conn(child_id, {'status': TaskStatus.READY.value}, conn)
self._log_event(conn, task_id, 'moved', {
'old_status': task.status.value,
'new_status': status
})
return updated_task
def get_board(self, board: str = "default") -> Dict[str, Any]:
"""Get board layout for UI."""
tasks = self.list_tasks({'board': board})
# Group by status for kanban columns
columns = {}
for status in TaskStatus:
columns[status.value] = []
for task in tasks:
if task.status.value in columns:
columns[task.status.value].append(task.to_dict())
return {
'board': board,
'columns': columns,
'task_count': len(tasks)
}
def bulk_update(self, operations: List[Dict[str, Any]]) -> List[Task]:
"""Bulk update operations."""
results = []
with self._get_connection() as conn:
for op in operations:
op_type = op.get('operation')
if op_type == 'update':
task = self.update_task(op['task_id'], op['updates'])
results.append(task)
elif op_type == 'move':
task = self.move_task(op['task_id'], op['status'])
results.append(task)
elif op_type == 'delete':
if self.delete_task(op['task_id']):
results.append({'deleted': op['task_id']})
return results
def add_comment(self, task_id: str, author: str, text: str) -> TaskComment:
"""Add comment to task."""
comment_id = f"comment_{uuid.uuid4().hex[:12]}"
comment = TaskComment(
id=comment_id,
task_id=task_id,
author=author,
text=text
)
with self._get_connection() as conn:
conn.execute("""
INSERT INTO task_comments (id, task_id, author, text, created_at)
VALUES (?, ?, ?, ?, ?)
""", (
comment.id, comment.task_id, comment.author,
comment.text, comment.created_at.isoformat()
))
self._log_event(conn, task_id, 'commented', {
'author': author,
'text': text[:100] + '...' if len(text) > 100 else text
})
return comment
def get_comments(self, task_id: str) -> List[TaskComment]:
"""Get task comments."""
with self._get_connection() as conn:
cursor = conn.execute("""
SELECT * FROM task_comments
WHERE task_id = ?
ORDER BY created_at ASC
""", (task_id,))
comments = []
for row in cursor.fetchall():
comment_data = dict(row)
comment_data['created_at'] = datetime.fromisoformat(comment_data['created_at'])
comments.append(TaskComment(**comment_data))
return comments
def add_link(self, parent_id: str, child_id: str) -> TaskLink:
"""Create task dependency."""
link = TaskLink(parent_id=parent_id, child_id=child_id)
with self._get_connection() as conn:
# Check for cycles (simplified cycle detection)
cursor = conn.execute("""
WITH RECURSIVE cycle_check AS (
SELECT parent_id, child_id, 1 as depth
FROM task_links
WHERE parent_id = ?
UNION ALL
SELECT tl.parent_id, tl.child_id, cc.depth + 1
FROM task_links tl
JOIN cycle_check cc ON cc.child_id = tl.parent_id
WHERE cc.depth < 10
)
SELECT COUNT(*) as cycle_found
FROM cycle_check
WHERE child_id = ?
""", (child_id, parent_id))
if cursor.fetchone()['cycle_found'] > 0:
raise ValueError("Would create dependency cycle")
conn.execute("""
INSERT OR IGNORE INTO task_links (parent_id, child_id, created_at)
VALUES (?, ?, ?)
""", (parent_id, child_id, link.created_at.isoformat()))
self._log_event(conn, child_id, 'linked', {
'parent_id': parent_id,
'child_id': child_id
})
return link
def remove_link(self, parent_id: str, child_id: str) -> bool:
"""Remove task dependency."""
with self._get_connection() as conn:
result = conn.execute("""
DELETE FROM task_links
WHERE parent_id = ? AND child_id = ?
""", (parent_id, child_id))
if result.rowcount > 0:
self._log_event(conn, child_id, 'unlinked', {
'parent_id': parent_id,
'child_id': child_id
})
return result.rowcount > 0
def list_events(self, since: Optional[datetime] = None) -> List[TaskEvent]:
"""List events for SSE."""
query = "SELECT * FROM task_events"
values = []
if since:
query += " WHERE created_at > ?"
values.append(since.isoformat())
query += " ORDER BY created_at DESC LIMIT 100"
with self._get_connection() as conn:
cursor = conn.execute(query, values)
events = []
for row in cursor.fetchall():
event_data = dict(row)
event_data['created_at'] = datetime.fromisoformat(event_data['created_at'])
event_data['data'] = json.loads(event_data['data'])
events.append(TaskEvent(**event_data))
return events
def claim_task(self, task_id: str, worker_id: str) -> bool:
"""Claim a ready task for execution (CAS operation)."""
with self._get_connection() as conn:
# Atomic claim with CAS on status and claim_lock, increment version for optimistic locking
from datetime import timezone
result = conn.execute("""
UPDATE tasks
SET claim_lock = ?, updated_at = ?, status = 'running', version = version + 1
WHERE id = ? AND status = 'ready' AND (claim_lock IS NULL OR claim_lock = '')
""", (worker_id, datetime.now(timezone.utc).isoformat(), task_id))
if result.rowcount > 0:
self._log_event(conn, task_id, 'claimed', {'worker_id': worker_id})
return True
return False
def release_claim(self, task_id: str, worker_id: str) -> bool:
"""Release claim on task."""
with self._get_connection() as conn:
# Release claim and increment version for optimistic locking
from datetime import timezone
result = conn.execute("""
UPDATE tasks
SET claim_lock = NULL, updated_at = ?, status = 'ready', version = version + 1
WHERE id = ? AND claim_lock = ?
""", (datetime.now(timezone.utc).isoformat(), task_id, worker_id))
if result.rowcount > 0:
self._log_event(conn, task_id, 'released', {'worker_id': worker_id})
return True
return False
@@ -0,0 +1,51 @@
"""
Observability hooks for PraisonAI.
Centralizes observability initialization (AgentOps, etc.) so it can be
used consistently across all entry points without duplicating logic.
"""
import os
import logging
from typing import List, Optional
logger = logging.getLogger(__name__)
def init_observability(framework_tag: str, *, tags: Optional[List[str]] = None) -> None:
"""
Initialize observability providers (AgentOps, etc.) if available.
Args:
framework_tag: Primary framework tag (e.g., "crewai", "autogen_v4")
tags: Additional tags to include
"""
# Try to initialize AgentOps if available
_init_agentops(framework_tag, tags or [])
# Future: Add other observability providers here
# _init_langfuse(framework_tag, tags)
# _init_wandb(framework_tag, tags)
def _init_agentops(framework_tag: str, additional_tags: List[str]) -> None:
"""Initialize AgentOps if available."""
try:
import agentops
agentops_api_key = os.getenv("AGENTOPS_API_KEY")
if agentops_api_key:
all_tags = [framework_tag] + additional_tags
agentops.init(agentops_api_key, default_tags=all_tags)
logger.debug("Initialized AgentOps with tags: %s", all_tags)
except ImportError:
logger.debug("AgentOps not available, skipping initialization")
except Exception as e:
logger.warning("Failed to initialize AgentOps: %s", e)
# Constants for checking availability
try:
import agentops
AGENTOPS_AVAILABLE = True
except ImportError:
AGENTOPS_AVAILABLE = False
@@ -41,6 +41,7 @@ def create_valkey_client(
config = GlideClientConfiguration(
addresses=addresses,
credentials=creds,
client_name="praisonai_valkey_client",
database_id=db,
client_name=_CLIENT_NAME,
)
@@ -12,7 +12,12 @@ import time
from typing import List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage, validate_identifier
from ..._async_bridge import run_sync
# Note: This store is async-only. For sync operations, use sync_sqlite.SyncSQLiteConversationStore
try:
import aiosqlite
except ImportError:
aiosqlite = None
logger = logging.getLogger(__name__)
@@ -28,6 +33,43 @@ class AsyncSQLiteConversationStore(ConversationStore):
await store.init()
"""
# Sync method stubs - this store is async-only
def create_session(self, session) -> None:
"""This store is async-only; use async_create_session or the sync_sqlite store."""
raise RuntimeError("This store is async-only; use async_create_session() or the sync_sqlite store")
def get_session(self, session_id: str) -> None:
"""This store is async-only; use async_get_session or the sync_sqlite store."""
raise RuntimeError("This store is async-only; use async_get_session() or the sync_sqlite store")
def update_session(self, session) -> None:
"""This store is async-only; use async_update_session or the sync_sqlite store."""
raise RuntimeError("This store is async-only; use async_update_session() or the sync_sqlite store")
def delete_session(self, session_id: str) -> None:
"""This store is async-only; use async_delete_session or the sync_sqlite store."""
raise RuntimeError("This store is async-only; use async_delete_session() or the sync_sqlite store")
def list_sessions(self, **kwargs) -> None:
"""This store is async-only; use async_list_sessions or the sync_sqlite store."""
raise RuntimeError("This store is async-only; use async_list_sessions() or the sync_sqlite store")
def add_message(self, session_id: str, message) -> None:
"""This store is async-only; use async_add_message or the sync_sqlite store."""
raise RuntimeError("This store is async-only; use async_add_message() or the sync_sqlite store")
def get_messages(self, session_id: str, **kwargs) -> None:
"""This store is async-only; use async_get_messages or the sync_sqlite store."""
raise RuntimeError("This store is async-only; use async_get_messages() or the sync_sqlite store")
def delete_messages(self, session_id: str, **kwargs) -> None:
"""This store is async-only; use async_delete_messages or the sync_sqlite store."""
raise RuntimeError("This store is async-only; use async_delete_messages() or the sync_sqlite store")
def close(self) -> None:
"""This store is async-only; use async_close or the sync_sqlite store."""
raise RuntimeError("This store is async-only; use async_close() or the sync_sqlite store")
def __init__(
self,
path: str = "praisonai_conversations.db",
@@ -51,9 +93,7 @@ class AsyncSQLiteConversationStore(ConversationStore):
if self._initialized:
return
try:
import aiosqlite
except ImportError:
if aiosqlite is None:
raise ImportError(
"aiosqlite is required for async SQLite support. "
"Install with: pip install aiosqlite"
@@ -120,11 +160,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
return session
def create_session(self, session: ConversationSession) -> ConversationSession:
"""Sync wrapper for create_session."""
return run_sync(
self.async_create_session(session)
)
async def async_get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID asynchronously."""
@@ -150,11 +185,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
)
return None
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Sync wrapper for get_session."""
return run_sync(
self.async_get_session(session_id)
)
async def async_update_session(self, session: ConversationSession) -> ConversationSession:
"""Update an existing session asynchronously."""
@@ -176,11 +206,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
return session
def update_session(self, session: ConversationSession) -> ConversationSession:
"""Sync wrapper for update_session."""
return run_sync(
self.async_update_session(session)
)
async def async_delete_session(self, session_id: str) -> bool:
"""Delete a session asynchronously."""
@@ -195,11 +220,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
return cursor.rowcount > 0
def delete_session(self, session_id: str) -> bool:
"""Sync wrapper for delete_session."""
return run_sync(
self.async_delete_session(session_id)
)
async def async_list_sessions(
self,
@@ -248,17 +268,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
for row in rows
]
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: int = 100,
offset: int = 0
) -> List[ConversationSession]:
"""Sync wrapper for list_sessions."""
return run_sync(
self.async_list_sessions(user_id, agent_id, limit, offset)
)
async def async_add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Add a message asynchronously."""
@@ -280,11 +289,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
return message
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Sync wrapper for add_message."""
return run_sync(
self.async_add_message(session_id, message)
)
async def async_get_messages(
self,
@@ -334,17 +338,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
for row in rows
]
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Sync wrapper for get_messages."""
return run_sync(
self.async_get_messages(session_id, limit, before, after)
)
async def async_delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Delete messages asynchronously."""
@@ -366,11 +359,6 @@ class AsyncSQLiteConversationStore(ConversationStore):
await self._conn.commit()
return cursor.rowcount
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Sync wrapper for delete_messages."""
return run_sync(
self.async_delete_messages(session_id, message_ids)
)
async def async_close(self) -> None:
"""Close the connection."""
@@ -379,7 +367,3 @@ class AsyncSQLiteConversationStore(ConversationStore):
self._conn = None
self._initialized = False
def close(self) -> None:
"""Sync wrapper for close."""
if self._conn:
run_sync(self.async_close())
@@ -0,0 +1,374 @@
"""
Synchronous SQLite implementation of ConversationStore.
Provides blocking database operations using the standard sqlite3 library.
Safe for multi-agent use via per-operation connection locking.
Example:
store = SyncSQLiteConversationStore(path="./conversations.db")
store.init()
"""
import json
import logging
import sqlite3
import threading
import time
from typing import List, Optional
from .base import ConversationStore, ConversationSession, ConversationMessage, validate_identifier
logger = logging.getLogger(__name__)
class SyncSQLiteConversationStore(ConversationStore):
"""
Synchronous SQLite conversation store using sqlite3.
Provides blocking database operations with per-call locking
for multi-agent safety.
"""
def __init__(
self,
path: str = "praisonai_conversations.db",
table_prefix: str = "praison_",
):
"""
Initialize sync SQLite store.
Args:
path: Path to SQLite database file
table_prefix: Prefix for table names
"""
self.path = path
validate_identifier(table_prefix, "table_prefix")
self.table_prefix = table_prefix
self._lock = threading.RLock()
self._initialized = False
def init(self):
"""Initialize connection and create tables."""
with self._lock:
if self._initialized:
return
conn = sqlite3.connect(self.path)
conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = sqlite3.Row
try:
self._create_tables(conn)
self._initialized = True
finally:
conn.close()
def _create_tables(self, conn: sqlite3.Connection):
"""Create database tables."""
sessions_table = f"{self.table_prefix}sessions"
messages_table = f"{self.table_prefix}messages"
conn.executescript(f"""
CREATE TABLE IF NOT EXISTS {sessions_table} (
session_id TEXT PRIMARY KEY,
user_id TEXT,
agent_id TEXT,
name TEXT,
state TEXT,
metadata TEXT,
created_at REAL,
updated_at REAL
);
CREATE TABLE IF NOT EXISTS {messages_table} (
id TEXT PRIMARY KEY,
session_id TEXT,
role TEXT,
content TEXT,
tool_calls TEXT,
tool_call_id TEXT,
metadata TEXT,
created_at REAL,
FOREIGN KEY (session_id) REFERENCES {sessions_table}(session_id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}sessions_user_agent
ON {sessions_table}(user_id, agent_id);
CREATE INDEX IF NOT EXISTS idx_{self.table_prefix}messages_session
ON {messages_table}(session_id, created_at);
""")
conn.commit()
def _get_connection(self) -> sqlite3.Connection:
"""Get a thread-safe connection."""
if not self._initialized:
self.init()
conn = sqlite3.connect(self.path)
conn.execute("PRAGMA foreign_keys = ON")
conn.row_factory = sqlite3.Row
return conn
def create_session(self, session: ConversationSession) -> ConversationSession:
"""Create a new session."""
with self._lock:
conn = self._get_connection()
try:
table = f"{self.table_prefix}sessions"
conn.execute(f"""
INSERT INTO {table} (session_id, user_id, agent_id, name, state, metadata, created_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
session.session_id,
session.user_id,
session.agent_id,
session.name,
json.dumps(session.state) if session.state else None,
json.dumps(session.metadata) if session.metadata else None,
session.created_at,
session.updated_at
))
conn.commit()
return session
finally:
conn.close()
def get_session(self, session_id: str) -> Optional[ConversationSession]:
"""Get a session by ID."""
conn = self._get_connection()
try:
table = f"{self.table_prefix}sessions"
cursor = conn.execute(f"""
SELECT * FROM {table} WHERE session_id = ?
""", (session_id,))
row = cursor.fetchone()
if row:
return ConversationSession(
session_id=row['session_id'],
user_id=row['user_id'],
agent_id=row['agent_id'],
name=row['name'],
state=json.loads(row['state']) if row['state'] else None,
metadata=json.loads(row['metadata']) if row['metadata'] else None,
created_at=row['created_at'],
updated_at=row['updated_at']
)
return None
finally:
conn.close()
def update_session(self, session: ConversationSession) -> ConversationSession:
"""Update an existing session."""
with self._lock:
conn = self._get_connection()
try:
table = f"{self.table_prefix}sessions"
session.updated_at = time.time()
conn.execute(f"""
UPDATE {table}
SET user_id = ?, agent_id = ?, name = ?, state = ?, metadata = ?, updated_at = ?
WHERE session_id = ?
""", (
session.user_id,
session.agent_id,
session.name,
json.dumps(session.state) if session.state else None,
json.dumps(session.metadata) if session.metadata else None,
session.updated_at,
session.session_id
))
conn.commit()
return session
finally:
conn.close()
def delete_session(self, session_id: str) -> bool:
"""Delete a session and all its messages."""
with self._lock:
conn = self._get_connection()
try:
table = f"{self.table_prefix}sessions"
cursor = conn.execute(f"""
DELETE FROM {table} WHERE session_id = ?
""", (session_id,))
conn.commit()
return cursor.rowcount > 0
finally:
conn.close()
def list_sessions(
self,
user_id: Optional[str] = None,
agent_id: Optional[str] = None,
limit: Optional[int] = None,
offset: Optional[int] = None
) -> List[ConversationSession]:
"""List sessions with optional filtering."""
conn = self._get_connection()
try:
table = f"{self.table_prefix}sessions"
conditions = []
params = []
if user_id:
conditions.append("user_id = ?")
params.append(user_id)
if agent_id:
conditions.append("agent_id = ?")
params.append(agent_id)
where_clause = " WHERE " + " AND ".join(conditions) if conditions else ""
# Handle pagination - SQLite requires LIMIT when OFFSET is used
if limit is not None:
params.append(limit)
limit_clause = " LIMIT ?"
elif offset is not None:
# When offset is provided without limit, use -1 for unbounded
params.append(-1)
limit_clause = " LIMIT ?"
else:
limit_clause = ""
if offset is not None:
params.append(offset)
offset_clause = " OFFSET ?"
else:
offset_clause = ""
cursor = conn.execute(f"""
SELECT * FROM {table}{where_clause}
ORDER BY updated_at DESC{limit_clause}{offset_clause}
""", params)
sessions = []
for row in cursor.fetchall():
sessions.append(ConversationSession(
session_id=row['session_id'],
user_id=row['user_id'],
agent_id=row['agent_id'],
name=row['name'],
state=json.loads(row['state']) if row['state'] else None,
metadata=json.loads(row['metadata']) if row['metadata'] else None,
created_at=row['created_at'],
updated_at=row['updated_at']
))
return sessions
finally:
conn.close()
def add_message(self, session_id: str, message: ConversationMessage) -> ConversationMessage:
"""Add a message to the conversation."""
with self._lock:
conn = self._get_connection()
try:
table = f"{self.table_prefix}messages"
# Use the provided session_id, falling back to message.session_id if needed
actual_session_id = session_id or message.session_id
message.session_id = actual_session_id # Ensure message object is consistent
conn.execute(f"""
INSERT INTO {table} (id, session_id, role, content, tool_calls, tool_call_id, metadata, created_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
""", (
message.id,
actual_session_id,
message.role,
message.content,
json.dumps(message.tool_calls) if message.tool_calls else None,
message.tool_call_id,
json.dumps(message.metadata) if message.metadata else None,
message.created_at
))
conn.commit()
return message
finally:
conn.close()
def get_messages(
self,
session_id: str,
limit: Optional[int] = None,
before: Optional[float] = None,
after: Optional[float] = None
) -> List[ConversationMessage]:
"""Get messages for a session."""
conn = self._get_connection()
try:
table = f"{self.table_prefix}messages"
params: list = [session_id]
conditions = []
# Handle timestamp filtering
if before is not None:
conditions.append("created_at < ?")
params.append(before)
if after is not None:
conditions.append("created_at > ?")
params.append(after)
# Build where clause
timestamp_filter = " AND " + " AND ".join(conditions) if conditions else ""
# Handle limit
if limit is not None:
params.append(limit)
limit_clause = " LIMIT ?"
else:
limit_clause = ""
cursor = conn.execute(f"""
SELECT * FROM {table} WHERE session_id = ?{timestamp_filter}
ORDER BY created_at{limit_clause}
""", params)
messages = []
for row in cursor.fetchall():
messages.append(ConversationMessage(
id=row['id'],
session_id=row['session_id'],
role=row['role'],
content=row['content'],
tool_calls=json.loads(row['tool_calls']) if row['tool_calls'] else None,
tool_call_id=row['tool_call_id'],
metadata=json.loads(row['metadata']) if row['metadata'] else None,
created_at=row['created_at']
))
return messages
finally:
conn.close()
def delete_messages(self, session_id: str, message_ids: Optional[List[str]] = None) -> int:
"""Delete messages from a session."""
with self._lock:
conn = self._get_connection()
try:
table = f"{self.table_prefix}messages"
if message_ids is None:
cursor = conn.execute(f"""
DELETE FROM {table} WHERE session_id = ?
""", (session_id,))
else:
placeholders = ','.join(['?' for _ in message_ids])
cursor = conn.execute(f"""
DELETE FROM {table} WHERE session_id = ? AND id IN ({placeholders})
""", [session_id] + message_ids)
conn.commit()
return cursor.rowcount
finally:
conn.close()
def clear_session(self, session_id: str) -> bool:
"""Clear all messages from a session (keep session metadata)."""
return self.delete_messages(session_id) > 0
def close(self) -> None:
"""No persistent connection is held; method exists for protocol compliance."""
return None
+37 -5
View File
@@ -6,7 +6,7 @@ unused dependencies. Uses registry pattern for extensibility.
"""
import logging
from typing import Any, Dict, Optional
from typing import Any, Dict, Literal, Optional
from .config import PersistenceConfig
from .registry import CONVERSATION_STORES, KNOWLEDGE_STORES, STATE_STORES
@@ -17,6 +17,7 @@ logger = logging.getLogger(__name__)
def create_conversation_store(
backend: str,
url: Optional[str] = None,
mode: Literal["sync", "async", "auto"] = "auto",
**options: Any
):
"""
@@ -25,18 +26,44 @@ def create_conversation_store(
Args:
backend: Backend type (postgres, mysql, sqlite, etc.)
url: Connection URL
mode: "sync" for blocking, "async" for async-only, "auto" for legacy behavior
**options: Backend-specific options
Returns:
ConversationStore instance
Example:
# Async-safe SQLite store
store = create_conversation_store(
"postgres",
url="postgresql://localhost:5432/praisonai"
"sqlite",
path="./conversations.db",
mode="async"
)
# Sync-only SQLite store (multi-agent safe)
store = create_conversation_store(
"sqlite",
path="./conversations.db",
mode="sync"
)
"""
return CONVERSATION_STORES.create(backend, url=url, **options)
# Validate mode parameter
if mode not in ("sync", "async", "auto"):
raise ValueError(f"Invalid conversation mode: {mode}. Expected sync|async|auto")
# Handle sync/async mode routing
if mode == "sync" and backend in ("sqlite", "postgres", "mysql"):
backend_name = f"sync_{backend}"
elif mode == "async" and backend in ("sqlite", "postgres", "mysql"):
backend_name = f"async_{backend}"
elif mode == "auto":
# Legacy behavior - use existing backend as-is
backend_name = backend
else:
backend_name = backend
return CONVERSATION_STORES.create(backend_name, url=url, **options)
def create_knowledge_store(
@@ -103,10 +130,15 @@ def create_stores_from_config(config: PersistenceConfig) -> Dict[str, Any]:
}
if config.conversation_store:
# Extract mode from conversation_options if present
conversation_options = dict(config.conversation_options)
mode = conversation_options.pop("mode", "auto")
stores["conversation"] = create_conversation_store(
config.conversation_store,
url=config.conversation_url,
**config.conversation_options
mode=mode,
**conversation_options
)
if config.knowledge_store:
@@ -5,6 +5,7 @@ Provides wrapper functions to add persistence capabilities to PraisonAI agents
without modifying the core SDK.
"""
import asyncio
import time
import uuid
import logging
@@ -60,15 +61,13 @@ def wrap_agent_with_persistence(
session_id = session_id or f"session-{uuid.uuid4().hex[:8]}"
user_id = user_id or getattr(agent, 'user_id', 'default')
# Store original chat method
# Store original methods
original_chat = agent.chat
original_achat = getattr(agent, 'achat', None)
_session_initialized = [False] # Use list to allow mutation in closure
@wraps(original_chat)
def chat_with_persistence(prompt, *args, **kwargs):
nonlocal _session_initialized
# Initialize session on first call
def _ensure_session_sync():
"""Ensure session is initialized (sync version)."""
if not _session_initialized[0]:
history = orchestrator.on_agent_start(
agent,
@@ -86,6 +85,31 @@ def wrap_agent_with_persistence(
logger.info(f"Resumed session {session_id} with {len(history)} messages")
_session_initialized[0] = True
async def _ensure_session_async():
"""Ensure session is initialized (async version)."""
if not _session_initialized[0]:
history = await orchestrator.aon_agent_start(
agent,
session_id=session_id,
user_id=user_id,
resume=auto_resume
)
# Inject history into agent's chat_history if resuming
if auto_resume and history:
agent.chat_history = [
{"role": msg.role, "content": msg.content}
for msg in history
]
logger.info(f"Resumed session {session_id} with {len(history)} messages")
_session_initialized[0] = True
@wraps(original_chat)
def chat_with_persistence(prompt, *args, **kwargs):
# Initialize session on first call
_ensure_session_sync()
# Persist user message
orchestrator.on_message(session_id, "user", prompt if isinstance(prompt, str) else str(prompt))
@@ -102,6 +126,27 @@ def wrap_agent_with_persistence(
# Replace chat method
agent.chat = chat_with_persistence
# Replace achat method if it exists
if original_achat is not None:
@wraps(original_achat)
async def achat_with_persistence(prompt, *args, **kwargs):
# Initialize session on first call
await _ensure_session_async()
# Persist user message
await orchestrator.aon_message(session_id, "user", prompt if isinstance(prompt, str) else str(prompt))
# Call original achat
response = await original_achat(prompt, *args, **kwargs)
# Persist assistant response
if response:
await orchestrator.aon_message(session_id, "assistant", response)
return response
agent.achat = achat_with_persistence
# Add persistence-related methods to agent
agent._persistence_orchestrator = orchestrator
agent._persistence_session_id = session_id
@@ -118,9 +163,14 @@ def wrap_agent_with_persistence(
"""End the current session."""
orchestrator.on_agent_end(agent, session_id)
async def aend_session():
"""End the current session (async version)."""
await orchestrator.aon_agent_end(agent, session_id)
agent.get_session = get_session
agent.get_messages = get_messages
agent.end_session = end_session
agent.aend_session = aend_session
return agent
@@ -164,7 +214,7 @@ class PersistentAgent:
return getattr(self._agent, name)
def _ensure_session(self):
"""Ensure session is initialized."""
"""Ensure session is initialized (sync version)."""
if not self._session_initialized:
history = self._orchestrator.on_agent_start(
self._agent,
@@ -182,6 +232,25 @@ class PersistentAgent:
self._session_initialized = True
async def _ensure_session_async(self):
"""Ensure session is initialized (async version)."""
if not self._session_initialized:
history = await self._orchestrator.aon_agent_start(
self._agent,
session_id=self._session_id,
user_id=self._user_id,
resume=self._auto_resume
)
if self._auto_resume and history:
self._agent.chat_history = [
{"role": msg.role, "content": msg.content}
for msg in history
]
logger.info(f"Resumed session {self._session_id} with {len(history)} messages")
self._session_initialized = True
def chat(self, prompt, *args, **kwargs):
"""Chat with automatic persistence."""
self._ensure_session()
@@ -201,6 +270,29 @@ class PersistentAgent:
return response
async def achat(self, prompt, *args, **kwargs):
"""Async chat with automatic persistence."""
await self._ensure_session_async()
# Persist user message
await self._orchestrator.aon_message(
self._session_id, "user",
prompt if isinstance(prompt, str) else str(prompt)
)
# Call agent achat
if hasattr(self._agent, 'achat'):
response = await self._agent.achat(prompt, *args, **kwargs)
else:
# Fallback to sync chat if achat is not available - use asyncio.to_thread to avoid blocking
response = await asyncio.to_thread(self._agent.chat, prompt, *args, **kwargs)
# Persist assistant response
if response:
await self._orchestrator.aon_message(self._session_id, "assistant", response)
return response
def get_session(self) -> Optional[ConversationSession]:
"""Get the current session."""
return self._orchestrator.get_session(self._session_id)
@@ -213,6 +305,10 @@ class PersistentAgent:
"""End the current session."""
self._orchestrator.on_agent_end(self._agent, self._session_id)
async def aend_session(self):
"""End the current session (async version)."""
await self._orchestrator.aon_agent_end(self._agent, self._session_id)
@property
def session_id(self) -> str:
"""Get the session ID."""
@@ -9,6 +9,7 @@ import logging
import time
import threading
import uuid
import inspect
from copy import deepcopy
from typing import Any, Dict, List, Optional, TYPE_CHECKING
@@ -244,6 +245,164 @@ class PersistenceOrchestrator:
self._cache_put(session)
logger.debug(f"Updated session metadata: {session_id}")
# =========================================================================
# Async Agent Lifecycle Hooks
# =========================================================================
async def aon_agent_start(
self,
agent: Any,
session_id: Optional[str] = None,
user_id: Optional[str] = None,
resume: bool = True,
) -> List[ConversationMessage]:
"""
Async version of on_agent_start.
Args:
agent: The agent instance
session_id: Session ID (generated if not provided)
user_id: User ID for session
resume: Whether to load existing session history
Returns:
List of previous messages if resuming, empty list otherwise
"""
if not session_id:
session_id = str(uuid.uuid4())
if not self.conversation:
logger.debug("No conversation store configured, skipping session load")
return []
# Try to load existing session
session = None
if resume:
getter = getattr(self.conversation, 'async_get_session', self.conversation.get_session)
if inspect.iscoroutinefunction(getter):
session = await getter(session_id)
else:
session = getter(session_id)
if session:
logger.info(f"Resuming session: {session_id}")
self._current_session = session
self._cache_put(session)
# Load previous messages
msg_getter = getattr(self.conversation, 'async_get_messages', self.conversation.get_messages)
if inspect.iscoroutinefunction(msg_getter):
messages = await msg_getter(session_id)
else:
messages = msg_getter(session_id)
return messages
else:
# Create new session
agent_id = getattr(agent, "name", None) or getattr(agent, "agent_id", None)
session = ConversationSession(
session_id=session_id,
user_id=user_id,
agent_id=agent_id,
name=f"Session {session_id[:8]}",
metadata={"agent_type": type(agent).__name__},
)
creator = getattr(self.conversation, 'async_create_session', self.conversation.create_session)
if inspect.iscoroutinefunction(creator):
await creator(session)
else:
creator(session)
logger.info(f"Created new session: {session_id}")
self._current_session = session
self._cache_put(session)
return []
async def aon_message(
self,
session_id: str,
role: str,
content: str,
tool_calls: Optional[List[Dict]] = None,
tool_call_id: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> Optional[ConversationMessage]:
"""
Async version of on_message.
Args:
session_id: Session ID
role: Message role (user, assistant, system, tool)
content: Message content
tool_calls: Tool calls (for assistant messages)
tool_call_id: Tool call ID (for tool response messages)
metadata: Additional metadata
Returns:
The persisted message, or None if no store configured
"""
if not self.conversation:
return None
message = ConversationMessage(
id=str(uuid.uuid4()),
session_id=session_id,
role=role,
content=content,
tool_calls=tool_calls,
tool_call_id=tool_call_id,
metadata=metadata,
)
adder = getattr(self.conversation, 'async_add_message', self.conversation.add_message)
if inspect.iscoroutinefunction(adder):
await adder(session_id, message)
else:
adder(session_id, message)
logger.debug(f"Persisted {role} message to session {session_id}")
return message
async def aon_agent_end(
self,
agent: Any,
session_id: str,
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""
Async version of on_agent_end.
Args:
agent: The agent instance
session_id: Session ID
metadata: Additional metadata to store
"""
if not self.conversation:
return
session = self._cache_get(session_id)
if not session:
getter = getattr(self.conversation, 'async_get_session', self.conversation.get_session)
if inspect.iscoroutinefunction(getter):
session = await getter(session_id)
else:
session = getter(session_id)
if session:
session.updated_at = time.time()
if metadata:
session.metadata = {**(session.metadata or {}), **metadata}
updater = getattr(self.conversation, 'async_update_session', self.conversation.update_session)
if inspect.iscoroutinefunction(updater):
await updater(session)
else:
updater(session)
# Update cache with the modified session
self._cache_put(session)
logger.debug(f"Updated session metadata: {session_id}")
# =========================================================================
# Knowledge Retrieval
# =========================================================================
@@ -121,6 +121,13 @@ def _register_builtin_conversation_stores(registry: StoreRegistry):
from .conversation.sqlite import SQLiteConversationStore
return SQLiteConversationStore(path=url or path, **kwargs)
def _sync_sqlite(url=None, path=None, **kwargs):
from .conversation.sync_sqlite import SyncSQLiteConversationStore
resolved_path = url or path
if resolved_path is None:
return SyncSQLiteConversationStore(**kwargs)
return SyncSQLiteConversationStore(path=resolved_path, **kwargs)
def _async_sqlite(url=None, path=None, **kwargs):
from .conversation.async_sqlite import AsyncSQLiteConversationStore
return AsyncSQLiteConversationStore(path=url or path, **kwargs)
@@ -154,6 +161,8 @@ def _register_builtin_conversation_stores(registry: StoreRegistry):
registry.register("async_mysql", _async_mysql,
aliases=("aiomysql", "mysql_async"))
registry.register("sqlite", _sqlite)
registry.register("sync_sqlite", _sync_sqlite,
aliases=("sqlite_sync",))
registry.register("async_sqlite", _async_sqlite,
aliases=("aiosqlite", "sqlite_async"))
registry.register("json", _json)
+11 -23
View File
@@ -100,9 +100,7 @@ class E2BSandbox:
if self._sandbox:
try:
await asyncio.get_event_loop().run_in_executor(
None, self._sandbox.kill
)
await asyncio.to_thread(self._sandbox.kill)
except Exception as e:
logger.warning(f"Failed to kill E2B sandbox: {e}")
self._sandbox = None
@@ -161,9 +159,8 @@ class E2BSandbox:
"""Execute Python code using E2B code interpreter."""
try:
# Run in executor to avoid blocking the event loop
execution = await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._sandbox.run_code(code, timeout=limits.timeout_seconds)
execution = await asyncio.to_thread(
self._sandbox.run_code, code, timeout=limits.timeout_seconds
)
# Extract results from E2B execution
@@ -219,23 +216,20 @@ class E2BSandbox:
if env:
import shlex
for key, value in env.items():
await asyncio.get_event_loop().run_in_executor(
None,
lambda k=key, v=value: self._sandbox.commands.run(f"export {shlex.quote(k)}={shlex.quote(v)}", timeout=5)
await asyncio.to_thread(
self._sandbox.commands.run, f"export {shlex.quote(key)}={shlex.quote(value)}", timeout=5
)
# Change directory if needed
if working_dir:
import shlex
await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._sandbox.commands.run(f"cd {shlex.quote(working_dir)}", timeout=5)
await asyncio.to_thread(
self._sandbox.commands.run, f"cd {shlex.quote(working_dir)}", timeout=5
)
# Execute the command
result = await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._sandbox.commands.run(command, timeout=limits.timeout_seconds)
result = await asyncio.to_thread(
self._sandbox.commands.run, command, timeout=limits.timeout_seconds
)
return SandboxResult(
@@ -318,10 +312,7 @@ class E2BSandbox:
if isinstance(content, bytes):
content = content.decode("utf-8")
await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._sandbox.files.write(path, content)
)
await asyncio.to_thread(self._sandbox.files.write, path, content)
return True
except Exception as e:
logger.error(f"Failed to write file {path}: {e}")
@@ -336,10 +327,7 @@ class E2BSandbox:
await self.start()
try:
content = await asyncio.get_event_loop().run_in_executor(
None,
lambda: self._sandbox.files.read(path)
)
content = await asyncio.to_thread(self._sandbox.files.read, path)
return content
except Exception as e:
logger.warning(f"Failed to read file {path}: {e}")
@@ -71,12 +71,17 @@ class AsyncAgentScheduler:
- Cancellation support
- No global state pollution
- Native async coordination
- Timeout support (TODO: needs porting from sync version)
- Budget tracking (TODO: needs porting from sync version)
- YAML/recipe constructors (TODO: needs porting from sync version)
- Timeout support
- Budget tracking
- YAML/recipe constructors
Example:
scheduler = AsyncAgentScheduler(agent, task="Check news")
scheduler = AsyncAgentScheduler(
agent,
task="Check news",
timeout=30, # 30 second timeout per execution
max_cost=1.00 # $1.00 budget limit
)
await scheduler.start(schedule_expr="hourly")
await asyncio.sleep(3600) # Let it run
await scheduler.stop()
@@ -89,9 +94,8 @@ class AsyncAgentScheduler:
config: Optional[Dict[str, Any]] = None,
on_success: Optional[Callable] = None,
on_failure: Optional[Callable] = None,
# TODO: Add these missing features from sync version:
# timeout: Optional[int] = None,
# max_cost: Optional[float] = 1.00
timeout: Optional[int] = None,
max_cost: Optional[float] = 1.00
):
"""
Initialize async agent scheduler.
@@ -102,18 +106,17 @@ class AsyncAgentScheduler:
config: Optional configuration dict
on_success: Callback function on successful execution
on_failure: Callback function on failed execution
# TODO: Add timeout and max_cost parameters
timeout: Maximum execution time per run in seconds (None = no limit)
max_cost: Maximum total cost in USD (default: $1.00 for safety)
"""
self.agent = agent
self.task = task
self.config = config or {}
self.on_success = on_success
self.on_failure = on_failure
# TODO: Add these missing features from sync version:
# self.timeout = timeout
# self.max_cost = max_cost
# self._total_cost = 0.0
self.timeout = timeout
self.max_cost = max_cost
self._total_cost = 0.0
self.is_running = False
self._task: Optional[asyncio.Task] = None
@@ -172,7 +175,10 @@ class AsyncAgentScheduler:
logger.info(f"Starting async agent scheduler: {getattr(self.agent, 'name', 'Agent')}")
logger.info(f"Task: {self.task}")
logger.info(f"Schedule: {schedule_expr} ({interval}s interval)")
# TODO: Add timeout and budget logging from sync version
if self.timeout:
logger.info(f"Timeout per execution: {self.timeout}s")
if self.max_cost is not None:
logger.info(f"Budget limit: ${self.max_cost}")
# Run immediately if requested
if run_immediately:
@@ -265,26 +271,12 @@ class AsyncAgentScheduler:
async def get_stats(self) -> Dict[str, Any]:
"""
Get current execution statistics (best-effort synchronous access).
Warning: This method provides a best-effort view of stats without
guaranteeing atomicity. For consistent snapshots in async context,
use get_stats_async() instead.
Get current execution statistics (async, atomic snapshot).
Returns:
Dictionary with execution stats
"""
# Best-effort read without lock for backward compatibility
return {
"is_running": self.is_running,
"total_executions": self._execution_count,
"successful_executions": self._success_count,
"failed_executions": self._failure_count,
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
# TODO: Add cost tracking from sync version:
# "total_cost": self._total_cost,
# "remaining_budget": (self.max_cost - self._total_cost) if self.max_cost else None,
}
return await self.get_stats_async()
async def get_stats_async(self) -> Dict[str, Any]:
"""
@@ -295,13 +287,14 @@ class AsyncAgentScheduler:
"""
if self._stats_lock is None:
# Not yet started: stats are all zero, no lock needed
execs, success, failed = 0, 0, 0
execs, success, failed, total_cost = 0, 0, 0, 0.0
else:
# Take atomic snapshot of all counters
async with self._stats_lock:
execs = self._execution_count
success = self._success_count
failed = self._failure_count
total_cost = self._total_cost
return {
"is_running": self.is_running,
@@ -309,9 +302,8 @@ class AsyncAgentScheduler:
"successful_executions": success,
"failed_executions": failed,
"success_rate": (success / execs * 100) if execs > 0 else 0,
# TODO: Add cost tracking from sync version:
# "total_cost": self._total_cost,
# "remaining_budget": (self.max_cost - self._total_cost) if self.max_cost else None,
"total_cost_usd": round(total_cost, 4),
"remaining_budget": round(self.max_cost - total_cost, 4) if self.max_cost is not None else None,
}
def get_stats_sync(self) -> Dict[str, Any]:
@@ -328,6 +320,8 @@ class AsyncAgentScheduler:
"successful_executions": self._success_count,
"failed_executions": self._failure_count,
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
"total_cost_usd": round(self._total_cost, 4),
"remaining_budget": round(self.max_cost - self._total_cost, 4) if self.max_cost is not None else None,
}
async def _run_schedule(self, interval: int, max_retries: int):
@@ -350,45 +344,50 @@ class AsyncAgentScheduler:
self.is_running = False
async def _execute_with_retry(self, max_retries: int):
"""Execute agent with retry logic.
TODO: Port missing features from sync version:
- Timeout support per execution
- Budget tracking and limits
- Daemon state updates (_update_state_if_daemon)
"""
"""Execute agent with retry logic."""
self._ensure_async_primitives() # guarantees _stats_lock is bound to current loop
# Check budget limit before incrementing execution count
if self.max_cost and self._total_cost >= self.max_cost:
logger.warning(f"Budget limit reached: ${self._total_cost:.4f} >= ${self.max_cost}")
logger.warning("Stopping scheduler to prevent additional costs")
self._stop_event.set() # Actually stop the scheduler
return
async with self._stats_lock:
self._execution_count += 1
# TODO: Add budget check from sync version:
# if self.max_cost and self._total_cost >= self.max_cost:
# logger.warning(f"Budget limit reached: ${self._total_cost:.4f} >= ${self.max_cost}")
# return
# Check budget limit before execution
if self.max_cost is not None and self._total_cost >= self.max_cost:
logger.warning(f"Budget limit reached: ${self._total_cost:.4f} >= ${self.max_cost}")
if self._stop_event is not None:
self._stop_event.set()
self.is_running = False
return
last_exc: Optional[Exception] = None
for attempt in range(max_retries):
try:
logger.info(f"Async attempt {attempt + 1}/{max_retries}")
# TODO: Add timeout support from sync version:
# if self.timeout:
# result = await asyncio.wait_for(
# self._executor.execute(self.task),
# timeout=self.timeout
# )
# else:
result = await self._executor.execute(self.task)
# Execute with timeout if specified
if self.timeout:
result = await asyncio.wait_for(
self._executor.execute(self.task),
timeout=self.timeout
)
else:
result = await self._executor.execute(self.task)
logger.info(f"Async agent execution successful on attempt {attempt + 1}")
logger.info(f"Result: {result}")
# Estimate cost (rough: ~$0.0001 per execution for gpt-4o-mini)
estimated_cost = 0.0001 # Base cost estimate
async with self._stats_lock:
self._success_count += 1
# TODO: Add cost tracking from sync version:
# estimated_cost = self._estimate_cost(result)
# self._total_cost += estimated_cost
self._total_cost += estimated_cost
logger.info(f"Estimated cost this run: ${estimated_cost:.4f}, Total: ${self._total_cost:.4f}")
safe_call(self.on_success, result)
# TODO: Add daemon state update from sync version:
@@ -438,22 +437,207 @@ class AsyncAgentScheduler:
logger.error(f"One-time async execution failed: {e}")
raise
# TODO: Add these missing factory methods from sync version:
# @classmethod
# async def from_yaml(cls, yaml_path: str, ...) -> "AsyncAgentScheduler":
# """Create scheduler from YAML configuration."""
# pass
#
# @classmethod
# async def from_recipe(cls, recipe_name: str, ...) -> "AsyncAgentScheduler":
# """Create scheduler from recipe configuration."""
# pass
@classmethod
def from_yaml(
cls,
yaml_path: str = "agents.yaml",
interval_override: Optional[str] = None,
max_retries_override: Optional[int] = None,
timeout_override: Optional[int] = None,
max_cost_override: Optional[float] = None,
on_success: Optional[Callable] = None,
on_failure: Optional[Callable] = None
) -> 'AsyncAgentScheduler':
"""
Create AsyncAgentScheduler from agents.yaml file.
Args:
yaml_path: Path to agents.yaml file
interval_override: Override schedule interval from YAML
max_retries_override: Override max_retries from YAML
timeout_override: Override timeout from YAML
max_cost_override: Override max_cost from YAML
on_success: Callback function on successful execution
on_failure: Callback function on failed execution
Returns:
Configured AsyncAgentScheduler instance
Example:
scheduler = AsyncAgentScheduler.from_yaml("agents.yaml")
await scheduler.start("hourly")
"""
from .yaml_loader import load_agent_yaml_with_schedule, create_agent_from_config
# Load configuration from YAML
agent_config, schedule_config = load_agent_yaml_with_schedule(yaml_path)
# Create agent from config
agent = create_agent_from_config(agent_config)
# Get task
task = agent_config.get('task', '')
if not task:
raise ValueError("No task specified in YAML file")
# Apply overrides to schedule config
if interval_override:
schedule_config['interval'] = interval_override
if max_retries_override is not None:
schedule_config['max_retries'] = max_retries_override
if timeout_override is not None:
schedule_config['timeout'] = timeout_override
if max_cost_override is not None:
schedule_config['max_cost'] = max_cost_override
# Create scheduler instance with timeout and cost limits
scheduler = cls(
agent=agent,
task=task,
config=agent_config,
timeout=schedule_config.get('timeout'),
max_cost=schedule_config.get('max_cost'),
on_success=on_success,
on_failure=on_failure
)
# Store schedule config for later use
scheduler._yaml_schedule_config = schedule_config
return scheduler
async def start_from_yaml_config(self) -> bool:
"""
Start scheduler using configuration from YAML file.
Must be called after from_yaml() class method.
Returns:
True if started successfully
"""
if not hasattr(self, '_yaml_schedule_config'):
raise ValueError("No YAML configuration found. Use from_yaml() first.")
schedule_config = self._yaml_schedule_config
interval = schedule_config.get('interval', 'hourly')
max_retries = schedule_config.get('max_retries', 3)
run_immediately = schedule_config.get('run_immediately', False)
return await self.start(interval, max_retries, run_immediately)
@classmethod
def from_recipe(
cls,
recipe_name: str,
*,
input_data: Any = None,
config: Optional[Dict[str, Any]] = None,
interval_override: Optional[str] = None,
max_retries_override: Optional[int] = None,
timeout_override: Optional[int] = None,
max_cost_override: Optional[float] = None,
on_success: Optional[Callable] = None,
on_failure: Optional[Callable] = None
) -> 'AsyncAgentScheduler':
"""
Create AsyncAgentScheduler from a recipe name.
Args:
recipe_name: Name of the recipe to schedule
input_data: Input data for the recipe
config: Configuration overrides for the recipe
interval_override: Override schedule interval from recipe runtime config
max_retries_override: Override max_retries from recipe runtime config
timeout_override: Override timeout from recipe runtime config
max_cost_override: Override max_cost from recipe runtime config
on_success: Callback function on successful execution
on_failure: Callback function on failed execution
Returns:
Configured AsyncAgentScheduler instance
Example:
scheduler = AsyncAgentScheduler.from_recipe("news-monitor")
await scheduler.start("hourly")
"""
from praisonai.recipe.bridge import resolve, execute_resolved_recipe, get_recipe_task_description
# Resolve the recipe
resolved = resolve(
recipe_name,
input_data=input_data,
config=config or {},
options={'timeout_sec': timeout_override or 300},
)
# Get runtime config defaults from recipe
interval = interval_override or "hourly"
max_retries = max_retries_override if max_retries_override is not None else 3
timeout = timeout_override or 300
max_cost = max_cost_override if max_cost_override is not None else 1.00
runtime = resolved.runtime_config
if runtime and hasattr(runtime, 'schedule'):
sched_config = runtime.schedule
interval = interval_override or sched_config.interval
max_retries = max_retries_override if max_retries_override is not None else sched_config.max_retries
timeout = timeout_override or sched_config.timeout_sec
max_cost = max_cost_override if max_cost_override is not None else sched_config.max_cost_usd
# Create a recipe executor agent wrapper that supports async
class AsyncRecipeExecutorAgent:
"""Wrapper that makes a recipe look like an agent for the async scheduler."""
def __init__(self, resolved_recipe):
self.resolved = resolved_recipe
self.name = f"AsyncRecipeAgent:{resolved_recipe.name}"
async def astart(self, task: str) -> Any:
# Run recipe execution in thread to avoid blocking async loop
loop = asyncio.get_running_loop()
return await loop.run_in_executor(
None, lambda: execute_resolved_recipe(self.resolved)
)
def start(self, task: str) -> Any:
# Fallback sync method
return execute_resolved_recipe(self.resolved)
# Create the agent wrapper
agent = AsyncRecipeExecutorAgent(resolved)
task = get_recipe_task_description(resolved)
# Create scheduler instance
scheduler = cls(
agent=agent,
task=task,
timeout=timeout,
max_cost=max_cost,
on_success=on_success,
on_failure=on_failure,
)
# Store recipe metadata and schedule config
scheduler._recipe_name = recipe_name
scheduler._recipe_resolved = resolved
scheduler._yaml_schedule_config = {
'interval': interval,
'max_retries': max_retries,
'run_immediately': False,
'timeout': timeout,
'max_cost': max_cost,
}
return scheduler
def create_async_agent_scheduler(
agent,
task: str,
config: Optional[Dict[str, Any]] = None
config: Optional[Dict[str, Any]] = None,
on_success: Optional[Callable] = None,
on_failure: Optional[Callable] = None,
timeout: Optional[int] = None,
max_cost: Optional[float] = 1.00
) -> AsyncAgentScheduler:
"""
Factory function to create async agent scheduler.
@@ -462,8 +646,20 @@ def create_async_agent_scheduler(
agent: PraisonAI Agent instance
task: Task description
config: Optional configuration
on_success: Callback function on successful execution
on_failure: Callback function on failed execution
timeout: Maximum execution time per run in seconds (None = no limit)
max_cost: Maximum total cost in USD (default: $1.00 for safety)
Returns:
Configured AsyncAgentScheduler instance
"""
return AsyncAgentScheduler(agent, task, config)
return AsyncAgentScheduler(
agent,
task,
config=config,
on_success=on_success,
on_failure=on_failure,
timeout=timeout,
max_cost=max_cost
)
+57 -22
View File
@@ -19,8 +19,8 @@ class ToolRegistry:
def __init__(self):
self._functions: Dict[str, Callable] = {}
self._autogen_adapters: Dict[str, Callable] = {}
self._lock = threading.Lock()
# Note: AutoGen-specific adapters moved to framework_adapters.autogen
def register_function(self, name: str, func: Callable) -> None:
"""Register a function tool."""
@@ -30,13 +30,24 @@ class ToolRegistry:
self._functions[name] = func
logger.debug(f"Registered function tool: {name}")
def register_autogen_adapter(self, tool_type_name: str, adapter: Callable) -> None:
"""Register an AutoGen-specific tool adapter."""
def register_autogen_adapter(self, tool_type_name: str, adapter: Callable, _suppress_deprecation_warning: bool = False) -> None:
"""Deprecated: AutoGen adapters moved to framework_adapters.autogen module."""
if not _suppress_deprecation_warning:
import warnings
warnings.warn(
"ToolRegistry.register_autogen_adapter is deprecated. "
"AutoGen-specific logic has been moved to framework_adapters.autogen module.",
DeprecationWarning,
stacklevel=2
)
# For backward compatibility, still store but warn
if not callable(adapter):
raise ValueError(f"AutoGen adapter for {tool_type_name} must be callable")
with self._lock:
if not hasattr(self, '_autogen_adapters'):
self._autogen_adapters: Dict[str, Callable] = {}
self._autogen_adapters[tool_type_name] = adapter
logger.debug(f"Registered AutoGen adapter: {tool_type_name}")
logger.debug(f"Registered AutoGen adapter: {tool_type_name} (deprecated)")
def get_function(self, name: str) -> Optional[Callable]:
"""Get a function tool by name."""
@@ -44,9 +55,19 @@ class ToolRegistry:
return self._functions.get(name)
def get_autogen_adapter(self, tool_type_name: str) -> Optional[Callable]:
"""Get an AutoGen adapter by tool type name."""
with self._lock:
return self._autogen_adapters.get(tool_type_name)
"""Deprecated: AutoGen adapters moved to framework_adapters.autogen module."""
import warnings
warnings.warn(
"ToolRegistry.get_autogen_adapter is deprecated. "
"AutoGen-specific logic has been moved to framework_adapters.autogen module.",
DeprecationWarning,
stacklevel=2
)
# For backward compatibility
if hasattr(self, '_autogen_adapters'):
with self._lock:
return self._autogen_adapters.get(tool_type_name)
return None
def list_functions(self) -> List[str]:
"""List all registered function tool names."""
@@ -54,9 +75,19 @@ class ToolRegistry:
return list(self._functions.keys())
def list_autogen_adapters(self) -> List[str]:
"""List all registered AutoGen adapter names."""
with self._lock:
return list(self._autogen_adapters.keys())
"""Deprecated: AutoGen adapters moved to framework_adapters.autogen module."""
import warnings
warnings.warn(
"ToolRegistry.list_autogen_adapters is deprecated. "
"AutoGen-specific logic has been moved to framework_adapters.autogen module.",
DeprecationWarning,
stacklevel=2
)
# For backward compatibility
if hasattr(self, '_autogen_adapters'):
with self._lock:
return list(self._autogen_adapters.keys())
return []
def get_functions_dict(self) -> Dict[str, Callable]:
"""Get a copy of all registered functions."""
@@ -67,7 +98,8 @@ class ToolRegistry:
"""Clear all registered tools."""
with self._lock:
self._functions.clear()
self._autogen_adapters.clear()
if hasattr(self, '_autogen_adapters'):
self._autogen_adapters.clear()
logger.debug("Cleared tool registry")
def register_from_module(self, module: Any) -> List[str]:
@@ -91,25 +123,27 @@ class ToolRegistry:
logger.debug(f"Registered {len(registered)} functions from module: {registered}")
return registered
def register_builtin_autogen_adapters(self) -> None:
"""Register builtin AutoGen adapters from inbuilt_tools."""
def register_builtin_autogen_adapters(self, _suppress_deprecation_warning: bool = False) -> None:
"""Deprecated: AutoGen adapters moved to framework_adapters.autogen module."""
if not _suppress_deprecation_warning:
import warnings
warnings.warn(
"ToolRegistry.register_builtin_autogen_adapters is deprecated. "
"AutoGen-specific logic has been moved to framework_adapters.autogen module.",
DeprecationWarning,
stacklevel=2
)
# For backward compatibility, attempt old logic but warn
try:
# Lazy import to avoid circular dependencies
from .inbuilt_tools import _get_autogen_tools
# Get the autogen_tools module
tools_module = _get_autogen_tools()
if tools_module:
# Register adapters based on the pattern in the original code
for attr_name in dir(tools_module):
if attr_name.startswith('autogen_') and not attr_name.startswith('__'):
adapter = getattr(tools_module, attr_name)
if callable(adapter):
# Extract tool type name from adapter function name
# e.g., 'autogen_CodeDocsSearchTool' -> 'CodeDocsSearchTool'
tool_type_name = attr_name.replace('autogen_', '')
self.register_autogen_adapter(tool_type_name, adapter)
self.register_autogen_adapter(tool_type_name, adapter, _suppress_deprecation_warning=True)
except ImportError as e:
logger.warning(f"Could not register builtin AutoGen adapters: {e}")
except Exception as e:
@@ -118,7 +152,8 @@ class ToolRegistry:
def __len__(self) -> int:
"""Return total number of registered tools."""
with self._lock:
return len(self._functions) + len(self._autogen_adapters)
autogen_count = len(self._autogen_adapters) if hasattr(self, '_autogen_adapters') else 0
return len(self._functions) + autogen_count
def __contains__(self, name: str) -> bool:
"""Check if a tool function is registered."""
-12
View File
@@ -621,15 +621,3 @@ def validate_yaml_tools(yaml_config: Dict[str, Any], resolver: Optional[ToolReso
return (resolver or _get_default_resolver()).validate_yaml_tools(yaml_config)
def reset_default_resolver() -> None:
"""Clear the process-default resolver.
Call this between tenants, on CWD change, or in test setup to ensure
that local tools.py resolution is not affected by previous calls.
This follows the same pattern as _framework_availability.invalidate()
for resetting cached state.
"""
global _default_resolver
with _default_resolver_lock:
_default_resolver = None
+12
View File
@@ -13,6 +13,8 @@ __all__ = [
"stt_tool",
"create_tts_tool",
"create_stt_tool",
"get_kanban_tools",
"KANBAN_TOOLS",
]
_lazy_cache = {}
@@ -58,4 +60,14 @@ def __getattr__(name: str):
_lazy_cache[name] = create_stt_tool
return create_stt_tool
if name == "get_kanban_tools":
from .kanban_tools import get_kanban_tools
_lazy_cache[name] = get_kanban_tools
return get_kanban_tools
if name == "KANBAN_TOOLS":
from .kanban_tools import KANBAN_TOOLS
_lazy_cache[name] = KANBAN_TOOLS
return KANBAN_TOOLS
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,399 @@
"""
Kanban Tools for PraisonAI Agents.
Provides kanban task management tools that allow agents to create, update,
and manage kanban tasks for multi-agent coordination and workflow management.
All tools connect to the same SQLite kanban store as the PraisonAIUI dashboard.
"""
import logging
import os
from typing import Dict, Any, List, Optional
logger = logging.getLogger(__name__)
# Lazy-loaded kanban store instance
_kanban_store = None
def _get_kanban_store():
"""Lazy load kanban store."""
global _kanban_store
if _kanban_store is None:
try:
from praisonai.kanban.sqlite_store import SQLiteKanbanStore
_kanban_store = SQLiteKanbanStore()
except ImportError as e:
logger.error(f"Failed to import kanban store: {e}")
raise ImportError("Kanban functionality requires praisonai kanban module") from e
return _kanban_store
def kanban_create(
title: str,
body: str = "",
assignee: str = "",
status: str = "todo",
priority: int = 0,
board: str = "default"
) -> Dict[str, Any]:
"""
Create a kanban task for multi-agent coordination.
Args:
title: Task title (required)
body: Task description/details
assignee: Username or agent name to assign to
status: Task status (todo, ready, running, blocked, done)
priority: Task priority (higher number = higher priority)
board: Board name to create task on
Returns:
Dict containing the created task details including task ID
Example:
>>> kanban_create("Implement user authentication", assignee="coder")
{'id': 'task_abc123', 'title': 'Implement user authentication', ...}
"""
try:
store = _get_kanban_store()
task_data = {
'title': title,
'body': body,
'assignee': assignee,
'status': status,
'priority': priority,
'board': board
}
task = store.create_task(task_data)
return task.to_dict()
except Exception as e:
logger.error(f"Failed to create kanban task: {e}")
return {'error': str(e)}
def kanban_list(
status: Optional[str] = None,
assignee: Optional[str] = None,
board: str = "default",
limit: int = 50
) -> Dict[str, Any]:
"""
List kanban tasks with optional filtering.
Args:
status: Filter by status (todo, ready, running, blocked, done)
assignee: Filter by assignee username
board: Board name to list from
limit: Maximum number of tasks to return
Returns:
Dict containing list of tasks and metadata
Example:
>>> kanban_list(status="ready", assignee="coder")
{'tasks': [...], 'count': 3, 'filters': {...}}
"""
try:
store = _get_kanban_store()
filters = {'board': board}
if status:
filters['status'] = status
if assignee:
filters['assignee'] = assignee
tasks = store.list_tasks(filters)
# Limit results
if len(tasks) > limit:
tasks = tasks[:limit]
return {
'tasks': [task.to_dict() for task in tasks],
'count': len(tasks),
'filters': filters,
'limited': len(tasks) == limit
}
except Exception as e:
logger.error(f"Failed to list kanban tasks: {e}")
return {'error': str(e)}
def kanban_show(task_id: str) -> Dict[str, Any]:
"""
Show detailed information about a specific task.
Args:
task_id: The task ID to retrieve
Returns:
Dict containing task details, comments, and dependencies
Example:
>>> kanban_show("task_abc123")
{'task': {...}, 'comments': [...], 'dependencies': [...]}
"""
try:
store = _get_kanban_store()
task = store.get_task(task_id)
if not task:
return {'error': f'Task {task_id} not found'}
# Get comments
comments = store.get_comments(task_id)
# Get parent/child links (simplified - would need more complex query for full DAG)
result = {
'task': task.to_dict(),
'comments': [comment.to_dict() for comment in comments],
'comment_count': len(comments)
}
return result
except Exception as e:
logger.error(f"Failed to show kanban task {task_id}: {e}")
return {'error': str(e)}
def kanban_complete(task_id: str, comment: str = "") -> Dict[str, Any]:
"""
Mark a task as completed.
Args:
task_id: The task ID to complete
comment: Optional completion comment
Returns:
Dict containing the updated task
Example:
>>> kanban_complete("task_abc123", "Authentication system working")
{'task': {...}, 'status': 'done'}
"""
try:
store = _get_kanban_store()
# Move to done status
task = store.move_task(task_id, 'done')
# Add completion comment if provided
if comment:
store.add_comment(task_id, 'agent', f"Completed: {comment}")
return {
'task': task.to_dict(),
'status': 'done',
'completed': True
}
except Exception as e:
logger.error(f"Failed to complete kanban task {task_id}: {e}")
return {'error': str(e)}
def kanban_block(task_id: str, reason: str) -> Dict[str, Any]:
"""
Mark a task as blocked with a reason.
Args:
task_id: The task ID to block
reason: Reason why the task is blocked
Returns:
Dict containing the updated task
Example:
>>> kanban_block("task_abc123", "Waiting for API credentials")
{'task': {...}, 'status': 'blocked'}
"""
try:
store = _get_kanban_store()
# Move to blocked status
task = store.move_task(task_id, 'blocked')
# Add blocking reason as comment
store.add_comment(task_id, 'agent', f"Blocked: {reason}")
return {
'task': task.to_dict(),
'status': 'blocked',
'reason': reason
}
except Exception as e:
logger.error(f"Failed to block kanban task {task_id}: {e}")
return {'error': str(e)}
def kanban_comment(task_id: str, text: str, author: str = "agent") -> Dict[str, Any]:
"""
Add a comment to a kanban task.
Args:
task_id: The task ID to comment on
text: Comment text
author: Comment author (defaults to 'agent')
Returns:
Dict containing the created comment
Example:
>>> kanban_comment("task_abc123", "Made progress on auth module")
{'comment': {...}, 'task_id': 'task_abc123'}
"""
try:
store = _get_kanban_store()
comment = store.add_comment(task_id, author, text)
return {
'comment': comment.to_dict(),
'task_id': task_id
}
except Exception as e:
logger.error(f"Failed to add comment to kanban task {task_id}: {e}")
return {'error': str(e)}
def kanban_link(parent_id: str, child_id: str) -> Dict[str, Any]:
"""
Create a dependency link between two tasks.
Args:
parent_id: The parent task ID (must be completed before child)
child_id: The child task ID (depends on parent)
Returns:
Dict containing the created link
Example:
>>> kanban_link("task_design", "task_implement")
{'link': {...}, 'parent_id': 'task_design', 'child_id': 'task_implement'}
"""
try:
store = _get_kanban_store()
link = store.add_link(parent_id, child_id)
return {
'link': {
'parent_id': link.parent_id,
'child_id': link.child_id,
'created_at': link.created_at.isoformat()
},
'parent_id': parent_id,
'child_id': child_id
}
except Exception as e:
logger.error(f"Failed to link kanban tasks {parent_id} -> {child_id}: {e}")
return {'error': str(e)}
def kanban_heartbeat(task_id: str, status: str = "working") -> Dict[str, Any]:
"""
Send a heartbeat signal while working on a task.
Args:
task_id: The task ID being worked on
status: Status message (e.g., "working", "testing", "debugging")
Returns:
Dict containing heartbeat confirmation
Example:
>>> kanban_heartbeat("task_abc123", "testing authentication flow")
{'heartbeat': True, 'task_id': 'task_abc123', 'status': 'testing...'}
"""
try:
store = _get_kanban_store()
# Verify task exists and is claimed
task = store.get_task(task_id)
if not task:
return {'error': f'Task {task_id} not found'}
# Add heartbeat comment
store.add_comment(
task_id,
'agent',
f"Heartbeat: {status} at {task.updated_at.strftime('%H:%M:%S')}"
)
return {
'heartbeat': True,
'task_id': task_id,
'status': status,
'timestamp': task.updated_at.isoformat()
}
except Exception as e:
logger.error(f"Failed to send heartbeat for kanban task {task_id}: {e}")
return {'error': str(e)}
# Export all kanban tools for agent registration
KANBAN_TOOLS = [
kanban_create,
kanban_list,
kanban_show,
kanban_complete,
kanban_block,
kanban_comment,
kanban_link,
kanban_heartbeat,
]
def get_kanban_tools() -> List[callable]:
"""
Get all kanban tools for agent registration.
Returns:
List of kanban tool functions
Example:
>>> from praisonai.tools.kanban_tools import get_kanban_tools
>>> agent = Agent(name="coordinator", tools=get_kanban_tools())
"""
return KANBAN_TOOLS
def create_kanban_agent_tools(board: str = "default") -> List[callable]:
"""
Create kanban tools bound to a specific board.
Args:
board: Board name to bind tools to
Returns:
List of board-specific kanban tools
"""
# Create board-specific versions of tools with board parameter pre-filled
def _make_board_tool(tool_func, board_name):
def wrapper(*args, **kwargs):
if 'board' not in kwargs:
kwargs['board'] = board_name
return tool_func(*args, **kwargs)
wrapper.__name__ = f"{tool_func.__name__}_{board_name}"
wrapper.__doc__ = tool_func.__doc__
return wrapper
board_tools = []
for tool in KANBAN_TOOLS:
if 'board' in tool.__code__.co_varnames:
board_tools.append(_make_board_tool(tool, board))
else:
board_tools.append(tool)
return board_tools
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.6.48"
__version__ = "4.6.52"
+1 -1
View File
@@ -12,7 +12,7 @@ dependencies = [
"rich>=13.7",
"markdown>=3.5",
"pyparsing>=3.0.0",
"praisonaiagents>=1.6.48",
"praisonaiagents>=1.6.52",
"python-dotenv>=0.19.0",
"litellm>=1.83.14,<2",
"PyYAML>=6.0",
@@ -0,0 +1,127 @@
"""Integration tests for kanban host injection."""
import pytest
import tempfile
import shutil
from pathlib import Path
from unittest.mock import patch, MagicMock
@pytest.fixture
def temp_kanban_dir():
"""Create temporary directory for kanban testing."""
temp_dir = Path(tempfile.mkdtemp())
yield temp_dir
if temp_dir.exists():
shutil.rmtree(temp_dir)
def test_kanban_bridge_registration():
"""Test that kanban bridge can register backends."""
# Mock praisonaiui backends
mock_backends = MagicMock()
with patch.dict('sys.modules', {'praisonaiui.backends': mock_backends}):
from praisonai.integration.bridges.kanban_bridge import register_kanban_backends
result = register_kanban_backends()
# Should return True indicating successful registration
assert result is True
# Verify backends were set
assert mock_backends.set_backend.call_count >= 1
# Check specific backend calls
backend_calls = [call[0] for call in mock_backends.set_backend.call_args_list]
assert any('kanban_store' in str(call) for call in backend_calls)
def test_kanban_store_factory():
"""Test kanban store factory creation."""
from praisonai.integration.bridges.kanban_bridge import get_kanban_store_factory
factory = get_kanban_store_factory()
assert factory is not None
# Test factory creates store
with patch('praisonai.integration.bridges.kanban_bridge.SQLiteKanbanStore') as mock_store:
store = factory()
mock_store.assert_called_once()
def test_setup_bridges_includes_kanban(temp_kanban_dir):
"""Test that setup_bridges includes kanban registration."""
# Mock all the dependencies
mock_backends = MagicMock()
mock_praisonaiui = MagicMock()
mock_praisonaiui.backends = mock_backends
# Mock the kanban path to use temp directory
def mock_get_path(board=None):
return temp_kanban_dir / "test_kanban.db"
with patch.dict('sys.modules', {
'praisonaiui': mock_praisonaiui,
'praisonaiui.backends': mock_backends
}), patch('praisonai.kanban.paths.get_kanban_db_path', mock_get_path):
from praisonai.integration.host_app import setup_bridges
# This should not raise and should call kanban registration
setup_bridges()
# Verify some backend was registered (exact calls depend on available components)
assert mock_backends.set_backend.called
def test_kanban_store_environment_variable_support(temp_kanban_dir):
"""Test that kanban store respects environment variables."""
import os
test_board = "test_board"
with patch.dict(os.environ, {'PRAISONAI_KANBAN_BOARD': test_board}):
with patch('praisonai.kanban.paths.get_kanban_db_path') as mock_path:
mock_path.return_value = temp_kanban_dir / f"{test_board}_kanban.db"
from praisonai.integration.bridges.kanban_bridge import get_kanban_store_factory
factory = get_kanban_store_factory()
# This should use the environment variable for board selection
with patch('praisonai.integration.bridges.kanban_bridge.SQLiteKanbanStore') as mock_store:
store = factory()
mock_store.assert_called_once()
def test_missing_dependencies_graceful_degradation():
"""Test that missing dependencies don't break setup."""
# Simulate missing praisonaiui
with patch.dict('sys.modules', {}, clear=True):
# Remove praisonaiui from modules to simulate missing dependency
if 'praisonaiui' in __import__('sys').modules:
del __import__('sys').modules['praisonaiui']
if 'praisonaiui.backends' in __import__('sys').modules:
del __import__('sys').modules['praisonaiui.backends']
from praisonai.integration.bridges.kanban_bridge import register_kanban_backends
# Should not raise, just return False
result = register_kanban_backends()
assert result is False
def test_jobs_store_bridge():
"""Test jobs store bridge functionality."""
from praisonai.integration.bridges.kanban_bridge import get_jobs_store, get_jobs_executor
# These may return None if jobs module isn't available, which is fine
jobs_store = get_jobs_store()
jobs_executor = get_jobs_executor()
# Just test that the functions don't raise exceptions
# Actual functionality depends on jobs module implementation
assert jobs_store is None or callable(jobs_store)
assert jobs_executor is None or callable(jobs_executor)
@@ -0,0 +1,259 @@
"""
Test that Telegram gateway polling path enforces security checks.
This test verifies the fix for issue #1747 where gateway Telegram polling
bypassed allowed_users, pairing, and group policy enforcement.
The fix introduces a shared security pipeline `process_inbound_telegram_message()`
used by both standalone and gateway paths to ensure identical security enforcement.
"""
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from praisonaiagents.bots import BotConfig, BotUser
from praisonai.bots.telegram import TelegramBot, process_inbound_telegram_message
from praisonai.bots._unknown_user import UnknownUserHandler
def create_mock_telegram_update(user_id: str = "12345", chat_id: str = "-100123456789", text: str = "test message", chat_type: str = "group"):
"""Create a mock Telegram Update for testing."""
update = MagicMock()
# Mock message
update.message = MagicMock()
update.message.text = text
update.message.voice = None
update.message.audio = None
update.message.date.timestamp.return_value = 1234567890.0
update.message.message_id = 123
update.message.message_thread_id = None
# Mock user
update.message.from_user = MagicMock()
update.message.from_user.id = int(user_id)
update.message.from_user.username = f"user_{user_id}"
update.message.from_user.first_name = f"User {user_id}"
update.message.from_user.is_bot = False
# Mock chat
update.message.chat = MagicMock()
update.message.chat.id = int(chat_id)
update.message.chat.type = chat_type
update.message.chat.title = "Test Group" if chat_type == "group" else None
update.message.chat.username = f"chat_{chat_id}"
return update
def create_test_bot(allowed_users=None, allowed_channels=None, group_policy="mention_only") -> TelegramBot:
"""Create a TelegramBot for testing with specified security config."""
config = BotConfig(
token="test_token",
allowed_users=allowed_users or [],
allowed_channels=allowed_channels or [],
group_policy=group_policy,
)
bot = TelegramBot(token="test_token", config=config)
# Mock required attributes
bot._bot_user = BotUser(
user_id="123456789",
username="test_bot",
display_name="Test Bot",
is_bot=True,
)
# Mock the fire_message_received method
bot.fire_message_received = MagicMock()
return bot
@pytest.mark.asyncio
async def test_user_allowlist_enforcement():
"""Test that user allowlist is enforced in the security pipeline."""
# Bot with restricted user allowlist
bot = create_test_bot(allowed_users=["42"])
# Message from allowed user
allowed_update = create_mock_telegram_update(user_id="42", text="hello", chat_type="private")
allowed_message = await process_inbound_telegram_message(allowed_update, bot)
assert allowed_message is not None, "Message from allowed user should pass"
assert allowed_message.sender.user_id == "42"
# Message from disallowed user
disallowed_update = create_mock_telegram_update(user_id="99", text="hello", chat_type="private")
disallowed_message = await process_inbound_telegram_message(disallowed_update, bot)
assert disallowed_message is None, "Message from disallowed user should be blocked"
# Group message from allowed user should still pass allowlist checks
allowed_group_update = create_mock_telegram_update(user_id="42", text="@test_bot hello", chat_type="group")
allowed_group_message = await process_inbound_telegram_message(allowed_group_update, bot)
assert allowed_group_message is not None, "Allowlisted users should pass in group chats too"
@pytest.mark.asyncio
async def test_channel_allowlist_enforcement():
"""Test that channel allowlist is enforced in the security pipeline."""
# Bot with restricted channel allowlist
bot = create_test_bot(allowed_channels=["-100123456789"])
# Message from allowed channel
allowed_update = create_mock_telegram_update(chat_id="-100123456789", text="@test_bot hello", chat_type="supergroup")
allowed_message = await process_inbound_telegram_message(allowed_update, bot)
assert allowed_message is not None, "Message from allowed channel should pass"
assert allowed_message.channel.channel_id == "-100123456789"
# Message from disallowed channel
disallowed_update = create_mock_telegram_update(chat_id="-100999999999", text="@test_bot hello", chat_type="supergroup")
disallowed_message = await process_inbound_telegram_message(disallowed_update, bot)
assert disallowed_message is None, "Message from disallowed channel should be blocked"
@pytest.mark.asyncio
async def test_group_policy_mention_enforcement():
"""Test that group mention policy is enforced in the security pipeline."""
# Bot with mention_only group policy
bot = create_test_bot(group_policy="mention_only")
bot._bot_user.username = "Test_Bot"
# Group message with bot mention - should pass
mention_update = create_mock_telegram_update(
chat_type="group",
text="@test_bot please help"
)
mention_message = await process_inbound_telegram_message(mention_update, bot)
assert mention_message is not None, "Group message with mention should pass"
# Group message without mention - should be blocked
no_mention_update = create_mock_telegram_update(
chat_type="group",
text="hello everyone"
)
no_mention_message = await process_inbound_telegram_message(no_mention_update, bot)
assert no_mention_message is None, "Group message without mention should be blocked"
# Commands should always pass regardless of mention
command_update = create_mock_telegram_update(
chat_type="group",
text="/help"
)
command_message = await process_inbound_telegram_message(command_update, bot)
assert command_message is not None, "Commands should always pass in groups"
@pytest.mark.asyncio
async def test_dm_messages_bypass_group_policies():
"""Test that DM messages bypass group-specific policies."""
# Bot with mention_only group policy
bot = create_test_bot(group_policy="mention_only")
# DM message without mention - should pass
dm_update = create_mock_telegram_update(
chat_type="private",
text="hello bot"
)
dm_message = await process_inbound_telegram_message(dm_update, bot)
assert dm_message is not None, "DM messages should bypass group mention requirements"
@pytest.mark.asyncio
async def test_group_policy_command_only_enforcement():
"""Test that command_only only allows commands in groups."""
bot = create_test_bot(group_policy="command_only")
message_update = create_mock_telegram_update(chat_type="group", text="hello everyone")
message = await process_inbound_telegram_message(message_update, bot)
assert message is None, "Non-command group messages should be blocked in command_only mode"
command_update = create_mock_telegram_update(chat_type="group", text="/help")
command_message = await process_inbound_telegram_message(command_update, bot)
assert command_message is not None, "Commands should pass in command_only mode"
@pytest.mark.asyncio
@patch.object(UnknownUserHandler, 'handle')
async def test_pairing_system_integration(mock_unknown_handler):
"""Test that pairing system is properly integrated."""
# Mock the UnknownUserHandler to simulate pairing rejection
mock_unknown_handler.return_value = False # User not approved
# Bot with explicit allowlist that does not include unknown user
bot = create_test_bot(allowed_users=["42"])
# Message from unknown user
unknown_update = create_mock_telegram_update(user_id="12345", text="hello")
unknown_message = await process_inbound_telegram_message(unknown_update, bot)
# Should be blocked by pairing system
assert unknown_message is None, "Unknown user should be blocked by pairing system"
# Verify UnknownUserHandler.handle was called
mock_unknown_handler.assert_called_once()
@pytest.mark.asyncio
async def test_empty_allowlists_allow_all():
"""Test that empty allowlists allow all users/channels (default behavior)."""
# Bot with no restrictions
bot = create_test_bot(allowed_users=[], allowed_channels=[])
# Message from any user in any channel
update = create_mock_telegram_update(user_id="99999", chat_id="-999999999", text="hello", chat_type="private")
message = await process_inbound_telegram_message(update, bot)
assert message is not None, "Empty allowlists should allow all users and channels"
@pytest.mark.asyncio
async def test_audio_message_transcription():
"""Test that audio messages are properly transcribed in the security pipeline."""
bot = create_test_bot()
# Mock the transcribe_audio method
bot._transcribe_audio = AsyncMock(return_value="[Voice message]: transcribed text")
# Create update with voice message
update = create_mock_telegram_update(text=None, chat_type="private")
update.message.text = None
update.message.voice = MagicMock() # Simulate voice message
message = await process_inbound_telegram_message(update, bot)
assert message is not None, "Voice message should be processed"
assert message.content == "[Voice message]: transcribed text"
bot._transcribe_audio.assert_called_once()
def test_security_pipeline_exists():
"""Basic smoke test to ensure the security pipeline function exists and is importable."""
from praisonai.bots.telegram import process_inbound_telegram_message
assert callable(process_inbound_telegram_message), "Security pipeline function should be callable"
@pytest.mark.asyncio
async def test_shared_pipeline_consistency():
"""Test that the shared pipeline provides consistent results."""
# Create identical bot configs
bot1 = create_test_bot(allowed_users=["42"], group_policy="mention_only")
bot2 = create_test_bot(allowed_users=["42"], group_policy="mention_only")
# Same message update
update = create_mock_telegram_update(user_id="42", text="@test_bot hello")
# Both should produce identical results
message1 = await process_inbound_telegram_message(update, bot1)
message2 = await process_inbound_telegram_message(update, bot2)
assert message1 is not None and message2 is not None
assert message1.content == message2.content
assert message1.sender.user_id == message2.sender.user_id
@@ -195,6 +195,12 @@ class TestCodexCLIStructuredOutput:
class TestIntegrationExports:
"""Tests for integration module exports."""
def test_exported_codex_integration_is_class(self):
"""Test exported CodexCLIIntegration resolves to the class, not a loader."""
from praisonai.integrations import CodexCLIIntegration
assert inspect.isclass(CodexCLIIntegration)
def test_get_available_integrations_export_is_sync(self):
"""Test exported get_available_integrations remains synchronous."""
from praisonai.integrations import get_available_integrations
@@ -298,6 +298,42 @@ def test_backward_compatible_aliases():
assert ManagedBackendConfig == ManagedConfig
@pytest.mark.asyncio
async def test_local_managed_stream_persists_messages():
"""stream() must persist user/assistant messages like execute()."""
import tempfile
from unittest.mock import MagicMock, patch
from praisonaiagents.session.store import DefaultSessionStore
from praisonai.integrations.managed_local import LocalManagedAgent
with tempfile.TemporaryDirectory() as session_dir:
store = DefaultSessionStore(session_dir=session_dir)
managed = LocalManagedAgent(session_store=store)
mock_agent = MagicMock()
def _fake_chat(prompt, stream=False):
if stream:
return iter(["Hel", "lo"])
return "Hello"
mock_agent.chat.side_effect = _fake_chat
with patch.object(managed, "_ensure_agent", return_value=mock_agent):
chunks = []
async for chunk in managed.stream("Hi there"):
chunks.append(chunk)
assert "".join(chunks) == "Hello"
assert managed.session_id
history = store.get_chat_history(managed.session_id)
assert history == [
{"role": "user", "content": "Hi there"},
{"role": "assistant", "content": "Hello"},
]
def test_local_managed_persist_state_preserves_messages():
"""_persist_state must not overwrite messages added concurrently on disk."""
import tempfile
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff. Show more