Compare commits

...
104 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
praison d5f1114aaf Release v4.6.48 2026-05-30 07:22:17 +01:00
Anna Tao d364fa5020 feat: add CLIENT SETNAME to Valkey client for connection identification (#1761)
Sets client_name="praisonai_persistence_client" on GlideClientConfiguration
so connections are identifiable in CLIENT LIST, Valkey Admin, and CloudWatch.

Naming convention: {project}_{purpose}_client
- praisonai = project
- persistence = purpose (state, knowledge, and session storage)
- _client = convention (matches Valkey Admin pattern)
2026-05-30 07:20:59 +01:00
c1db6317c8 fix: reload session from disk on get_chat_history reads (#1759)
* fix: reload session from disk on get_chat_history reads

get_chat_history and get_session returned stale in-memory cache when
another DefaultSessionStore instance (or worker process with a warm
cache) had written newer messages to the same session file. Bot
load_history paths then fed the LLM truncated context.

Reload under FileLock before returning reads; add regression test.

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

* fix: address session store test failure and consistency issues

- Update test_invalidate_cache to reflect new always-reload behavior
- Fix inconsistency in get_sessions_by_agent() to use fresh reads
- Ensures consistent staleness guarantees across all read methods

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-05-30 07:20:30 +01:00
f88727cb75 feat: Multi-Telegram-channel deployments with per-channel token env convention (#1757)
* feat: implement multi-channel Telegram bot support (fixes #1751)

- Add multi-channel support to onboard wizard with role-based naming
- Implement TELEGRAM_<ROLE>_BOT_TOKEN environment variable convention
- Add doctor validation for duplicate token usage and naming conventions
- Create YAML generation for multiple channels per platform with agent routing
- Add documentation and example for Hermes-style multi-bot workforces

This enables operators to run multiple Telegram bots (e.g. @company_cfo_bot,
@company_ops_bot) each routing to different agents with specialized knowledge,
matching Hermes workforce deployment patterns.

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

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

* fix: critical structural regression in onboard wizard and improve validation

- Fix unreachable code in onboard wizard - Steps 2-6 were dead code after return statement
- Restore self.tokens attribute for backward compatibility with legacy methods
- Improve duplicate token detection to check actual token values, not just env var names
- Add role sanitization (regex) and duplicate channel prevention
- Fix markdown language specifier to resolve markdownlint warning

Fixes structural issue where wizard would exit after Step 1 without collecting tokens,
generating YAML, or installing daemon. All critical reviewer feedback addressed.

🤖 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: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-30 07:19:36 +01:00
MervinPraison 7ab0479e82 docs: auto-update api.md [skip ci] 2026-05-30 06:18:38 +00:00
0e0241d15f fix: Gateway port 8765 collision — add preflight bind check and single-instance guard (#1756)
* fix: Gateway port 8765 collision — add preflight bind check and single-instance guard (fixes #1749)

- Add port_utils.py with collision detection utilities:
  * is_port_in_use() - Check if port is bound
  * check_port_available() - Check port and find PID using it
  * GatewayPIDLock - Single-instance enforcement with PID lock files
  * format_collision_error() - Actionable error messages

- Update gateway server with preflight port checking:
  * Check port availability before uvicorn starts
  * Acquire/release PID lock for single-instance enforcement
  * Clear error messages when port conflicts detected

- Enhance gateway CLI with stop command and GATEWAY_PORT support:
  * Add 'praisonai gateway stop' with graceful/force options
  * Enhanced 'praisonai gateway status' shows PID lock and port status
  * Support GATEWAY_PORT environment variable in all commands

- Implement gateway handler stop functionality:
  * PID detection and graceful/force process termination
  * Enhanced status checking with port and lock information

Resolves port bind failures, duplicate process scenarios, and Windows restart loops.
Provides clear error messages with actionable guidance for operators.

🤖 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 critical PID lock and port collision issues

Fixes 5 critical bugs identified in gateway collision prevention:

1. Multi-port conflict: PID lock now port-specific to allow different ports
2. Lock cleanup failure: Added exception handler around uvicorn startup
3. TOCTOU race: Use atomic temp file + rename for lock acquisition
4. Windows compatibility: Use SIGTERM on Windows instead of SIGKILL
5. Unused variable: Remove unused output controller in stop command

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-30 07:18:15 +01:00
MervinPraison 5815dc61bf docs: auto-update api.md [skip ci] 2026-05-30 06:17:53 +00:00
e720c0bc3d fix: Agent RLock not picklable in multi-channel gateway (#1755)
* fix: Agent RLock not picklable in multi-channel gateway (fixes #1746)

- Add Agent.clone_for_channel() method to safely clone agents without threading.RLock issues
- Update WebSocketGateway._create_bot() to use clone_for_channel() instead of copy.deepcopy()
- Add comprehensive tests for multi-channel scenarios
- Resolves 'cannot pickle _thread.RLock object' error when creating 2nd+ gateway channels

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

* Changes before error encountered

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/356b719f-451d-4adf-93ad-1a33350b555b

* fix: comprehensive Agent.clone_for_channel() implementation

- Replace deepcopy approach with proper cloning logic
- Shallow copy tools to avoid nested RLock deepcopy failures
- Exclude handoffs from clones (no shared agents across channels)
- Create fresh interrupt_controller for each clone (no shared state)
- Preserve all config objects and core attributes properly
- Add deprecation warnings for backward compatibility
- Remove ad-hoc test files, use proper test structure

Resolves all issues identified in code reviews while maintaining
full backward compatibility and following AGENTS.md guidelines.

🤖 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: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-30 07:17:30 +01:00
cd448fa710 fix: Gateway Telegram error replies crash on non-ASCII exception text (fixes #1752) (#1754)
* fix: Gateway Telegram error replies crash on non-ASCII exception text

- Create safe error message helper in unicode_utils.py
- Update Telegram bot error handling to use ASCII-safe formatting
- Fix gateway server error handler to extract root cause errors
- Ensure UTF-8 encoding for YAML config file reads
- Add support for accented character conversion to ASCII

Fixes #1752

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

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

* Changes before error encountered

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/a921937b-8ef2-4716-a4c1-16e7e340f16d

* fix: address reviewer feedback on unicode_utils performance and logic

- Remove dead code comment and simplify safe_log_message implementation
- Fix double sanitization in extract_root_cause_from_error by returning raw text
- Improve code comments for clarity

Addresses Greptile review feedback on performance and logic issues.

---------

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]>
2026-05-30 07:16:38 +01:00
c35dadebdb feat: implement Telegram typing indicator renewal during long agent operations (#1753)
* feat: implement Telegram typing indicator renewal during long agent operations

- Add TypingIndicator utility class with configurable renewal interval
- Update TelegramBot standalone mode to use typing renewal during agent.chat()
- Update gateway polling handler to use typing renewal during agent.chat()
- Enable ack_emoji (⏳) and done_emoji (✅) in onboard-generated configs
- Typing indicator now renews every 4 seconds during long operations
- Fixes issue where users thought bot was broken during 20-30s agent responses

Fixes #1748

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

* Changes before error encountered

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/964cb77f-55a0-4e60-b20d-348759d336d8

* docs: improve docstring coverage for typing indicator module

Enhance docstring for _typing_loop method to improve coverage
and meet repository documentation standards.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-30 07:15:55 +01:00
f9ae9e9aa4 fix: HierarchicalSessionStore message loss and extended field wipe (#1745)
* fix: HierarchicalSessionStore message loss and extended field wipe

HierarchicalSessionStore.add_message used an unlocked load-modify-save
path, so concurrent writers could overwrite each other's messages.
Inherited _modify_session_locked also loaded SessionData, which dropped
parent_id, snapshots, and children_ids on metadata updates.

- Load ExtendedSessionData under the existing file lock
- Route add_message through _modify_session_locked
- Keep _extended_cache in sync after locked writes
- Add regression tests for concurrency and extended fields

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

* fix: remove redundant SessionMessage import in hierarchy.py

Copilot found redundant local import of SessionMessage in add_message
method since it's already imported at module scope. This removes
the unnecessary import statement.

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-05-30 07:15:24 +01:00
9d9a9a7ad8 fix: Security vulnerabilities in wrapper layer (#1744)
* fix: Security vulnerabilities in wrapper layer (fixes #1743)

- Fix 1: Gateway magic-link auth secret mismatch - export resolved token to environment for single source of truth across HTTP and WebSocket auth paths
- Fix 2a: SSH sandbox timeout cleanup - add try/finally to ensure remote temp files are always cleaned up
- Fix 2b: SSH sandbox remote process orphaning - wrap commands with timeout to ensure remote processes self-terminate
- Fix 2c: Docker sandbox container orphaning - add container names and docker kill on timeout to stop actual containers
- Fix 3: Cloud deploy API key exposure - replace inline argv secrets with secure temporary YAML file approach

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

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

* fix: ensure gateway auth token env stays in sync

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/174baeba-8504-435e-875b-60dc62d3972c

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-30 07:14:53 +01:00
44d9d8c4c0 fix: eliminate wrapper layer architectural gaps (#1723)
* fix: eliminate wrapper layer architectural gaps (fixes #1722)

Gap 1 - Remove dead duplicate framework runners (~790 lines):
- Delete _run_autogen, _run_autogen_v4, _run_ag2, _run_crewai, _run_praisonai from agents_generator.py
- These methods were never called (execution goes through framework_adapter.run())
- Eliminates maintenance drift between duplicate implementations

Gap 2 - Complete async scheduler migration:
- Move AsyncAgentScheduler to canonical location scheduler/async_agent_scheduler.py
- Reduce root async_agent_scheduler.py to thin re-export with deprecation warning
- Add TODO comments for missing features (timeout, max_cost, from_yaml, etc.)
- Maintains backward compatibility while stopping feature drift

Gap 3 - Replace global singletons with contextvars:
- interactive_tools.py: _shared_runtime → _runtime_var for per-context config
- tool_resolver.py: _default_resolver → _resolver_var for CWD isolation
- Prevents race conditions and config capture in concurrent execution
- Add reset_default_resolver() for daemon/IDE plugin invalidation

All fixes preserve backward compatibility while aligning with project philosophy:
DRY, multi-agent safe, async-safe, no global singletons, protocol-driven core.

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

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

* fix: align async scheduler compatibility logging and docs

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/043eb2d4-7868-4904-8b6f-2301a38da452

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

* fix: resolve reviewer issues in wrapper layer fixes

- Fix hot retry loop on asyncio.TimeoutError by adding backoff delay
- Remove unused _cancel_event attribute from AsyncAgentScheduler
- Add missing get_stats_async() and get_stats_sync() methods for backward compatibility
- Remove unnecessary process-wide lock from context-local runtime creation
- Document first-caller-wins behavior for runtime config

Addresses critical issues identified by Greptile and CodeRabbit reviewers.
Maintains full backward compatibility while fixing architectural gaps.

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

* fix: simplify get_stats_sync to avoid event loop issues

Use direct synchronous access instead of trying to coordinate with event loop.
This prevents timeouts and maintains backward compatibility.

---------

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: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-30 07:13:29 +01:00
praison bf2656cee9 Release v4.6.47 2026-05-30 07:11:48 +01:00
d55cd989ea fix: persist DbSessionAdapter metadata across process restarts (#1742)
* fix: persist DbSessionAdapter metadata to conversation store

Managed agents using PraisonDB lost token counts, session history, and
compute refs after process restart because set_metadata was in-memory only.
Flush metadata on write and reload from the conversation session on read.

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

* fix: address CodeRabbit findings in DbSessionAdapter metadata persistence

- Add _purge_persisted_metadata() to clear metadata on session deletion
- Fix test mock configuration to prevent auto-creation of conversation attr
- Add integration test as required by AGENTS.md §9.4

Resolves stale metadata resurrection and improves test coverage.

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-05-30 07:05:57 +01:00
0305214695 fix: wrapper defects - scheduler timeout, tool loading, and YAML surface (#1741)
* fix: wrapper defects - scheduler timeout, tool loading, and YAML surface

- Replace signal.SIGALRM with ThreadPoolExecutor timeout in AgentScheduler (fixes #1738)
- Add thread safety with _stats_lock for execution counters
- Consolidate tool loading through ToolResolver, remove duplicate _extract_tool_classes
- Add YAML surface for handoff, web, and web_fetch CLI features
- Ensure CLI flags propagate to YAML-driven agent configurations

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

* fix: restore YAML CLI parity extraction and scheduler timeout behavior

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/005274fc-241b-4332-8eca-96b9f814a5b8

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

* test: simplify scheduler timeout regression fixture

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/005274fc-241b-4332-8eca-96b9f814a5b8

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

* test: make scheduler delay intent explicit

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/005274fc-241b-4332-8eca-96b9f814a5b8

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

* test: replace timeout threshold literal with named variable

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/005274fc-241b-4332-8eca-96b9f814a5b8

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

* test: document scheduler timeout threshold buffer

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/005274fc-241b-4332-8eca-96b9f814a5b8

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

* test: use timeout-relative threshold for scheduler regression

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/005274fc-241b-4332-8eca-96b9f814a5b8

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

* fix: guarantee scheduler timeout executor cleanup

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/005274fc-241b-4332-8eca-96b9f814a5b8

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

* test: name scheduler timing buffer constant

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/005274fc-241b-4332-8eca-96b9f814a5b8

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

* fix: resolve reviewer issues - timeout blocking, BaseTool import, boolean conversion

- Fix ThreadPoolExecutor timeout blocking by using non-blocking shutdown
- Replace BaseTool import error with safe tool instantiation pattern
- Fix case-sensitive boolean conversion for handoff_detect_cycles
- Consolidate duplicate tool loading code in ToolResolver
- Improve per-file error isolation in directory tool loading

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-30 07:05:30 +01:00
3f26bf166c fix: correct callback signature mismatch in multi-agent startup (fixes #1730) (#1740)
* fix: correct callback signature mismatch in multi-agent startup (fixes #1730)

- Fix composed_on_task_start to accept both task and task_id parameters
- Fix composed_on_task_complete to accept both task and task_output parameters
- Ensures callbacks receive the correct number of arguments as expected
- Resolves AgentTeam startup error with function signature mismatch

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

* fix: forward correct parameters to verbose callbacks (fixes verbose display issue)

- Pass task_id to verbose_task_start_callback(task, task_id)
- Pass task_output to verbose_task_complete_callback(task, task_output)
- Resolves silent TypeError in verbose mode identified by all reviewers

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

* fix: forward verbose callback args and add regression test

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/bd0c3ea8-33b8-4148-b703-2c368de0aa14

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-30 07:04:06 +01:00
ab19a13cc4 fix: make deduplicate_chunks and build_context handle SearchResultItem objects (#1739)
* fix: make deduplicate_chunks and build_context handle SearchResultItem objects

- Add _extract_value helper to handle both dict and SearchResultItem objects
- Update type hints to accept Union[Dict[str, Any], Any] in context functions
- Maintain full backward compatibility with existing dict-based usage
- Fix AttributeError when SearchResultItem objects are passed to .get() method

Fixes #1732

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

* fix: improve SearchResultItem handling in rag context builder

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/fbaed72c-c0f6-4426-8cb4-09c3542f499f

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

* refactor: tighten source fallback semantics in rag context

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/fbaed72c-c0f6-4426-8cb4-09c3542f499f

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

* refactor: dedupe metadata fallback extraction in rag context

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/fbaed72c-c0f6-4426-8cb4-09c3542f499f

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

* test: add comprehensive SearchResultItem test coverage for RAG context module

- Add tests for _extract_value and _extract_metadata_value helpers
- Add tests for SearchResultItem deduplication functionality
- Add tests for mixed dict/SearchResultItem format handling
- Add tests for source/filename fallback behavior from top-level attributes
- Verify metadata preference over top-level attributes works correctly

Ensures all reviewer feedback scenarios are properly tested.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-30 07:03:16 +01:00
28d487b66d fix: resolve three high-impact gaps in praisonai wrapper layer (#1736)
* fix: resolve three high-impact gaps in praisonai wrapper layer (fixes #1735)

- Fix blocking ollama serve calls: Replace subprocess.run(['ollama', 'serve'])
  with proper daemon management across 4 files. Created shared _ollama.py
  helper with non-blocking daemon startup and connection checking.

- Fix multi-tenant unsafe tool resolver: Resolve paths eagerly in constructor
  instead of lazy CWD binding. Add reset_default_resolver() function to clear
  process-level cache, following _framework_availability.invalidate() pattern.

- Fix sync-only auto-generator: Add async API with _astructured_completion(),
  agenerate() methods, and proper async context managers (__aenter__/__aexit__).
  Remove unreliable __del__ cleanup in favor of explicit close() and aclose().

All fixes maintain backward compatibility while adding async-safe and
multi-tenant-safe alternatives as required by project architecture.

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

* fix(auto): close sync client in async cleanup path

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/55e92785-fa2d-40bb-b67a-0ef213025ab3

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

* fix(auto): avoid blocking event loop during async close

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/55e92785-fa2d-40bb-b67a-0ef213025ab3

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

* refactor(auto): clarify async cleanup variable naming

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/55e92785-fa2d-40bb-b67a-0ef213025ab3

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-30 07:02:22 +01:00
MervinPraison a06239fb15 docs: auto-update api.md [skip ci] 2026-05-30 06:00:55 +00:00
dabe2cf671 feat: integration follow-up - Pattern C CLI, L3 pages, aiui 0.3.121, cross-repo CI (#1718)
* feat: integration follow-up - Pattern C CLI, L3 pages, aiui 0.3.121, cross-repo CI (fixes #1717)

Comprehensive integration follow-up implementing Pattern B→C transition:

**Tier 1 - Release & CI:**
- Pin aiui>=0.3.121,<0.4 across all optional dependencies (8 occurrences)
- Add cross-repo CI workflow testing PraisonAI + PraisonAIUI integration
- Create RELEASE_INTEGRATION.md with version matrix and troubleshooting
- Include optional agentic testing job for end-to-end validation

**Tier 2 - Pattern C & CLI:**
- Add configure_host(style=...) parameter with dashboard/chat options
- Implement 'praisonai serve ui-gateway' command for Pattern C
- Export run_integrated_gateway in main package API
- Add sync wrapper for async gateway function

**Tier 3 - L3 Pages & Infrastructure:**
- Register L3 dashboard pages (workflow-runs, bot-health)
- Add context_paths parameter for AGENTS.md-style injection
- Upgrade bridge failure logs from debug to warning level
- Set up framework for backend slot injection

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

* fix: critical issues in Pattern C UI-Gateway integration

- Fix TypeError: remove unsupported agents_file parameter from CLI call
- Align default ports: change gateway_host.py default from 8080 to 8765
- Add **kwargs to configure_host for resilience against extra arguments
- Revert log levels: warning back to debug for optional dependencies

Fixes review feedback from gemini-code-assist

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

* fix: address PR review findings for ui-gateway and integration CI

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/3ee145c5-72dc-4884-9840-c85ba238e718

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

* test: tighten ui-gateway assertions and remove test magic number

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/3ee145c5-72dc-4884-9840-c85ba238e718

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

* Delete RELEASE_INTEGRATION.md

* fix: correct path to PraisonAIUI in cross-repo CI workflow

Both cross-repo-integration and agentic-integration jobs were using
cd ../../PraisonAIUI which resolves to PraisonAI/PraisonAIUI (doesn't exist).
Changed to cd ../../../PraisonAIUI to correctly reach the sibling checkout
at /home/runner/work/PraisonAI/PraisonAI/PraisonAIUI.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-30 07:00:29 +01:00
MervinPraison 619787c9da docs: auto-update feature parity trackers [skip ci] 2026-05-30 06:00:01 +00:00
2fa6a6efd0 fix: RAG filtering error with multiple where conditions (fixes #1728) (#1729)
* fix: RAG filtering with multiple where conditions using ChromaDB  operator

- Fix ChromaKnowledgeAdapter to handle multiple filter conditions properly
- Use ChromaDB's  operator when len(where_filter) > 1
- Resolves validation error: len(where) != 1 when filtering by user_id, agent_id, run_id
- Maintains backward compatibility with single condition filters
- Fixes #1728

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

* test: add Chroma where-filter coverage for single and multi conditions

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/49aeab8d-0c2b-4a87-b55a-e30f79086d29

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

* fix: add logging to ChromaDB search exception handler

Improves debugging for ChromaDB query failures by logging warnings
instead of silently returning empty results.

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-30 06:59:37 +01:00
8acf77c531 fix: DbSessionAdapter.clear_session must purge persisted messages (#1737)
clear_session only cleared the in-memory cache while BotSessionManager
and managed agents rewrite history via clear_session + add_message.
Stale DB rows were reloaded on the next on_agent_start, resurrecting
trimmed or cleared chat history.

- Call conversation.delete_messages when available
- Reset session tracking and skip stale on_agent_start hydration
- Add regression test for bot history trim roundtrip

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-25 09:15:23 +01:00
5ff79d39c9 fix: prevent session message loss in LocalManagedAgent._persist_state (#1727)
* fix: prevent session message loss in LocalManagedAgent._persist_state

Use update_session_metadata (locked read-modify-write) instead of
get_session + _save_session, which could overwrite messages written
concurrently via add_message on another store instance.

Adds regression test mirroring session store race coverage from #1724.

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

* fix: preserve legacy _persist_state fallback when session is absent

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/4d08ce0a-1b7b-4e50-b841-6a08416f2f53

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

---------

Co-authored-by: Cursor Agent <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-23 10:29:01 +01:00
6902147f13 fix: architectural gaps in wrapper layer execution paths (#1726)
* fix: architectural gaps in wrapper layer execution paths

- Add per-name resolution caching to ToolResolver for performance
- Replace eager 17-tool resolution with demand-driven YAML parsing
- Fix InteractiveRuntime lifecycle safety in exception paths
- Remove dead framework methods (_run_autogen, _run_crewai, etc.)

Fixes #1725

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

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

* fix: resolve critical architectural gaps identified in code review

- Fix lock-order inversion in ToolResolver.resolve() to prevent deadlock
- Remove PRAISONAI_TOOLS_AVAILABLE gating from YAML tool resolution
- Preserve partial tool successes when individual tool initialization fails
- Add graceful degradation for common InteractiveRuntime startup errors

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: Claude <[email protected]>
Co-authored-by: Mervin Praison <[email protected]>
2026-05-23 09:54:56 +01:00
321b6b28a9 fix: prevent session message loss in set_agent_info and clear_session (#1724)
* fix: prevent session message loss in set_agent_info and clear_session

Apply locked read-modify-write (same pattern as update_session_metadata)
to set_agent_info, clear_session, and set_gateway_info so stale in-memory
copies cannot overwrite messages written by add_message on another instance.

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

* test: add regression test for set_gateway_info message preservation

Addresses Copilot's suggestion to ensure set_gateway_info also preserves
messages when racing with concurrent message writes from another instance.
Completes test coverage for all four methods migrated to _modify_session_locked.

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-05-22 13:21:31 +01:00
1c5b58aa13 fix: async-safety gaps in praisonai wrapper (#1692)
* fix: async-safety gaps in praisonai wrapper

- Replace InteractiveRuntime separate event loop with shared _async_bridge
- Add proper cancellation to run_sync timeouts to prevent resource leaks
- Add timeout wrapper at wrapper boundary for defense-in-depth tool_timeout
- Remove deprecated run_async in favor of consistent run_sync usage

Fixes #1662

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

* fix: resolve ThreadPoolExecutor blocking and Python 3.10 TimeoutError compatibility

- Fix ThreadPoolExecutor context manager that defeated timeout purpose
- Add explicit concurrent.futures.TimeoutError handling for Python 3.10 compat
- Ensure consistent JSON return types for timeout errors
- Use shutdown(wait=False) to prevent blocking on timeout

Addresses reviewer feedback from Copilot on PR #1692

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

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

* fix: prevent sync timeout wrapper from blocking and harden run_sync cancellation

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/91864b65-201b-4168-8287-2c6e31d70c63

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

* Changes before error encountered

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/91864b65-201b-4168-8287-2c6e31d70c63

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: Claude <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-22 13:20:42 +01:00
Mervin Praison 71834d39d7 Delete knowledge.db 2026-05-22 10:28:34 +01:00
MervinPraison 6971d8ecc9 docs: auto-update feature parity trackers [skip ci] 2026-05-22 07:32:16 +00:00
8348c2b7ab feat: add kanban protocols and hook events (praisonai-agents core SDK) (#1721)
* feat: add kanban protocols and hook events for praisonaiagents core SDK

- Add KanbanStoreProtocol in praisonaiagents/kanban/protocols.py
- Add VALID_KANBAN_STATUSES constant matching PraisonAIUI columns
- Add 6 kanban hook events to HookEvent enum (KANBAN_TASK_*)
- Add KanbanHookInput dataclass for typed payloads
- Add comprehensive unit tests covering all functionality
- Follow AGENTS.md protocol-driven design (protocols in core, implementations in wrapper)
- No new dependencies or heavy implementations

Fixes #1719

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

* test: fix HookEvent serialization assertion for Enum semantics

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/5480309b-3d48-4bd9-b55f-89dc11e39468

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

* test: verify HookEvent JSON serialization round-trip

Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/5480309b-3d48-4bd9-b55f-89dc11e39468

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

* fix: split kanban protocol optional methods into extension protocols

Fixes CodeRabbit finding about @runtime_checkable protocols requiring
all declared methods at runtime. Splits optional methods (add_comment,
link_tasks, unlink_tasks) into separate extension protocols:

- KanbanCommentingProtocol for commenting functionality
- KanbanLinkingProtocol for task relationship functionality

This allows stores to implement core KanbanStoreProtocol without
breaking isinstance checks, while optionally implementing extension
protocols for additional features.

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

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

---------

Co-authored-by: praisonai-triage-agent[bot] <272766704+praisonai-triage-agent[bot]@users.noreply.github.com>
Co-authored-by: MervinPraison <[email protected]>
Co-authored-by: copilot-swe-agent[bot] <[email protected]>
Co-authored-by: MervinPraison <[email protected]>
2026-05-22 08:31:49 +01:00
praison 9cfc636a92 Release v4.6.46 2026-05-21 23:24:42 +01:00
Mervin Praison b43e93c502 Delete test_capability_implementation.py 2026-05-21 23:21:50 +01:00
Mervin Praison b09450d9dc Delete test_backward_compatibility.py 2026-05-21 23:21:41 +01:00
Mervin Praison 710d607f6e Merge pull request #1705 from edlng/add-valkey
feat: Add Valkey Integration
2026-05-21 23:18:17 +01:00
MervinPraison a7910c2eb0 docs: auto-update api.md [skip ci] 2026-05-21 20:39:24 +00:00
Mervin Praison c98e1a1972 Merge pull request #1710 from MervinPraison/claude/issue-1697-20260520-0959
feat: implement Hermes/OpenClaw skill metadata capability gates
2026-05-21 21:38:56 +01:00
Mervin Praison aee9552dd0 Delete docs/skill_capability_gates.md 2026-05-21 19:54:27 +01:00
MervinPraison 6507b8e7ef docs: auto-update api.md [skip ci] 2026-05-21 18:54:06 +00:00
Mervin Praison 50652531e7 Merge pull request #1711 from MervinPraison/claude/issue-1696-20260520-0959
fix: enhance BaseTool schema validation for OpenAI compatibility
2026-05-21 19:53:34 +01:00
Mervin Praison a949579759 Merge pull request #1713 from MervinPraison/claude/issue-1694-20260520-0812
fix: resolve critical gaps in praisonai wrapper layer
2026-05-21 19:51:11 +01:00
Mervin Praison 79702ba1ff Merge pull request #1709 from MervinPraison/cursor/critical-correctness-bugs-989e
fix: prevent session message loss in update_session_metadata
2026-05-21 19:50:17 +01:00
MervinPraison e9b75bfef9 docs: auto-update feature parity trackers [skip ci] 2026-05-21 13:51:06 +00:00
Mervin Praison 982b7dfe4f Merge pull request #1712 from MervinPraison/claude/issue-1695-20260520-0959
fix: add UTF-8 encoding for Windows skills subsystem files (fixes #1695)
2026-05-21 14:50:21 +01:00
MervinPraison f372821b9e docs: auto-update api.md [skip ci] 2026-05-21 10:43:07 +00:00
praison 445003042e Release v4.6.45 2026-05-21 11:42:37 +01:00
copilot-swe-agent[bot]andMervinPraison 00b40e8cc4 fix: address review findings for capability gates
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/b4c856b3-93a3-4a2b-ad6c-bb90ef413121

Co-authored-by: MervinPraison <[email protected]>
2026-05-21 10:02:52 +00:00
copilot-swe-agent[bot]andMervinPraison 6dca959eda fix: correct deployment scheduler parser import and add regression test
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/2b3a43a6-84a1-42f9-bd87-2a92ac3188e0

Co-authored-by: MervinPraison <[email protected]>
2026-05-21 10:02:28 +00:00
copilot-swe-agent[bot]andMervinPraison 5ba8782bf7 fix: return clear UTF-8 decode errors from skill validator
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/11761ef3-3173-426f-b016-9fee9fd66a1e

Co-authored-by: MervinPraison <[email protected]>
2026-05-21 10:02:07 +00:00
copilot-swe-agent[bot]andMervinPraison a2402063f8 fix: handle non-UTF8 SKILL.md decoding errors in validator
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/11761ef3-3173-426f-b016-9fee9fd66a1e

Co-authored-by: MervinPraison <[email protected]>
2026-05-21 09:59:32 +00:00
praisonai-triage-agent[bot]andMervin Praison 302bbcbec2 perf: optimize BaseTool validation per Gemini review
- Simplify required field validation logic (line 307)
- Move get_tool_schema import outside loop for better performance
- Use set for O(n) duplicate detection instead of O(n²) list lookup

Addresses valid performance issues identified in PR review while
maintaining backward compatibility and test coverage.

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

Co-authored-by: Mervin Praison <[email protected]>
2026-05-21 09:57:20 +00:00
praisonai-triage-agent[bot]andMervin Praison ac309e99ea fix: address reviewer feedback on skill capability gates
- Fix hardcoded paths in test scripts to use relative paths
- Treat missing environment variables as errors in STRICT mode
- Add support for comma-separated strings in requirement parsing

Co-authored-by: Mervin Praison <[email protected]>
2026-05-21 09:56:28 +00:00
praisonai-triage-agent[bot]andMervin Praison 727ee626c8 fix: catch UnicodeDecodeError in skills validator
Validator should gracefully handle encoding errors and return
validation errors rather than crashing. Fixes issue found by
code review.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-21 09:56:15 +00:00
Mervin Praison 1dc61d3946 Merge pull request #1706 from MervinPraison/feat/aiui-integration-host
feat: PraisonAIUI host integration (Patterns B/C) + core protocol bridges
2026-05-21 10:49:26 +01:00
praisonai-triage-agent[bot]andMervin Praison 50a0399f1c fix: handle FileLock acquisition failure in context manager
CodeRabbit identified a critical security bug where FileLock.__enter__()
didn't check if acquire() succeeded, potentially allowing unlocked
execution. Fixed to properly raise IOError when lock acquisition fails.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-21 09:30:11 +00:00
MervinPraison 40bfd66797 docs: auto-update api.md [skip ci] 2026-05-21 09:28:35 +00:00
Mervin Praison 0da4a90c80 Merge pull request #1708 from MervinPraison/claude/issue-1707-20260521-0816
fix: consolidate framework availability detection and add simple Python API
2026-05-21 10:28:04 +01:00
Cursor AgentandMervin Praison dc75c51fea fix: prevent session message loss in update_session_metadata
Use locked read-modify-write when merging session stats so a stale
in-memory copy cannot overwrite messages written by add_message.
Adds a regression test for the two-store race introduced with UI
session telemetry.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-21 09:18:52 +00:00
praisonai-triage-agent[bot]andMervin Praison b95a7f6ded fix: address reviewer feedback - explicit arun signature, remove unused imports, add framework validation
- Make arun() signature explicit instead of opaque **kwargs
- Remove unused importlib.util import from inbuilt_tools/__init__.py
- Add trailing newline to _entrypoint.py
- Add framework validation for explicit framework param like CLI does

Co-authored-by: Mervin Praison <[email protected]>
2026-05-21 08:31:41 +00:00
praisonai-triage-agent[bot]andMervinPraison 92953fdbe6 fix: consolidate framework availability detection and add simple Python API (fixes #1707)
- Consolidate framework availability detection in _framework_availability.py
  - Add missing probes for acp, rich, gradio, unsloth
  - Replace ~30 direct find_spec calls with centralized is_available()
  - Update cli/main.py, tool_resolver.py, inbuilt_tools, acp, console.py

- Add simple Python API entry points
  - Create _entrypoint.py with run() and arun() functions
  - Export in __init__.py with lazy loading
  - Use same LLM endpoint resolution as CLI for consistency
  - Enables one-liner: praisonai.run('agents.yaml')

Addresses Gaps 2 and 3 from architectural review.
Gap 1 (dead _run_* methods) requires adapter functionality porting first.

Co-authored-by: MervinPraison <[email protected]>
2026-05-21 08:22:12 +00:00
MervinPraison b3cd7ecfd0 docs: auto-update feature parity trackers [skip ci] 2026-05-21 06:59:29 +00:00
copilot-swe-agent[bot]andMervinPraison 01ea0f5a05 fix: address gemini review findings in tool emitter + usage summary
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/c2c4c0e0-0cb7-4608-ab5e-3a259f9bd635

Co-authored-by: MervinPraison <[email protected]>
2026-05-21 06:58:47 +00:00
praisonai-triage-agent[bot]andMervin Praison eddcae8d31 fix: replace fragile name mangling with proper stream_emitter property access
- Replace getattr(self, '_Agent__stream_emitter', None) with self.stream_emitter
- Optimize token summary calculation with single-pass algorithm
- Fix inconsistent total_tokens calculation in telemetry protocols

Co-authored-by: Mervin Praison <[email protected]>
2026-05-21 06:55:55 +00:00
praison 71f891003e Add PraisonAIUI host integration layer (Patterns B/C) with core protocol bridges.
Introduces configure_host/build_host_app, optional L1 bridges (hooks, workflows, usage, approvals, schedules), refactors bundled default apps, and adds session/telemetry protocols plus integration tests so aiui can run standalone or wired via set_backend injection.
2026-05-21 07:49:50 +01:00
praison 13750a4ccd Release v4.6.44 2026-05-20 20:45:21 +01:00
Mervin Praison c9e1d1a202 Merge pull request #1704 from MervinPraison/claude/issue-1703-20260520-1507
fix: Agent.achat hanging - unified dispatch missing response handling
2026-05-20 20:43:31 +01:00
praisonai-triage-agent[bot]andMervin Praison 5515290329 refactor: extract shared guardrail validation logic to reduce code duplication
- Extract _validate_with_guardrail() method containing common validation logic
- Simplified _apply_guardrail_with_retry() and _aapply_guardrail_with_retry() to use shared validation
- Reduced code duplication from ~126 lines to ~25 lines of shared core logic
- Maintains identical functionality while improving maintainability

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

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 19:41:18 +00:00
praisonai-triage-agent[bot]andMervin Praison cb1cefd94b fix: resolve async guardrail retry bug and improve response handling
- Fixed critical bug in _aapply_guardrail_with_retry line 4732
- Changed await self._achat_completion to await self._execute_unified_achat_completion
- _achat_completion expects response object, not message list
- Added response type safety guards (hasattr checks, isinstance fallbacks)
- Prevents AttributeError crashes during async guardrail retries
- Maintains both sync and async guardrail methods for architectural clarity

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

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 19:04:14 +00:00
praisonai-triage-agent[bot]andMervin Praison a90666a19f fix: resolve async guardrails and response.choices access issues in unified dispatch
- Add proper hasattr guards for response.choices access to prevent crashes when dispatcher returns plain strings
- Fix stale reflection context by properly updating reflection_messages with new responses
- Create async _aapply_guardrail_with_retry method to prevent event loop blocking
- Update unified async dispatch path to use async guardrails while preserving sync guardrails in sync paths
- Resolves critical issues identified in Greptile review while maintaining backward compatibility

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 16:21:45 +00:00
Mervin Praison 89df701c2a Merge branch 'main' into claude/issue-1703-20260520-1507 2026-05-20 16:51:34 +01:00
praisonai-triage-agent[bot]andMervin Praison 480848c26d fix: implement async self-reflection in unified dispatch to prevent infinite loop
- Add complete self-reflection logic to unified achat dispatch path
- Prevent infinite loop when self.self_reflect=True with unified dispatch
- Mirror sync implementation with proper async/await patterns
- Handle OpenAI client unavailability gracefully for custom LLMs
- Add proper return statements to break while loop
- Maintain reflection limits (min_reflect, max_reflect)
- Include comprehensive error handling and retry logic

Fixes infinite hanging issue for achat with self-reflection enabled.

Co-authored-by: Mervin Praison <[email protected]>
2026-05-20 15:33:16 +00:00
praisonai-triage-agent[bot]andMervinPraison a064a286f3 fix: Agent.achat hanging - unified dispatch missing response handling (fixes #1703)
- Fix execute_tool_async issue in _execute_unified_achat_completion
- Add proper response processing after unified dispatch call
- Mirror sync _chat_impl behavior for content extraction, history update
- Add guardrails, callbacks, and proper return to exit while loop
- Prevents infinite loop by handling response and returning result

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 15:11:24 +00:00
praisonai-triage-agent[bot] 1d78204b0b feat: implement Hermes/OpenClaw skill metadata capability gates
This implements comprehensive capability gate enforcement for Agent Skills as requested in issue #1697.

Key Features:
- SkillRequirements data model with support for requires_* frontmatter
- CapabilityValidator with layered enforcement levels (disabled/warn/strict)
- Integration with existing SkillManager and discovery pipeline
- CLI doctor diagnostics for missing dependencies
- Feature flag control via SKILL_CAPABILITY_ENFORCEMENT env var
- Full backward compatibility with existing skills

Architecture:
- Core SDK implementation in praisonaiagents following protocol-driven design
- Extends existing skills system without breaking changes
- Tool registry integration for automatic capability detection
- Lazy loading and zero performance impact when disabled

Enforcement Levels:
- disabled: No enforcement (legacy behavior)
- telemetry: Log missing deps only
- warn: Show warnings but allow activation (default)
- strict: Block skill activation until deps satisfied

Skills can now declare requirements in SKILL.md frontmatter.

CLI usage:
- praisonai doctor skills - Check capability status
- SKILL_CAPABILITY_ENFORCEMENT=strict - Enable strict enforcement

Fixes #1697
2026-05-20 10:07:34 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] bba5d43cc1 fix: enhance BaseTool schema validation for OpenAI compatibility (fixes #1696)
- Enhanced BaseTool.validate() with OpenAI schema compatibility checks
- Added validate_schema_roundtrip() for JSON serialization verification
- Added validate_tool_schema_consistency() for tool list validation
- Enhanced @tool decorator with automatic validation at creation time
- Added comprehensive test suite for schema round-trip validation
- Improved error messages with specific remediation guidance

Ensures robust tool schema handling while maintaining backward compatibility.
All tools now validate against OpenAI function calling format requirements.

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

Co-authored-by: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-20 10:05:10 +00:00
praisonai-triage-agent[bot]andpraisonai-triage-agent[bot] c2dfa859c3 fix: add UTF-8 encoding for Windows skills subsystem files (fixes #1695)
- Fix parser.py: add encoding='utf-8' to skill_md.read_text()
- Fix validator.py: add encoding='utf-8' to skill_md.read_text()
- Fix loader.py: add encoding='utf-8' to all read_text() calls for SKILL.md body, scripts, and references
- Add comprehensive UTF-8 tests to verify fix works across all file types
- Prevents UnicodeDecodeError on Windows when SKILL.md contains non-ASCII characters like em dashes, smart quotes, arrows, or non-Latin scripts

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

Co-Authored-By: praisonai-triage-agent[bot] <praisonai-triage-agent[bot]@users.noreply.github.com>
2026-05-20 10:04:31 +00:00
praisonai-triage-agent[bot]andMervinPraison bb8a56e33c fix: resolve critical gaps in praisonai wrapper layer
Fixes three architectural issues that violated DRY and "safe by default" principles:

1. Shadow scheduler module issue:
   - Remove dead scheduler.py that was shadowed by scheduler/ package
   - Replace mock create_scheduler with real DeploymentScheduler factory
   - CLI --deploy --schedule now uses actual deployment logic instead of MockAgent

2. Disjoint tool registry/resolver issue:
   - Connect ToolRegistry to ToolResolver resolution chain
   - Tools registered via register_function() are now discoverable by YAML resolver
   - Add ToolRegistry as step 2 in resolution order (after local tools.py)

3. Incompatible async-bridging strategies:
   - Standardize on _async_bridge.run_sync() as single source of truth
   - Remove per-call asyncio.run() loops from agent_tools.py
   - Fix InteractiveRuntime to run on shared background loop
   - Eliminate dormant event loop anti-pattern in praisonai_adapter.py

All fixes follow protocol-driven design principles from AGENTS.md.

Fixes #1694

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 08:17:51 +00:00
236 changed files with 18119 additions and 3247 deletions

No files matched your search

@@ -0,0 +1,158 @@
name: Cross-Repo Integration Tests
on:
push:
branches: [ main ]
paths:
- 'src/praisonai/praisonai/integration/**'
- 'src/praisonai/tests/integration/test_aiui_*'
- 'src/praisonai/pyproject.toml'
pull_request:
branches: [ main ]
paths:
- 'src/praisonai/praisonai/integration/**'
- 'src/praisonai/tests/integration/test_aiui_*'
- 'src/praisonai/pyproject.toml'
workflow_dispatch:
jobs:
cross-repo-integration:
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Checkout PraisonAI
uses: actions/checkout@v4
with:
path: PraisonAI
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout PraisonAIUI
uses: actions/checkout@v4
with:
repository: MervinPraison/PraisonAIUI
path: PraisonAIUI
ref: main
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
# Install PraisonAI packages in development mode
cd PraisonAI/src/praisonai-agents
pip install -e .
cd ../praisonai
pip install -e ".[ui,dev]"
# Install PraisonAIUI in development mode
cd ../../../PraisonAIUI
pip install -e .
- name: Verify versions
run: |
python -c "import praisonaiagents; print(f'praisonaiagents: {praisonaiagents.__version__}')"
python -c "import praisonai; print(f'praisonai: {praisonai.__version__}')"
python -c "import praisonaiui; print(f'praisonaiui: {praisonaiui.__version__}')"
- name: Run PraisonAI integration tests
run: |
cd PraisonAI/src/praisonai
python -m pytest tests/integration/test_aiui_* -v --timeout=300
- name: Run PraisonAIUI integration tests
run: |
cd PraisonAIUI
if [ -f tests/integration/test_agentic_roundtrip.py ]; then
python -m pytest tests/integration/test_agentic_roundtrip.py -v --timeout=300
fi
if [ -f tests/test_feature_sdk_backends.py ]; then
python -m pytest tests/test_feature_sdk_backends.py -v --timeout=300
fi
- name: Test Pattern C CLI
run: |
cd PraisonAI/src/praisonai
# Test that ui-gateway command exists
python -m praisonai serve --help | grep ui-gateway
- name: Test public API exports
run: |
cd PraisonAI/src/praisonai
python -c "from praisonai import run_integrated_gateway; print('✓ run_integrated_gateway imported')"
python -c "from praisonai import configure_host; print('✓ configure_host imported')"
- name: Integration smoke test
env:
PRAISONAI_TEST_MODE: "1"
PYTHONPATH: ${{ github.workspace }}/PraisonAI/src/praisonai:${{ github.workspace }}/PraisonAI/src/praisonai-agents:${{ github.workspace }}/PraisonAIUI
run: |
cd PraisonAI/src/praisonai
python -c "
import sys
sys.path.insert(0, '.')
from praisonai.integration.host_app import configure_host
from praisonai import run_integrated_gateway
print('✓ Integration imports successful')
# Test configure_host with new parameters
try:
configure_host(
style='dashboard',
context_paths=['AGENTS.md'],
title='Test App'
)
print('✓ configure_host with new parameters works')
except Exception as e:
print(f'× configure_host failed: {e}')
sys.exit(1)
"
# Optional job with API key for agentic tests (if secret is available)
agentic-integration:
runs-on: ubuntu-latest
timeout-minutes: 30
if: ${{ vars.RUN_AGENTIC_TESTS == 'true' }}
needs: cross-repo-integration
steps:
- name: Checkout PraisonAI
uses: actions/checkout@v4
with:
path: PraisonAI
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
- name: Checkout PraisonAIUI
uses: actions/checkout@v4
with:
repository: MervinPraison/PraisonAIUI
path: PraisonAIUI
ref: main
- name: Set up Python
uses: actions/setup-python@v4
with:
python-version: '3.11'
- name: Install dependencies
run: |
python -m pip install --upgrade pip
cd PraisonAI/src/praisonai-agents
pip install -e .
cd ../praisonai
pip install -e ".[ui,dev]"
cd ../../../PraisonAIUI
pip install -e .
- name: Run agentic integration test
env:
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
run: |
cd PraisonAI/src/praisonai
if [ -n "$OPENAI_API_KEY" ]; then
python -m pytest tests/integration/test_aiui_host_agentic.py -v --timeout=600
else
echo "OPENAI_API_KEY not available, skipping agentic tests"
fi
+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)
+28 -1
View File
@@ -40,6 +40,7 @@ Methods:
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>() -> Optional[bool]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value: Optional[bool]) -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">clone_for_channel</a>() -> 'Agent'</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">close</a>() -> None</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>() -> Optional[Any]</code>
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>() -> Optional[Any]</code>
@@ -171,6 +172,7 @@ Methods:
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">safe_run</a>(**kwargs) -> ToolResult</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate</a>() -> bool</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_class</a>() -> bool</code>
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_schema_roundtrip</a>() -> bool</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">__call__</a>(*args, **kwargs) -> Any</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">check_availability</a>() -> tuple[bool, str]</code>
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">injected_params</a>() -> Dict[str, Any]</code>
@@ -477,8 +479,10 @@ Methods:
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str) -> dict</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skill</a>(name: str) -> Optional[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
@@ -487,6 +491,7 @@ Methods:
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str) -> dict</code>
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
@@ -602,6 +607,13 @@ Methods:
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">set_tool_budget</a>(tool_name: str, max_tokens: int, protected: bool = False) -> None</code>
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">truncate_tool_output</a>(tool_name: str, output: str) -> str</code>
# UI
Types:
```python
from praisonaiagents import A2UI
```
# Config
Types:
@@ -756,7 +768,7 @@ Methods:
Types:
```python
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__, arun, run, run_integrated_gateway
```
# CLI
@@ -861,6 +873,7 @@ Methods:
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor skills <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai doctor troubleshoot <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
* <code title="cli">praisonai endpoints list <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
@@ -892,11 +905,24 @@ Methods:
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway stop <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai gateway uninstall <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
* <code title="cli">praisonai github triage <a href="./src/praisonai/praisonai/cli/commands/github.py">--help</a></code>
* <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>
@@ -1054,6 +1080,7 @@ Methods:
* <code title="cli">praisonai serve status <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve stop <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve ui <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve ui-gateway <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai serve unified <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
* <code title="cli">praisonai session delete <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
* <code title="cli">praisonai session export <a href="./src/praisonai/praisonai/cli/commands/session.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.43" \
"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.43" \
"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.43" \
"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"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Minimal A2UI example — agent + send_a2ui_messages tool.
Install:
pip install praisonaiagents[a2ui]
Run:
python send_surface.py
For live rendering, use PraisonAIUI with AG-UI (/agui) — see integrate-a2ui-frontend docs.
"""
from praisonaiagents import Agent
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
agent = Agent(
name="ui-assistant",
instructions=(
"When asked for UI, call send_a2ui_messages with A2UI v0.9 JSON "
"(createSurface on surface id 'main')."
),
tools=[send_a2ui_messages],
)
if __name__ == "__main__":
result = agent.start("Show a simple welcome card on surface main")
print(result)
@@ -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:
+222
View File
@@ -0,0 +1,222 @@
# Multi-Channel Bot Deployment Guide
This guide explains how to set up multiple bots on the same platform (e.g., multiple Telegram bots) for different roles using PraisonAI, similar to Hermes-style AI workforces.
## Overview
PraisonAI supports running multiple bots on the same platform, each routing to different agents with specialized knowledge and capabilities. This is useful for:
- **Role-based bots**: `@company_cfo_bot`, `@company_ops_bot`, `@company_content_bot`
- **Department-specific agents**: Each with different knowledge brains and tool access
- **Workflow separation**: Different bots handle different types of requests
## Quick Start with Onboard Wizard
The easiest way to set up multiple channels is using the onboard wizard:
```bash
praisonai onboard
```
The wizard now supports:
1. **Multi-channel setup**: Add multiple bots for the same platform
2. **Role-based naming**: Automatically generates `TELEGRAM_<ROLE>_BOT_TOKEN` environment variables
3. **Agent routing**: Each channel routes to a different agent
### Onboard Wizard Flow
1. **Choose platform**: Start with your first platform (e.g., telegram)
2. **Add more channels**: Wizard asks "Add another bot channel?"
3. **Specify role**: Enter role name (e.g., "cfo", "ops", "content")
4. **Configure tokens**: Each channel gets its own environment variable
5. **Test connections**: Wizard tests each bot separately
## Environment Variable Convention
For multiple bots on the same platform, use this naming convention:
| Channel | Environment Variable | Bot Purpose |
|---------|---------------------|-------------|
| `telegram_cfo` | `TELEGRAM_CFO_BOT_TOKEN` | CFO-related tasks |
| `telegram_ops` | `TELEGRAM_OPS_BOT_TOKEN` | Operations tasks |
| `telegram_content` | `TELEGRAM_CONTENT_BOT_TOKEN` | Content creation |
| `telegram_support` | `TELEGRAM_SUPPORT_BOT_TOKEN` | Customer support |
### Setting Up Tokens
1. **Create bots in @BotFather**:
```text
/newbot
Bot name: Company CFO Bot
Username: @company_cfo_bot
-> Copy token
/newbot
Bot name: Company Ops Bot
Username: @company_ops_bot
-> Copy token
```
2. **Set environment variables**:
```bash
export TELEGRAM_CFO_BOT_TOKEN="123456789:ABC..."
export TELEGRAM_OPS_BOT_TOKEN="987654321:DEF..."
export TELEGRAM_CONTENT_BOT_TOKEN="456789123:GHI..."
```
3. **Or use `~/.praisonai/.env`**:
```env
TELEGRAM_CFO_BOT_TOKEN=123456789:ABC...
TELEGRAM_OPS_BOT_TOKEN=987654321:DEF...
TELEGRAM_CONTENT_BOT_TOKEN=456789123:GHI...
TELEGRAM_ALLOWED_USERS=123456789,987654321
```
## Example Configuration
See `multi-telegram-hermes-workforce.yaml` for a complete example.
### Key Features
1. **Multiple Channels**:
```yaml
channels:
telegram_cfo:
platform: telegram
token: ${TELEGRAM_CFO_BOT_TOKEN}
routes:
default: cfo
telegram_ops:
platform: telegram
token: ${TELEGRAM_OPS_BOT_TOKEN}
routes:
default: ops
```
2. **Specialized Agents**:
```yaml
agents:
cfo:
instructions: "You are a CFO agent. Help with financial analysis..."
model: gpt-4o-mini
ops:
instructions: "You are an ops agent. Help with system monitoring..."
model: gpt-4o-mini
```
## Validation with Doctor
Use the doctor command to validate your multi-channel setup:
```bash
praisonai doctor
```
The doctor checks for:
- ✅ **Unique tokens**: Each channel uses a different bot token
- ✅ **Naming convention**: Follows `PLATFORM_ROLE_BOT_TOKEN` pattern
- ✅ **Missing tokens**: All referenced environment variables are set
- ❌ **Duplicate tokens**: Same token used by multiple channels (fails)
## Deployment
### Start Gateway
```bash
# Start the multi-bot gateway
praisonai gateway start --config multi-telegram-hermes-workforce.yaml
# Or install as daemon
praisonai onboard # Wizard installs daemon automatically
praisonai gateway status
```
### Verify Setup
1. **Check health**: `curl http://127.0.0.1:8765/health`
2. **View dashboard**: `praisonai claw` → http://127.0.0.1:8082
3. **Test bots**: Send messages to each bot to verify routing
## Common Patterns
### Basic Multi-Role Setup
```yaml
# Single platform, multiple roles
channels:
telegram_support:
token: ${TELEGRAM_SUPPORT_BOT_TOKEN}
routes:
default: support_agent
telegram_sales:
token: ${TELEGRAM_SALES_BOT_TOKEN}
routes:
default: sales_agent
```
### Cross-Platform Workforce
```yaml
# Multiple platforms, multiple roles
channels:
telegram_cfo:
platform: telegram
token: ${TELEGRAM_CFO_BOT_TOKEN}
routes:
default: cfo
discord_ops:
platform: discord
token: ${DISCORD_OPS_BOT_TOKEN}
routes:
default: ops
slack_content:
platform: slack
token: ${SLACK_CONTENT_BOT_TOKEN}
routes:
default: content
```
## Troubleshooting
### Common Issues
1. **"Token is used by multiple channels"**
- Each bot needs its own unique token from @BotFather
- Check environment variables for duplicates
2. **"Channel skipped - no token"**
- Set the environment variable: `export TELEGRAM_CFO_BOT_TOKEN="..."`
- Or update `~/.praisonai/.env`
3. **"Bot not responding"**
- Verify token is correct: `praisonai doctor`
- Check bot is not already running elsewhere
- Ensure allowlist includes your user ID
### Debugging Commands
```bash
# Check configuration
praisonai doctor
# View logs
praisonai gateway logs
# Test connection
curl -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
http://127.0.0.1:8765/info
```
## Migration from Single-Bot Setup
If you currently have a single bot setup, you can add more channels:
1. **Run onboard wizard**: `praisonai onboard`
2. **Add new channels**: Wizard will preserve existing setup
3. **Update configuration**: New channels added to existing `bot.yaml`
4. **Restart gateway**: `praisonai gateway restart`
The wizard preserves backward compatibility with single-bot configurations.
+19
View File
@@ -0,0 +1,19 @@
# A2UI canvas agent — YAML example
#
# Usage:
# pip install praisonaiagents[a2ui]
# praisonai agent run --file a2ui-canvas.yaml --prompt "Show a welcome card"
#
# For live A2UI rendering, use PraisonAIUI (AG-UI / surfaces):
# pip install praisonaiui
# See examples/python/a2ui/send_surface.py and PraisonAIUI example 29.
agent:
name: "ui-assistant"
instructions: |
You are a helpful assistant. When the user asks for UI elements
(buttons, cards, forms), call send_a2ui_messages with valid A2UI v0.9 JSON.
Use surface id "main" unless told otherwise.
llm: gpt-4o-mini
tools:
- send_a2ui_messages
+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.
@@ -0,0 +1,66 @@
# Multi-Telegram Channel Hermes Workforce Example
# Generated by `praisonai onboard` with multi-channel support
# Start with: praisonai gateway start --config multi-telegram-hermes-workforce.yaml
gateway:
host: "127.0.0.1"
port: 8765
# Legacy single-agent block for backward compatibility
agent:
name: "assistant"
instructions: "You are a helpful AI assistant. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."
model: "gpt-4o-mini"
# Multi-agent workforce with different specialized roles
agents:
cfo:
instructions: "You are a CFO agent. You are a helpful AI assistant. Use your tools proactively to help with CFO-related tasks like financial analysis, budget planning, and cost optimization."
model: gpt-4o-mini
memory: false
ops:
instructions: "You are an ops agent. You are a helpful AI assistant. Use your tools proactively to help with ops-related tasks like system monitoring, deployment, and infrastructure management."
model: gpt-4o-mini
memory: false
content:
instructions: "You are a content agent. You are a helpful AI assistant. Use your tools proactively to help with content-related tasks like writing, editing, and content strategy."
model: gpt-4o-mini
memory: false
# Multiple Telegram channels - each with unique tokens and routing
channels:
telegram_cfo:
platform: telegram
token: ${TELEGRAM_CFO_BOT_TOKEN}
group_policy: mention_only
allowed_users: ${TELEGRAM_ALLOWED_USERS}
# auto_approve_tools: true
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
routes:
default: cfo
telegram_ops:
platform: telegram
token: ${TELEGRAM_OPS_BOT_TOKEN}
group_policy: mention_only
allowed_users: ${TELEGRAM_ALLOWED_USERS}
# auto_approve_tools: true
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
routes:
default: ops
telegram_content:
platform: telegram
token: ${TELEGRAM_CONTENT_BOT_TOKEN}
group_policy: mention_only
allowed_users: ${TELEGRAM_ALLOWED_USERS}
# auto_approve_tools: true
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
routes:
default: content
# Legacy routing block
routing:
default: assistant
+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"
@@ -343,6 +343,7 @@ _LAZY_IMPORTS = {
# UI (optional)
'AGUI': ('praisonaiagents.ui.agui', 'AGUI'),
'A2A': ('praisonaiagents.ui.a2a', 'A2A'),
'A2UI': ('praisonaiagents.ui.a2ui', 'A2UI'),
# Feature configs
'MemoryConfig': ('praisonaiagents.config.feature_configs', 'MemoryConfig'),
@@ -383,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'),
@@ -814,6 +816,6 @@ def __dir__():
# - Workflow, Task, Route, Parallel, Loop, Repeat, Pipeline, etc.
# - MCP, FlowDisplay, track_workflow, FastContext, etc.
# - Plan, PlanStep, TodoList, PlanningAgent, ApprovalCallback, etc.
# - RAG, RAGConfig, RAGResult, AGUI, A2A, etc.
# - RAG, RAGConfig, RAGResult, AGUI, A2A, A2UI, etc.
# - All telemetry, display, and utility functions
# ============================================================================
@@ -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
# ============================================================
@@ -1952,6 +1989,130 @@ Your Goal: {self.goal}
# Sandbox configuration - initialize SandboxMixin
super().__init__(sandbox=sandbox)
def __deepcopy__(self, memo: dict) -> "Agent":
"""Custom deepcopy that creates fresh threading primitives.
threading.RLock (self.__cache_lock) and threading.Lock (self._cost_lock)
cannot be deep-copied on CPython < 3.13. This hook deep-copies every
other attribute normally and replaces the locks with new instances so
that copy.deepcopy(agent) works in any Python version.
"""
import copy
cls = self.__class__
result = cls.__new__(cls)
memo[id(self)] = result
for k, v in self.__dict__.items():
if k in ("_Agent__cache_lock",):
object.__setattr__(result, k, threading.RLock())
elif k == "_cost_lock":
object.__setattr__(result, k, threading.Lock())
else:
object.__setattr__(result, k, copy.deepcopy(v, memo))
return result
def clone_for_channel(self) -> "Agent":
"""Return a fully independent copy of this agent for a gateway channel.
This method safely clones an agent for use in multi-channel scenarios
by avoiding deepcopy issues with tools/handoffs and creating fresh
instances of mutable objects like interrupt_controller.
Returns:
Agent: A new Agent instance with copied configuration but fresh
locks, interrupt controller, and shallow-copied tools.
"""
import copy
from ..tools.base import BaseTool
# Build kwargs for new Agent instance, copying core attributes
clone_kwargs = {
# Core identity
'name': self.name,
'role': self.role,
'goal': self.goal,
'backstory': self.backstory,
'instructions': self.instructions,
# LLM configuration
'llm': self.llm,
'base_url': getattr(self, 'base_url', None),
'api_key': getattr(self, 'api_key', None),
'auth': getattr(self, 'auth', None),
# Shallow copy tools to avoid deepcopy issues with nested objects
'tools': list(self.tools) if self.tools else None,
# Skip handoffs entirely - they shouldn't be shared across channels
# and can contain nested Agent instances that cause RLock issues
'handoffs': None,
# Feature configurations - check for actual stored config objects
'memory': getattr(self, '_memory_config', None),
'knowledge': getattr(self, '_knowledge_config', None),
'planning': getattr(self, '_planning_config', None),
'reflection': getattr(self, '_reflection_config', None),
'guardrails': getattr(self, '_guardrails_config', None),
'web': getattr(self, '_web_config', None),
'context': getattr(self, '_context_config', None),
'autonomy': getattr(self, '_autonomy_config', None),
'output': getattr(self, '_output_config', None),
'execution': getattr(self, '_execution_config', None),
'templates': getattr(self, '_template_config', None),
'caching': getattr(self, '_caching_config', None),
'hooks': getattr(self, '_hooks_config', None),
'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),
'parallel_tool_calls': getattr(self, 'parallel_tool_calls', False),
# CLI backend
'cli_backend': getattr(self, '_cli_backend', None),
# Create fresh interrupt controller to avoid shared state
'interrupt_controller': None, # Let new instance create its own
# Sandbox config
'sandbox': getattr(self, '_sandbox_config', None),
}
# Handle deprecated parameters for backward compatibility
import warnings
# Check for deprecated standalone attributes and emit warnings
deprecated_attrs = [
('allow_code_execution', 'execution'),
('code_execution_mode', 'execution'),
('auto_save', 'memory'),
('rate_limiter', 'execution'),
('allow_delegation', 'handoffs'),
('verification_hooks', 'autonomy')
]
for old_attr, new_param in deprecated_attrs:
if hasattr(self, old_attr):
value = getattr(self, old_attr)
if value is not None and value != False: # Skip None/False defaults
warnings.warn(
f"Deprecated attribute '{old_attr}' found in agent clone. "
f"Use '{new_param}=' parameter instead.",
DeprecationWarning,
stacklevel=2
)
# For basic compatibility, include in clone_kwargs if not already set
if old_attr == 'allow_code_execution' and clone_kwargs['execution'] is None:
from ..config.execution import ExecutionConfig
clone_kwargs['execution'] = ExecutionConfig(code_execution=value)
elif old_attr == 'auto_save' and clone_kwargs['memory'] is None:
from ..config.memory import MemoryConfig
clone_kwargs['memory'] = MemoryConfig(auto_save=value)
# Create new Agent instance
return self.__class__(**{k: v for k, v in clone_kwargs.items() if v is not None})
@property
def _telemetry(self):
"""Lazy-loaded telemetry instance for performance."""
@@ -4616,60 +4777,57 @@ Answer:"""
error=f"Agent guardrail validation error: {str(e)}"
)
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
"""Apply guardrail validation with retry logic.
Args:
response_text: The response to validate
prompt: Original prompt for regeneration if needed
temperature: Temperature for regeneration
tools: Tools for regeneration
Returns:
str: The validated response text or None if validation fails after retries
"""
def _validate_with_guardrail(self, response_text):
"""Validate response with guardrail. Returns (success, result, error)."""
if not self._guardrail_fn:
return response_text
return True, response_text, None
from ..main import TaskOutput
task_output = TaskOutput(
description="Agent response output",
raw=response_text,
agent=self.name
)
guardrail_result = self._process_guardrail(task_output)
if guardrail_result.success:
# Return the potentially modified result
if guardrail_result.result and hasattr(guardrail_result.result, 'raw'):
return True, guardrail_result.result.raw, None
elif guardrail_result.result:
return True, str(guardrail_result.result), None
else:
return True, response_text, None
else:
return False, None, guardrail_result.error
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
"""Apply guardrail validation with retry logic (sync version)."""
retry_count = 0
current_response = response_text
while retry_count <= self.max_guardrail_retries:
# Create TaskOutput object
task_output = TaskOutput(
description="Agent response output",
raw=current_response,
agent=self.name
)
success, result, error = self._validate_with_guardrail(current_response)
# Process guardrail
guardrail_result = self._process_guardrail(task_output)
if guardrail_result.success:
if success:
logging.info(f"Agent {self.name}: Guardrail validation passed")
# Return the potentially modified result
if guardrail_result.result and hasattr(guardrail_result.result, 'raw'):
return guardrail_result.result.raw
elif guardrail_result.result:
return str(guardrail_result.result)
else:
return current_response
return result
# Guardrail failed
if retry_count >= self.max_guardrail_retries:
raise Exception(
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
f"Last error: {guardrail_result.error}"
f"Last error: {error}"
)
retry_count += 1
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {guardrail_result.error}")
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
# Regenerate response for retry
try:
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {guardrail_result.error}. Please provide an improved response."
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
response = self._chat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
if response and response.choices:
content = response.choices[0].message.content
@@ -4678,10 +4836,46 @@ Answer:"""
raise Exception("Failed to generate retry response")
except Exception as e:
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
# If we can't regenerate, fail the guardrail
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
return current_response
async def _aapply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
"""Apply guardrail validation with retry logic (async version)."""
retry_count = 0
current_response = response_text
while retry_count <= self.max_guardrail_retries:
success, result, error = self._validate_with_guardrail(current_response)
if success:
logging.info(f"Agent {self.name}: Guardrail validation passed")
return result
# Guardrail failed
if retry_count >= self.max_guardrail_retries:
raise Exception(
f"Agent {self.name} guardrail retry failed: {e}"
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
f"Last error: {error}"
)
retry_count += 1
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
# Regenerate response for retry (async version)
try:
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
response = await self._execute_unified_achat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
if response and hasattr(response, 'choices') and response.choices:
content = response.choices[0].message.content
current_response = content.strip() if content else ""
elif isinstance(response, str):
current_response = response.strip()
else:
raise Exception("Failed to generate retry response")
except Exception as e:
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
return current_response
@@ -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,
@@ -2079,7 +2195,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Apply guardrail validation for custom LLM response
try:
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
@@ -2187,22 +2303,216 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_id=task_id,
response_format=response_format
)
return await self._finalize_unified_achat_response(
response=response,
original_prompt=original_prompt,
temperature=temperature,
tools=tools,
output_json=output_json,
output_pydantic=output_pydantic,
reasoning_steps=reasoning_steps,
stream=stream,
messages=messages,
chat_history_length=chat_history_length,
start_time=start_time,
task_name=task_name,
task_description=task_description,
task_id=task_id,
)
# Process response - mirror sync _chat_impl behavior (lines ~1544-1602)
if not response:
# Rollback chat history on response failure
self._truncate_chat_history(chat_history_length)
return None
# Extract response content using the same method as sync
response_text = self._extract_llm_response_content(response)
if isinstance(response_text, str):
response_text = response_text.strip()
# Handle output_json or output_pydantic if specified
if output_json or output_pydantic:
# Add to chat history and return raw response
# User message already added before LLM call via _build_messages
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB
self._persist_message("assistant", response_text)
# Apply guardrail validation even for JSON output
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
# For regular responses (no self-reflection)
if not self.self_reflect:
# User message already added before LLM call via _build_messages
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB (non-reflect path)
self._persist_message("assistant", response_text)
if self.verbose:
logging.debug(f"Agent {self.name} final response: {response_text}")
# Return only reasoning content if reasoning_steps is True
if reasoning_steps and hasattr(response, 'choices') and response.choices and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
# Apply guardrail to reasoning content
try:
validated_reasoning = await self._aapply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
else:
# Apply guardrail to regular response content
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
# If self-reflection is enabled, implement reflection logic
if self.self_reflect:
# Implement async self-reflection similar to sync path
reflection_prompt = f"""
Reflect on your previous response: '{response_text}'.
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
Provide a "satisfactory" status ('yes' or 'no').
Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
# Add reflection prompt to messages
reflection_messages = messages + [
{"role": "assistant", "content": response_text},
{"role": "user", "content": reflection_prompt}
]
reflection_count = 0
while True:
try:
# Check if OpenAI client is available for self-reflection
if self._openai_client is None:
# For custom LLMs, self-reflection with structured output is not supported
if self.verbose:
_get_display_functions()['display_self_reflection'](f"Agent {self.name}: Self-reflection with structured output is not supported for custom LLM providers. Skipping reflection.", console=self.console)
# Return the original response without reflection
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB
self._persist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput']
)
reflection_output = reflection_response.choices[0].message.parsed
if self.verbose:
_get_display_functions()['display_self_reflection'](f"Agent {self.name} self reflection (using {self.reflect_llm if self.reflect_llm else self.llm}): reflection='{reflection_output.reflection}' satisfactory='{reflection_output.satisfactory}'", console=self.console)
# Only consider satisfactory after minimum reflections
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
if self.verbose:
_get_display_functions()['display_self_reflection']("Agent marked the response as satisfactory after meeting minimum reflections", console=self.console)
# Add to chat history and return
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB
self._persist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
# Check if we've hit max reflections
if reflection_count >= self.max_reflect - 1:
if self.verbose:
_get_display_functions()['display_self_reflection']("Maximum reflection count reached, returning current response", console=self.console)
# Add to chat history and return
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB
self._persist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
# Regenerate response based on reflection
regenerate_messages = reflection_messages + [
{"role": "assistant", "content": f"Self Reflection: {reflection_output.reflection} Satisfactory?: {reflection_output.satisfactory}"},
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
]
new_response = await self._execute_unified_achat_completion(
messages=regenerate_messages,
temperature=temperature,
tools=formatted_tools,
stream=stream,
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
response_format=response_format
)
if new_response:
new_response_text = self._extract_llm_response_content(new_response)
if isinstance(new_response_text, str):
response_text = new_response_text.strip()
# Update reflection_messages to include the new response for next iteration
reflection_messages = regenerate_messages + [
{"role": "assistant", "content": response_text}
]
reflection_count += 1
except Exception as e:
if self.verbose:
_get_display_functions()['display_error'](f"Error in parsing self-reflection json {e}. Retrying", console=self.console)
logging.error("Reflection parsing failed.", exc_info=True)
reflection_count += 1
if reflection_count >= self.max_reflect:
# Return original response after max reflection attempts
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB
self._persist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as guard_e:
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
continue
# This should never be reached due to the returns above
# But adding as safety fallback
logging.warning(f"Agent {self.name}: Unexpected code path reached in unified dispatch")
return None
else:
# LEGACY: Check if OpenAI client is available
if self._openai_client is None:
@@ -2339,7 +2649,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Apply guardrail validation for OpenAI client response
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -403,9 +403,33 @@ class MemoryMixin:
self._session_store.add_user_message(self._session_id, content)
elif role == "assistant":
self._session_store.add_assistant_message(self._session_id, content)
self._persist_session_stats()
except Exception as e:
logging.warning(f"Failed to persist message to session store: {e}")
def _persist_session_stats(self):
"""Flush agent cost/token stats into session JSON metadata."""
store = getattr(self, "_session_store", None)
session_id = getattr(self, "_session_id", None)
if store is None or session_id is None:
return
if not hasattr(store, "update_session_metadata"):
return
try:
summary = getattr(self, "cost_summary", {}) or {}
llm = getattr(self, "llm", None)
model = str(llm) if llm is not None else None
store.update_session_metadata(
session_id,
model=model,
total_tokens=summary.get("tokens_in", 0) + summary.get("tokens_out", 0),
cost=summary.get("cost", 0),
source=getattr(self, "_session_source", "chat"),
agent_id=getattr(self, "agent_id", None) or getattr(self, "_agent_id", None),
)
except Exception as e:
logging.debug(f"Session stats persist skipped: {e}")
def session_id(self) -> Optional[str]:
"""Get the current session ID."""
return self._session_id
@@ -26,6 +26,21 @@ if TYPE_CHECKING:
class ToolExecutionMixin:
"""Mixin providing toolexecution methods for the Agent class."""
def _get_existing_stream_emitter(self):
"""Return an already-initialized stream emitter without creating one."""
emitter = getattr(self, "_stream_emitter", None)
if emitter is not None:
return emitter
# Support name-mangled private attributes across class renames/inheritance.
for cls in type(self).mro():
mangled = f"_{cls.__name__}__stream_emitter"
if hasattr(self, mangled):
emitter = getattr(self, mangled, None)
if emitter is not None:
return emitter
return None
def _resolve_tool_names(self, tool_names):
"""Resolve tool names to actual tool instances from registry.
@@ -120,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
@@ -160,8 +181,9 @@ class ToolExecutionMixin:
# Emit TOOL_CALL_START to stream_emitter (for AIUI/AG-UI consumers)
# Zero overhead when no callbacks registered
if hasattr(self, '_Agent__stream_emitter') and getattr(self, "_Agent__stream_emitter", None) is not None and getattr(self, "_Agent__stream_emitter", None).has_callbacks:
getattr(self, "_Agent__stream_emitter", None).emit(StreamEvent(
_stream_emitter = self._get_existing_stream_emitter()
if _stream_emitter is not None and _stream_emitter.has_callbacks:
_stream_emitter.emit(StreamEvent(
type=StreamEventType.TOOL_CALL_START,
timestamp=_tool_start_perf,
tool_call={
@@ -275,10 +297,10 @@ class ToolExecutionMixin:
# Emit TOOL_CALL_RESULT to stream_emitter (for AIUI/AG-UI consumers)
# Zero overhead when no callbacks registered
if hasattr(self, '_Agent__stream_emitter') and getattr(self, "_Agent__stream_emitter", None) is not None and getattr(self, "_Agent__stream_emitter", None).has_callbacks:
if _stream_emitter is not None and _stream_emitter.has_callbacks:
# Truncate result for stream event (keep it reasonable for UI display)
result_summary = str(result)[:500] if result else None
getattr(self, "_Agent__stream_emitter", None).emit(StreamEvent(
_stream_emitter.emit(StreamEvent(
type=StreamEventType.TOOL_CALL_RESULT,
timestamp=_time.perf_counter(),
tool_call={
@@ -290,6 +312,18 @@ class ToolExecutionMixin:
agent_id=self.name,
metadata={"duration_ms": _duration_ms},
))
_stream_emitter.emit(StreamEvent(
type=StreamEventType.TOOL_CALL_END,
timestamp=_time.perf_counter(),
tool_call={
"name": function_name,
"arguments": arguments,
"result": result_summary,
"id": tool_call_id,
},
agent_id=self.name,
metadata={"duration_ms": _duration_ms},
))
# Trigger AFTER_TOOL hook
from ..hooks import HookEvent, AfterToolInput
@@ -926,4 +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}"
@@ -15,9 +15,9 @@ from enum import Enum
# Import token tracking
try:
from ..telemetry.token_collector import _token_collector
from ..telemetry.token_collector import get_token_collector
except ImportError:
_token_collector = None
get_token_collector = None
# Import async utility for hot-path usage
try:
@@ -1609,25 +1609,25 @@ class AgentTeam:
original_on_task_start = self.on_task_start
original_on_task_complete = self.on_task_complete
def composed_on_task_start(task):
def composed_on_task_start(task, task_id):
try:
verbose_task_start_callback(task)
verbose_task_start_callback(task, task_id)
except Exception as e:
logging.debug(f"Error in verbose task start callback: {e}")
if original_on_task_start:
try:
original_on_task_start(task)
original_on_task_start(task, task_id)
except Exception as e:
logging.debug(f"Error in original task start callback: {e}")
def composed_on_task_complete(task):
def composed_on_task_complete(task, task_output):
try:
verbose_task_complete_callback(task)
verbose_task_complete_callback(task, task_output)
except Exception as e:
logging.debug(f"Error in verbose task complete callback: {e}")
if original_on_task_complete:
try:
original_on_task_complete(task)
original_on_task_complete(task, task_output)
except Exception as e:
logging.debug(f"Error in original task complete callback: {e}")
@@ -1826,18 +1826,19 @@ class AgentTeam:
def get_token_usage_summary(self) -> Dict[str, Any]:
"""Get a summary of token usage across all agents and tasks."""
if not _token_collector:
if not get_token_collector:
return {"error": "Token tracking not available"}
return _token_collector.get_session_summary()
return get_token_collector().get_session_summary()
def get_detailed_token_report(self) -> Dict[str, Any]:
"""Get a detailed token usage report."""
if not _token_collector:
if not get_token_collector:
return {"error": "Token tracking not available"}
summary = _token_collector.get_session_summary()
recent = _token_collector.get_recent_interactions(limit=20)
collector = get_token_collector()
summary = collector.get_session_summary()
recent = collector.get_recent_interactions(limit=20)
# Calculate cost estimates (example rates)
cost_per_1k_input = 0.0005 # $0.0005 per 1K input tokens
@@ -1861,11 +1862,11 @@ class AgentTeam:
def display_token_usage(self):
"""Display token usage in a formatted table."""
if not _token_collector:
if not get_token_collector:
print("Token tracking not available")
return
summary = _token_collector.get_session_summary()
summary = get_token_collector().get_session_summary()
print("\n" + "="*50)
print("TOKEN USAGE SUMMARY")
@@ -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",
]
@@ -0,0 +1,13 @@
"""Hooks query protocol for UI/API consumers."""
from __future__ import annotations
from typing import Dict, List, Protocol, runtime_checkable
@runtime_checkable
class HooksQueryProtocol(Protocol):
"""Read path for registered hooks."""
def list_hooks_for_api(self) -> List[Dict]:
...
@@ -439,3 +439,13 @@ def has_hook(event: Union[str, HookEvent]) -> bool:
)
return get_default_registry().has_hooks(event)
def list_hooks_for_api() -> List[Dict]:
"""Flat hook list suitable for REST/UI consumers."""
registry = get_default_registry()
flat: List[Dict] = []
for event, hooks in registry.list_hooks().items():
for h in hooks:
flat.append({"event": event, "source": "sdk", **h})
return flat
@@ -68,6 +68,14 @@ class HookEvent(str, Enum):
SCHEDULE_REMOVE = "schedule_remove"
SCHEDULE_TRIGGER = "schedule_trigger"
# Kanban task lifecycle (wrapper dispatcher + tools emit these)
KANBAN_TASK_CREATED = "kanban_task_created"
KANBAN_TASK_CLAIMED = "kanban_task_claimed"
KANBAN_TASK_MOVED = "kanban_task_moved"
KANBAN_TASK_DONE = "kanban_task_done"
KANBAN_TASK_BLOCKED = "kanban_task_blocked"
KANBAN_TASK_FAILED = "kanban_task_failed"
# Claude Code parity events
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
NOTIFICATION = "notification" # When notification is sent
@@ -101,6 +109,34 @@ class HookInput:
}
@dataclass
class KanbanHookInput(HookInput):
"""Hook input for kanban task lifecycle events.
Used by wrapper dispatcher and tools when emitting kanban events.
Observability adapters can subscribe to these via the hook registry.
"""
task_id: str = ""
board: str = "default"
status: str = ""
assignee: Optional[str] = field(default=None)
from_status: Optional[str] = field(default=None)
to_status: Optional[str] = field(default=None)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = super().to_dict()
result.update({
"task_id": self.task_id,
"board": self.board,
"status": self.status,
"assignee": self.assignee,
"from_status": self.from_status,
"to_status": self.to_status,
})
return result
@dataclass
class HookOutput:
"""Base hook output - common fields for all events."""
@@ -0,0 +1,23 @@
"""
Kanban protocols and types for PraisonAI Agents.
This module provides the protocol contracts for kanban functionality,
allowing the wrapper (praisonai) and PraisonAIUI to share a stable
interface without coupling the core to SQLite or other heavy implementations.
"""
from praisonaiagents.kanban.protocols import (
KanbanStoreProtocol,
KanbanTaskProtocol,
KanbanCommentingProtocol,
KanbanLinkingProtocol,
VALID_KANBAN_STATUSES,
)
__all__ = [
"KanbanStoreProtocol",
"KanbanTaskProtocol",
"KanbanCommentingProtocol",
"KanbanLinkingProtocol",
"VALID_KANBAN_STATUSES",
]
@@ -0,0 +1,221 @@
"""
Kanban protocols for PraisonAI Agents.
Defines the protocol contracts that the wrapper (praisonai) and PraisonAIUI
can use to implement kanban functionality with a stable interface.
This follows AGENTS.md §3.2 Protocol-First Design:
- Protocols define WHAT (interface contract)
- Implementations define HOW (concrete behavior)
- Core SDK has protocols only, wrapper has heavy implementations
"""
from typing import Protocol, TypedDict, runtime_checkable
# Valid kanban statuses matching PraisonAIUI columns + archived
VALID_KANBAN_STATUSES: frozenset[str] = frozenset([
"triage",
"todo",
"ready",
"running",
"blocked",
"review",
"done",
"archived"
])
class KanbanTaskProtocol(TypedDict, total=False):
"""Typed dict shape for kanban task fields.
Defines the expected structure of task objects returned by
KanbanStoreProtocol implementations.
"""
id: str
title: str
body: str
status: str
assignee: str | None
priority: str | None
tenant: str | None
board: str
created_at: float
updated_at: float
@runtime_checkable
class KanbanStoreProtocol(Protocol):
"""Protocol contract for kanban store implementations.
This protocol defines the core interface that PraisonAIUI expects
for injected kanban stores. Wrapper implementations must
implement all methods to be compatible.
Duck typing contract matches InjectedKanbanStore in PraisonAIUI.
"""
def get_board(
self,
*,
board: str = "default",
tenant: str | None = None,
include_archived: bool = False,
) -> dict:
"""Get board data with tasks grouped by status.
Args:
board: Board name (default: "default")
tenant: Optional tenant filter
include_archived: Whether to include archived tasks
Returns:
Board data with tasks organized by status columns
"""
...
def get_task(self, task_id: str) -> dict | None:
"""Get a single task by ID.
Args:
task_id: Task identifier
Returns:
Task data or None if not found
"""
...
def create_task(self, data: dict) -> dict:
"""Create a new task.
Args:
data: Task creation data (title, body, status, etc.)
Returns:
Created task data with assigned ID
"""
...
def update_task(self, task_id: str, data: dict) -> dict | None:
"""Update an existing task.
Args:
task_id: Task identifier
data: Fields to update
Returns:
Updated task data or None if not found
"""
...
def move_task(self, task_id: str, status: str) -> dict | None:
"""Move a task to a different status.
Args:
task_id: Task identifier
status: New status (must be in VALID_KANBAN_STATUSES)
Returns:
Updated task data or None if not found
"""
...
def bulk_update(self, task_ids: list[str], status: str) -> dict:
"""Update multiple tasks to the same status.
Args:
task_ids: List of task identifiers
status: New status for all tasks
Returns:
Results summary with success/failure counts
"""
...
def delete_task(self, task_id: str) -> bool:
"""Delete a task.
Args:
task_id: Task identifier
Returns:
True if deleted, False if not found
"""
...
def list_events(self, since: float = 0.0, board: str = "default") -> list[dict]:
"""List kanban events since a timestamp.
Args:
since: Unix timestamp to filter events from
board: Board name to filter events
Returns:
List of event data
"""
...
def health(self) -> dict:
"""Check store health status.
Returns:
Health status information
"""
...
@runtime_checkable
class KanbanCommentingProtocol(Protocol):
"""Extension protocol for kanban task commenting functionality.
This protocol is implemented separately from KanbanStoreProtocol to allow
stores to optionally support commenting without breaking isinstance checks
on the core protocol.
"""
def add_comment(self, task_id: str, text: str, author: str | None = None) -> dict | None:
"""Add a comment to a task.
Args:
task_id: Task identifier
text: Comment text
author: Optional comment author
Returns:
Comment data or None if task not found
"""
...
@runtime_checkable
class KanbanLinkingProtocol(Protocol):
"""Extension protocol for kanban task linking functionality.
This protocol is implemented separately from KanbanStoreProtocol to allow
stores to optionally support task relationships without breaking isinstance
checks on the core protocol.
"""
def link_tasks(self, parent_id: str, child_id: str) -> bool:
"""Link two tasks in parent-child relationship.
Args:
parent_id: Parent task identifier
child_id: Child task identifier
Returns:
True if linked successfully, False otherwise
"""
...
def unlink_tasks(self, parent_id: str, child_id: str) -> bool:
"""Unlink parent-child task relationship.
Args:
parent_id: Parent task identifier
child_id: Child task identifier
Returns:
True if unlinked successfully, False otherwise
"""
...
@@ -174,7 +174,14 @@ class ChromaKnowledgeAdapter:
# Add where filter if any conditions specified
if where_filter:
query_kwargs["where"] = where_filter
# ChromaDB validation expects exactly one condition in the where clause
# When multiple conditions exist, use $and operator to combine them
if len(where_filter) == 1:
query_kwargs["where"] = where_filter
else:
# Convert multiple conditions to ChromaDB's $and format
and_conditions = [{k: v} for k, v in where_filter.items()]
query_kwargs["where"] = {"$and": and_conditions}
response = self.collection.query(**query_kwargs)
@@ -197,6 +204,10 @@ class ChromaKnowledgeAdapter:
return SearchResult(results=items)
except Exception as e:
# Log ChromaDB query errors for better debugging
import logging
logger = logging.getLogger(__name__)
logger.warning(f"ChromaDB search failed: {e}")
return SearchResult(results=[])
def add(self, content: Any, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
@@ -73,10 +73,10 @@ def _get_live():
# Import token tracking
try:
from ..telemetry.token_collector import TokenMetrics, _token_collector
from ..telemetry.token_collector import TokenMetrics, get_token_collector
except ImportError:
TokenMetrics = None
_token_collector = None
get_token_collector = None
# Logging is already configured in _logging.py via __init__.py
@@ -4519,7 +4519,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
def _track_token_usage(self, response: Dict[str, Any], model: str) -> Optional[TokenMetrics]:
"""Extract and track token usage from LLM response."""
if not TokenMetrics or not _token_collector:
if not TokenMetrics or not get_token_collector:
return None
# Note: metrics check moved to call sites for performance
@@ -4551,7 +4551,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Track in global collector
# Extract provider from model string (e.g., 'openai' from 'openai/gpt-4o-mini')
provider = model.split('/')[0] if '/' in model else 'litellm'
_token_collector.track_tokens(
get_token_collector().track_tokens(
model=model,
agent=self.current_agent_name,
metrics=metrics,
@@ -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 = {}
@@ -6,7 +6,13 @@ No heavy imports - only stdlib.
"""
import hashlib
from typing import Any, Dict, List, Optional, Set, Tuple
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
if TYPE_CHECKING:
from ..knowledge.models import SearchResultItem
ResultItem = Union[Dict[str, Any], "SearchResultItem"]
def _estimate_tokens(text: str) -> int:
@@ -19,6 +25,39 @@ def _estimate_tokens(text: str) -> int:
return len(text) // 4 + 1
def _extract_value(item: ResultItem, key: str, default: Any = None) -> Any:
"""
Extract value from either dict or object (e.g., SearchResultItem).
Args:
item: Dictionary or object with attributes
key: Key/attribute name to extract
default: Default value if key not found
Returns:
Extracted value or default
"""
if isinstance(item, dict):
return item.get(key, default)
else:
return getattr(item, key, default)
def _extract_metadata_value(
item: ResultItem,
metadata: Dict[str, Any],
key: str,
default: Any = "",
) -> Any:
"""
Extract metadata value with fallback to top-level item attribute.
"""
value = metadata.get(key)
if value is None:
return _extract_value(item, key, default)
return value
def _chunk_hash(text: str, source: Optional[str] = None) -> str:
"""
Generate stable hash for chunk deduplication.
@@ -35,31 +74,38 @@ def _chunk_hash(text: str, source: Optional[str] = None) -> str:
def deduplicate_chunks(
results: List[Dict[str, Any]],
results: List[ResultItem],
similarity_threshold: float = 0.9,
) -> List[Dict[str, Any]]:
) -> List[ResultItem]:
"""
Deduplicate chunks by content hash.
Handles both dict format and SearchResultItem objects.
Args:
results: List of search results with 'text' or 'memory' key
results: List of search results (dicts or SearchResultItem objects)
similarity_threshold: Not used currently (hash-based dedup)
Returns:
Deduplicated list of results
"""
seen_hashes: Set[str] = set()
unique_results: List[Dict[str, Any]] = []
unique_results: List[ResultItem] = []
for result in results:
# Skip None results
if result is None:
continue
# Handle different result formats
text = result.get("text") or result.get("memory", "")
# Handle different result formats (dict or SearchResultItem)
text = _extract_value(result, "text") or _extract_value(result, "memory", "")
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
metadata = result.get("metadata") or {}
source = metadata.get("source", "")
metadata = _extract_value(result, "metadata") or {}
if not isinstance(metadata, dict):
metadata = {}
source = _extract_metadata_value(result, metadata, "source", "")
chunk_id = _chunk_hash(text, source)
@@ -106,17 +152,19 @@ def truncate_context(
def build_context(
results: List[Dict[str, Any]],
results: List[ResultItem],
max_tokens: int = 4000,
deduplicate: bool = True,
separator: str = "\n\n---\n\n",
include_source: bool = True,
) -> Tuple[str, List[Dict[str, Any]]]:
) -> Tuple[str, List[ResultItem]]:
"""
Build context string from retrieval results.
Handles both dict format and SearchResultItem objects.
Args:
results: List of search results
results: List of search results (dicts or SearchResultItem objects)
max_tokens: Maximum tokens for context
deduplicate: Whether to deduplicate chunks
separator: Separator between chunks
@@ -133,7 +181,7 @@ def build_context(
results = deduplicate_chunks(results)
context_parts: List[str] = []
used_results: List[Dict[str, Any]] = []
used_results: List[ResultItem] = []
current_tokens = 0
separator_tokens = _estimate_tokens(separator)
@@ -141,17 +189,21 @@ def build_context(
# Skip None results
if result is None:
continue
# Handle different result formats
text = result.get("text") or result.get("memory", "")
# Handle different result formats (dict or SearchResultItem)
text = _extract_value(result, "text") or _extract_value(result, "memory", "")
if not text:
continue
# Build chunk text with optional source
if include_source:
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
metadata = result.get("metadata") or {}
source = metadata.get("source", "")
filename = metadata.get("filename", "")
metadata = _extract_value(result, "metadata") or {}
if not isinstance(metadata, dict):
metadata = {}
source = _extract_metadata_value(result, metadata, "source", "")
filename = _extract_metadata_value(result, metadata, "filename", "")
source_label = filename or source or f"Source {i + 1}"
chunk_text = f"[{source_label}]\n{text}"
else:
@@ -194,11 +246,14 @@ class DefaultContextBuilder:
def build(
self,
results: List[Dict[str, Any]],
results: List[ResultItem],
max_tokens: int = 4000,
deduplicate: bool = True,
) -> str:
"""Build context string from results."""
"""Build context string from results.
Handles both dict format and SearchResultItem objects.
"""
context, _ = build_context(
results=results,
max_tokens=max_tokens,
@@ -0,0 +1,29 @@
# Scheduler — runner vs loop
## ScheduleRunner (stateless)
`ScheduleRunner` is **stateless**. Call `get_due_jobs()` whenever you want to check for jobs that should fire. It does not run background threads.
Use when:
- CLI one-shot tick (`praisonai schedule tick`)
- Host integration that polls on its own timer
- Tests
## ScheduleLoop (optional daemon)
`ScheduleLoop` is an **optional background daemon**. You must call `.start()` explicitly; it polls the runner and invokes `on_trigger` callbacks.
Use when:
- Long-running host process (Pattern B/C via `setup_bridges()`)
- Standalone scheduler service
```python
from praisonaiagents.scheduler import FileScheduleStore, ScheduleRunner, ScheduleLoop
store = FileScheduleStore()
runner = ScheduleRunner(store)
loop = ScheduleLoop(runner)
loop.start() # optional daemon
```
The wrapper host calls `ensure_schedule_runner()` which starts `ScheduleLoop` when the scheduler optional dependency is available.
@@ -32,7 +32,22 @@ When you provide a `session_id` to an Agent:
### Default Storage Location
Sessions are stored in: `~/.praison/sessions/{session_id}.json`
Sessions are stored in: `~/.praisonai/sessions/{session_id}.json`
### Session metadata contract (Issue #48)
List endpoints and UI dashboards may expose these optional root-level fields:
| Field | Type | Description |
|-------|------|-------------|
| `model` | string | LLM model used in the session |
| `total_tokens` | int | Cumulative input+output tokens |
| `cost` | float | Estimated USD cost |
| `agent_id` | string | Gateway or registry agent id |
| `source` | string | Origin: `chat`, `gateway`, `cli`, `api` |
| `agent_name` | string | Human-readable agent name |
Fields are persisted in session JSON `metadata` and mirrored at root on save. Populated automatically after each assistant turn when using the default session store.
### Session File Format
@@ -45,7 +60,13 @@ Sessions are stored in: `~/.praison/sessions/{session_id}.json`
],
"created_at": "2026-01-02T04:00:00+00:00",
"updated_at": "2026-01-02T04:01:00+00:00",
"agent_name": "Assistant"
"agent_name": "Assistant",
"agent_id": "agent-abc",
"source": "chat",
"model": "gpt-4o-mini",
"total_tokens": 128,
"cost": 0.0004,
"metadata": {}
}
```
@@ -144,6 +144,74 @@ 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)."""
if os.path.exists(filepath):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
return ExtendedSessionData.from_dict(data)
except (json.JSONDecodeError, IOError):
pass
return ExtendedSessionData(session_id=session_id)
def _modify_session_locked(
self,
session_id: str,
mutator,
*,
error_label: str = "modify session",
) -> bool:
"""Locked read-modify-write preserving extended session fields."""
result = super()._modify_session_locked(
session_id, mutator, error_label=error_label
)
if result:
with self._lock:
cached = self._cache.get(session_id)
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,
@@ -157,56 +225,32 @@ class HierarchicalSessionStore(DefaultSessionStore):
Overrides parent to preserve extended session data.
"""
from .store import SessionMessage
# Load extended session (force reload to get latest)
session = self._load_extended_session(session_id, force_reload=True)
message = SessionMessage(
role=role,
content=content,
timestamp=time.time(),
metadata=metadata or {},
)
session.messages.append(message)
# Trim messages if over limit
if len(session.messages) > self.max_messages:
session.messages = session.messages[-self.max_messages:]
return self._save_extended_session(session)
def _apply(session: SessionData) -> None:
session.messages.append(message)
if len(session.messages) > self.max_messages:
session.messages = session.messages[-self.max_messages :]
return self._modify_session_locked(
session_id, _apply, error_label="add message to session"
)
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."""
@@ -234,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:
@@ -278,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
@@ -330,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
@@ -385,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
@@ -416,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:
"""
@@ -448,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."""
@@ -477,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.
@@ -534,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
@@ -551,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]:
"""
@@ -129,3 +129,16 @@ class SessionStoreProtocol(Protocol):
True if the session exists.
"""
...
@runtime_checkable
class CheckpointQueryProtocol(Protocol):
"""Read path for session checkpoints / rollback snapshots."""
def list_checkpoints(self, session_id: str) -> List[Dict[str, Any]]:
"""Return checkpoint metadata for a session."""
...
def get_checkpoint(self, session_id: str, checkpoint_id: str) -> Optional[Dict[str, Any]]:
"""Return a single checkpoint payload."""
...
@@ -15,14 +15,14 @@ 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
from typing import Any, Dict, List, Optional
from typing import Any, Callable, Dict, List, Optional
from ..paths import get_sessions_dir
@@ -81,7 +81,7 @@ class SessionData:
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
data = {
"session_id": self.session_id,
"messages": [m.to_dict() for m in self.messages],
"created_at": self.created_at,
@@ -92,6 +92,10 @@ class SessionData:
"gateway_session_id": self.gateway_session_id,
"agent_id": self.agent_id,
}
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source"):
if key in self.metadata:
data[key] = self.metadata[key]
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionData":
@@ -137,7 +141,8 @@ class FileLock:
self._lock_path = filepath + ".lock"
def __enter__(self):
self.acquire()
if not self.acquire():
raise IOError(f"Failed to acquire file lock for {self.filepath} after {self.timeout}s")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
@@ -162,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
@@ -260,32 +265,88 @@ 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):
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
def _load_session_from_disk(self, session_id: str, filepath: str) -> SessionData:
"""Load session JSON from disk (caller must hold FileLock)."""
if os.path.exists(filepath):
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)
return SessionData.from_dict(data)
except (json.JSONDecodeError, IOError):
pass
return SessionData(session_id=session_id)
def _read_session_fresh(self, session_id: str) -> SessionData:
"""Reload session from disk and refresh the in-process cache."""
filepath = self._get_session_path(session_id)
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
def _modify_session_locked(
self,
session_id: str,
mutator: Callable[[SessionData], None],
*,
error_label: str = "modify session",
) -> bool:
"""Apply mutator after reloading from disk under FileLock."""
filepath = self._get_session_path(session_id)
with FileLock(filepath, self.lock_timeout):
session = self._load_session_from_disk(session_id, filepath)
mutator(session)
session.updated_at = datetime.now(timezone.utc).isoformat()
if len(session.messages) > self.max_messages:
session.messages = session.messages[-self.max_messages:]
try:
dir_path = os.path.dirname(filepath) or "."
os.makedirs(dir_path, exist_ok=True)
with tempfile.NamedTemporaryFile(
mode="w",
encoding="utf-8",
dir=dir_path,
delete=False,
suffix=".tmp",
) as f:
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
temp_path = f.name
os.replace(temp_path, filepath)
with self._lock:
self._cache[session_id] = session
return True
except (IOError, OSError) as e:
logger.error(f"Failed to {error_label} {session_id}: {e}")
try:
if "temp_path" in locals():
os.remove(temp_path)
except (IOError, OSError):
pass
return False
def _save_session(self, session: SessionData) -> bool:
"""Save session to disk with atomic write."""
@@ -435,13 +496,13 @@ class DefaultSessionStore:
Returns:
List of {"role": "user/assistant", "content": "..."} dicts.
"""
session = self._load_session(session_id)
session = self._read_session_fresh(session_id)
limit = max_messages or self.max_messages
return session.get_chat_history(limit)
def get_session(self, session_id: str) -> SessionData:
"""Get full session data."""
return self._load_session(session_id)
return self._read_session_fresh(session_id)
def set_agent_info(
self,
@@ -450,27 +511,65 @@ class DefaultSessionStore:
user_id: Optional[str] = None,
) -> bool:
"""Set agent info for a session."""
session = self._load_session(session_id)
with self._lock:
def _apply(session: SessionData) -> None:
if agent_name:
session.agent_name = agent_name
if user_id:
session.user_id = user_id
self._cache[session_id] = session
return self._save_session(session)
return self._modify_session_locked(
session_id, _apply, error_label="set agent info for session"
)
def clear_session(self, session_id: str) -> bool:
"""Clear all messages from a session."""
session = self._load_session(session_id)
with self._lock:
return self._modify_session_locked(
session_id,
lambda session: session.messages.clear(),
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()
self._cache[session_id] = session
return self._save_session(session)
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:
return True
def _apply(session: SessionData) -> None:
for key, value in fields.items():
if value is None:
continue
session.metadata[key] = value
if key in ("agent_id", "agent_name", "user_id"):
setattr(session, key, value)
return self._modify_session_locked(
session_id, _apply, error_label="update session metadata"
)
def delete_session(self, session_id: str) -> bool:
"""Delete a session completely."""
filepath = self._get_session_path(session_id)
@@ -500,10 +599,16 @@ class DefaultSessionStore:
data = json.load(f)
sessions.append({
"session_id": data.get("session_id", filename[:-5]),
"id": data.get("session_id", filename[:-5]),
"agent_name": data.get("agent_name"),
"agent_id": data.get("agent_id") or (data.get("metadata") or {}).get("agent_id"),
"source": data.get("source") or (data.get("metadata") or {}).get("source"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"message_count": len(data.get("messages", [])),
"model": data.get("model") or data.get("llm") or (data.get("metadata") or {}).get("model"),
"total_tokens": data.get("total_tokens") or data.get("token_count") or (data.get("metadata") or {}).get("total_tokens"),
"cost": data.get("cost") or (data.get("metadata") or {}).get("cost"),
})
except (json.JSONDecodeError, IOError):
continue
@@ -563,7 +668,7 @@ class DefaultSessionStore:
List of SessionData objects for the specified agent
"""
session_ids = self.list_sessions_by_agent(agent_name, limit)
return [self._load_session(sid) for sid in session_ids]
return [self._read_session_fresh(sid) for sid in session_ids]
def get_agent_chat_history(
self,
@@ -617,16 +722,15 @@ class DefaultSessionStore:
Returns:
True if saved successfully
"""
session = self._load_session(session_id)
with self._lock:
def _apply(session: SessionData) -> None:
if gateway_session_id:
session.gateway_session_id = gateway_session_id
if agent_id:
session.agent_id = agent_id
self._cache[session_id] = session
return self._save_session(session)
return self._modify_session_locked(
session_id, _apply, error_label="set gateway info for session"
)
def get_by_gateway_session(self, gateway_session_id: str) -> Optional[SessionData]:
"""Get session data linked to a gateway session.
@@ -19,7 +19,9 @@ Usage:
__all__ = [
# Models
"SkillProperties",
"SkillMetadata",
"SkillMetadata",
"SkillRequirements",
"SkillState",
"ParseError",
"ValidationError",
# Parser
@@ -54,13 +56,17 @@ __all__ = [
"SkillActivatedEvent",
# Budget
"SkillPromptBudget",
# Capability validation
"CapabilityValidator",
"EnforcementLevel",
"ValidationResult",
]
def __getattr__(name: str):
"""Lazy load module components to avoid import overhead."""
if name in ("SkillProperties", "SkillMetadata", "ParseError", "ValidationError"):
from .models import SkillProperties, SkillMetadata, ParseError, ValidationError
if name in ("SkillProperties", "SkillMetadata", "SkillRequirements", "SkillState", "ParseError", "ValidationError"):
from .models import SkillProperties, SkillMetadata, SkillRequirements, SkillState, ParseError, ValidationError
return locals()[name]
if name in ("parse_frontmatter", "find_skill_md", "read_properties"):
@@ -130,5 +136,9 @@ def __getattr__(name: str):
return None
return load_skill
if name in ("CapabilityValidator", "EnforcementLevel", "ValidationResult"):
from .capability_validator import CapabilityValidator, EnforcementLevel, ValidationResult
return locals()[name]
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -0,0 +1,236 @@
"""Capability validation for Agent Skills.
This module implements the capability gate enforcement system for skills,
checking that required tools, servers, and environment variables are available
before allowing skill activation.
"""
import logging
import os
from dataclasses import dataclass
from typing import Dict, List, Optional, Set, Tuple
from enum import Enum
from .models import SkillProperties, SkillRequirements, SkillState
logger = logging.getLogger(__name__)
class EnforcementLevel(Enum):
"""Enforcement level for capability gates."""
DISABLED = "disabled" # No enforcement (existing behavior)
TELEMETRY = "telemetry" # Log only, no blocking
WARN = "warn" # Warning but allow activation
STRICT = "strict" # Hard failure, block activation
@dataclass
class ValidationResult:
"""Result of capability validation for a skill."""
skill_name: str
state: SkillState
satisfied_tools: List[str]
missing_tools: List[str]
satisfied_servers: List[str]
missing_servers: List[str]
satisfied_env_vars: List[str]
missing_env_vars: List[str]
warnings: List[str]
errors: List[str]
@property
def is_fully_satisfied(self) -> bool:
"""Check if all requirements are satisfied."""
return not any([
self.missing_tools,
self.missing_servers,
self.missing_env_vars,
self.errors
])
@property
def has_critical_missing(self) -> bool:
"""Check if critical requirements are missing (tools, servers)."""
return bool(self.missing_tools or self.missing_servers)
def to_dict(self) -> Dict:
"""Convert to dictionary for serialization."""
return {
"skill_name": self.skill_name,
"state": self.state.value,
"satisfied_tools": self.satisfied_tools,
"missing_tools": self.missing_tools,
"satisfied_servers": self.satisfied_servers,
"missing_servers": self.missing_servers,
"satisfied_env_vars": self.satisfied_env_vars,
"missing_env_vars": self.missing_env_vars,
"warnings": self.warnings,
"errors": self.errors,
"is_fully_satisfied": self.is_fully_satisfied,
"has_critical_missing": self.has_critical_missing
}
class CapabilityValidator:
"""Validates skill requirements against available capabilities.
This is the core capability gate enforcement engine. It checks skill
requirements against the runtime context (available tools, servers, etc.)
and determines whether a skill should be activated based on policy.
"""
def __init__(self, enforcement_level: EnforcementLevel = EnforcementLevel.WARN):
"""Initialize the validator.
Args:
enforcement_level: How strictly to enforce requirements
"""
self.enforcement_level = enforcement_level
self._tool_cache: Optional[Set[str]] = None
self._server_cache: Optional[Set[str]] = None
def validate_skill(
self,
skill: SkillProperties,
available_tools: Optional[Set[str]] = None,
available_servers: Optional[Set[str]] = None
) -> ValidationResult:
"""Validate a skill's requirements against available capabilities.
Args:
skill: Skill to validate
available_tools: Set of available tool names (auto-detected if None)
available_servers: Set of available server names (auto-detected if None)
Returns:
ValidationResult with validation status and details
"""
if not skill.requirements or skill.requirements.is_empty():
# No requirements specified - skill is always available
return ValidationResult(
skill_name=skill.name,
state=SkillState.ACTIVE,
satisfied_tools=[],
missing_tools=[],
satisfied_servers=[],
missing_servers=[],
satisfied_env_vars=[],
missing_env_vars=[],
warnings=[],
errors=[]
)
# Get available capabilities
if available_tools is None:
available_tools = self._get_available_tools()
if available_servers is None:
available_servers = self._get_available_servers()
requirements = skill.requirements
# Validate tools
satisfied_tools = [tool for tool in requirements.tools if tool in available_tools]
missing_tools = [tool for tool in requirements.tools if tool not in available_tools]
# Validate servers
satisfied_servers = [srv for srv in requirements.servers if srv in available_servers]
missing_servers = [srv for srv in requirements.servers if srv not in available_servers]
# Validate environment variables
satisfied_env_vars = [var for var in requirements.env_vars if os.getenv(var)]
missing_env_vars = [var for var in requirements.env_vars if not os.getenv(var)]
# Determine warnings and errors
warnings = []
errors = []
if missing_tools:
msg = f"Missing required tools: {', '.join(missing_tools)}"
if self.enforcement_level in (EnforcementLevel.STRICT,):
errors.append(msg)
else:
warnings.append(msg)
if missing_servers:
msg = f"Missing required servers: {', '.join(missing_servers)}"
if self.enforcement_level in (EnforcementLevel.STRICT,):
errors.append(msg)
else:
warnings.append(msg)
if missing_env_vars:
msg = f"Missing required environment variables: {', '.join(missing_env_vars)}"
if self.enforcement_level == EnforcementLevel.STRICT:
errors.append(msg)
else:
warnings.append(msg)
# Determine skill state
if errors:
state = SkillState.UNAVAILABLE
elif warnings:
state = SkillState.DEGRADED
else:
state = SkillState.ACTIVE
result = ValidationResult(
skill_name=skill.name,
state=state,
satisfied_tools=satisfied_tools,
missing_tools=missing_tools,
satisfied_servers=satisfied_servers,
missing_servers=missing_servers,
satisfied_env_vars=satisfied_env_vars,
missing_env_vars=missing_env_vars,
warnings=warnings,
errors=errors
)
# Log based on enforcement level
self._log_validation_result(result)
return result
def _get_available_tools(self) -> Set[str]:
"""Get set of available tool names from registry."""
if self._tool_cache is None:
try:
from ..tools.registry import get_registry
registry = get_registry()
self._tool_cache = set(registry.list_tools())
except ImportError:
logger.debug("Tool registry not available")
self._tool_cache = set()
return self._tool_cache
def _get_available_servers(self) -> Set[str]:
"""Get set of available MCP server names."""
if self._server_cache is None:
# TODO: Implement MCP server discovery
# For now, return empty set - this can be extended later
self._server_cache = set()
return self._server_cache
def _log_validation_result(self, result: ValidationResult) -> None:
"""Log validation result based on enforcement level."""
if self.enforcement_level == EnforcementLevel.DISABLED:
return
skill_name = result.skill_name
if result.state == SkillState.UNAVAILABLE:
if self.enforcement_level == EnforcementLevel.STRICT:
logger.error(f"Skill '{skill_name}' blocked: {'; '.join(result.errors)}")
else:
logger.warning(f"Skill '{skill_name}' has critical missing dependencies: {'; '.join(result.errors)}")
elif result.state == SkillState.DEGRADED:
logger.warning(f"Skill '{skill_name}' partially available: {'; '.join(result.warnings)}")
elif result.state == SkillState.ACTIVE:
logger.debug(f"Skill '{skill_name}' requirements satisfied")
def clear_cache(self) -> None:
"""Clear cached capability information."""
self._tool_cache = None
self._server_cache = None
@@ -108,7 +108,7 @@ class SkillLoader:
return False
try:
content = skill_md.read_text()
content = skill_md.read_text(encoding="utf-8")
_, body = parse_frontmatter(content)
skill.instructions = body
return True
@@ -136,7 +136,7 @@ class SkillLoader:
for script_file in scripts_dir.iterdir():
if script_file.is_file():
try:
scripts[script_file.name] = script_file.read_text()
scripts[script_file.name] = script_file.read_text(encoding="utf-8")
except Exception:
continue
except PermissionError:
@@ -166,7 +166,7 @@ class SkillLoader:
for ref_file in refs_dir.iterdir():
if ref_file.is_file():
try:
refs[ref_file.name] = ref_file.read_text()
refs[ref_file.name] = ref_file.read_text(encoding="utf-8")
except Exception:
continue
except PermissionError:
@@ -5,12 +5,13 @@ from typing import List, Optional, Dict
logger = logging.getLogger(__name__)
from .models import SkillMetadata
from .models import SkillMetadata, SkillState
from .discovery import discover_skills, get_default_skill_dirs
from .loader import SkillLoader, LoadedSkill
from .prompt import generate_skills_xml
from .substitution import render_skill_body
from .shell_render import render_shell_blocks
from .capability_validator import CapabilityValidator, EnforcementLevel, ValidationResult
class SkillManager:
@@ -34,11 +35,21 @@ class SkillManager:
manager.activate(skill)
"""
def __init__(self):
"""Initialize the SkillManager."""
def __init__(self, enforcement_level: Optional[EnforcementLevel] = None):
"""Initialize the SkillManager.
Args:
enforcement_level: Capability enforcement level (defaults to environment-based)
"""
self._skills: Dict[str, LoadedSkill] = {}
self._loader = SkillLoader()
self._discovered = False
self._validation_cache: Dict[str, ValidationResult] = {}
# Initialize capability validator
if enforcement_level is None:
enforcement_level = self._get_default_enforcement_level()
self._validator = CapabilityValidator(enforcement_level)
@property
def skills(self) -> List[LoadedSkill]:
@@ -126,15 +137,29 @@ class SkillManager:
def get_available_skills(self) -> List[SkillMetadata]:
"""Get metadata for all available skills.
This is used for system prompt injection. Skills with
``disable-model-invocation: true`` are omitted so the LLM never
sees them and cannot auto-trigger them.
This is used for system prompt injection. Skills are filtered by:
1. disable-model-invocation: true (excluded)
2. Capability enforcement (if strict mode, exclude UNAVAILABLE skills)
"""
return [
skill.metadata
for skill in self._skills.values()
if not getattr(skill.properties, "disable_model_invocation", False)
]
available = []
for skill in self._skills.values():
# Skip if explicitly disabled for model invocation
if getattr(skill.properties, "disable_model_invocation", False):
continue
# Check capability requirements if enforcement is strict
if self._validator.enforcement_level == EnforcementLevel.STRICT:
try:
validation = self.validate_skill_capabilities(skill.properties.name)
if validation.state == SkillState.UNAVAILABLE:
continue # Skip unavailable skills in strict mode
except Exception as e:
logger.warning(f"Skipping skill '{skill.properties.name}' due to validation error: {e}")
continue
available.append(skill.metadata)
return available
def get_user_invocable_skills(self) -> List[LoadedSkill]:
"""Return skills the *user* may invoke via slash-commands.
@@ -566,9 +591,90 @@ author: agent
logger.debug("Failed to clean up temp file %s", temp_path, exc_info=True)
raise
def validate_skill_capabilities(self, skill_name: str, force_refresh: bool = False) -> ValidationResult:
"""Validate a skill's capability requirements.
Args:
skill_name: Name of the skill to validate
force_refresh: If True, bypass cache and re-validate
Returns:
ValidationResult with capability status
Raises:
ValueError: If skill not found
"""
skill = self.get_skill(skill_name)
if skill is None:
raise ValueError(f"Skill '{skill_name}' not found")
# Check cache first
if not force_refresh and skill_name in self._validation_cache:
return self._validation_cache[skill_name]
# Perform validation
result = self._validator.validate_skill(skill.properties)
self._validation_cache[skill_name] = result
return result
def get_available_skills_by_state(self, state: SkillState) -> List[LoadedSkill]:
"""Get skills filtered by their capability validation state.
Args:
state: Desired skill state
Returns:
List of skills in the specified state
"""
matching = []
for skill in self._skills.values():
try:
result = self.validate_skill_capabilities(skill.properties.name)
if result.state == state:
matching.append(skill)
except Exception as e:
logger.warning(f"Failed to validate skill '{skill.properties.name}': {e}")
continue
return matching
def get_skills_diagnostics(self) -> Dict[str, ValidationResult]:
"""Get capability diagnostics for all skills.
Returns:
Dict mapping skill names to their validation results
"""
diagnostics = {}
for skill_name in self.skill_names:
try:
diagnostics[skill_name] = self.validate_skill_capabilities(skill_name)
except Exception as e:
logger.error(f"Failed to validate skill '{skill_name}': {e}")
return diagnostics
def _get_default_enforcement_level(self) -> EnforcementLevel:
"""Get default enforcement level from environment."""
import os
level_str = os.getenv('SKILL_CAPABILITY_ENFORCEMENT', 'warn').lower()
level_map = {
'disabled': EnforcementLevel.DISABLED,
'off': EnforcementLevel.DISABLED,
'telemetry': EnforcementLevel.TELEMETRY,
'log': EnforcementLevel.TELEMETRY,
'warn': EnforcementLevel.WARN,
'warning': EnforcementLevel.WARN,
'strict': EnforcementLevel.STRICT,
'hard': EnforcementLevel.STRICT,
'fail': EnforcementLevel.STRICT,
}
return level_map.get(level_str, EnforcementLevel.WARN)
def clear(self) -> None:
"""Clear all loaded skills."""
self._skills.clear()
self._validation_cache.clear()
self._validator.clear_cache()
self._discovered = False
def __len__(self) -> int:
@@ -1,8 +1,9 @@
"""Data models for Agent Skills."""
from dataclasses import dataclass, field
from typing import Optional
from typing import Optional, List, Union
from pathlib import Path
from enum import Enum
class ParseError(Exception):
@@ -15,6 +16,105 @@ class ValidationError(Exception):
pass
class SkillState(Enum):
"""Skill activation state based on requirement validation."""
ACTIVE = "active" # All requirements satisfied
DEGRADED = "degraded" # Some requirements missing (soft warn)
UNAVAILABLE = "unavailable" # Critical requirements missing (hard fail)
UNKNOWN = "unknown" # Requirements not yet validated
@dataclass
class SkillRequirements:
"""Capability requirements for a skill.
This represents the parsed and normalized requirements from skill frontmatter.
Supports both Hermes/OpenClaw conventions and PraisonAI-specific extensions.
Attributes:
servers: Required MCP servers or service endpoints
tools: Required tool names (from tool registry)
env_vars: Required environment variables (use sparingly)
openclaw_hints: OpenClaw-specific metadata (passthrough)
"""
servers: List[str] = field(default_factory=list)
tools: List[str] = field(default_factory=list)
env_vars: List[str] = field(default_factory=list)
openclaw_hints: dict = field(default_factory=dict)
@classmethod
def from_frontmatter(cls, metadata: dict) -> "SkillRequirements":
"""Parse requirements from skill frontmatter.
Normalizes various naming conventions:
- requires_servers, requires_tools (Hermes/OpenClaw style)
- allowed-tools (existing PraisonAI convention)
- openclaw (OpenClaw-specific hints)
Args:
metadata: Raw frontmatter dict
Returns:
SkillRequirements instance
"""
servers = []
tools = []
env_vars = []
openclaw_hints = {}
# Parse servers
if "requires_servers" in metadata:
servers = cls._normalize_list(metadata["requires_servers"])
elif "requires-servers" in metadata:
servers = cls._normalize_list(metadata["requires-servers"])
# Parse tools (multiple naming conventions)
if "requires_tools" in metadata:
tools.extend(cls._normalize_list(metadata["requires_tools"]))
elif "requires-tools" in metadata:
tools.extend(cls._normalize_list(metadata["requires-tools"]))
# Support existing allowed-tools convention for backward compatibility
if "allowed-tools" in metadata:
tools.extend(cls._normalize_list(metadata["allowed-tools"]))
# Parse environment variables
if "requires_env" in metadata:
env_vars = cls._normalize_list(metadata["requires_env"])
elif "requires-env" in metadata:
env_vars = cls._normalize_list(metadata["requires-env"])
# OpenClaw hints (passthrough)
if "openclaw" in metadata:
openclaw_hints = metadata["openclaw"] if isinstance(metadata["openclaw"], dict) else {}
return cls(
servers=servers,
tools=tools,
env_vars=env_vars,
openclaw_hints=openclaw_hints
)
@staticmethod
def _normalize_list(value: Union[str, List[str]]) -> List[str]:
"""Normalize string or list to list of strings."""
if isinstance(value, str):
# Support both comma-separated and space-separated strings
return [item.strip() for item in value.replace(',', ' ').split() if item.strip()]
elif isinstance(value, (list, tuple)):
return [str(item).strip() for item in value if str(item).strip()]
else:
return []
def is_empty(self) -> bool:
"""Check if no requirements are specified."""
return not any([self.servers, self.tools, self.env_vars, self.openclaw_hints])
def __bool__(self) -> bool:
"""Truth value: True if any requirements are specified."""
return not self.is_empty()
@dataclass
class SkillProperties:
"""Properties parsed from a skill's SKILL.md frontmatter.
@@ -49,6 +149,8 @@ class SkillProperties:
hooks: Optional[dict] = None
paths: Optional[object] = None # list[str] glob patterns
shell: Optional[str] = None # "bash" | "powershell"
# Capability requirements (new)
requirements: Optional[SkillRequirements] = None
def to_dict(self) -> dict:
"""Convert to dictionary, excluding None values and empty metadata."""
@@ -3,7 +3,7 @@
from pathlib import Path
from typing import Optional, Tuple
from .models import ParseError, ValidationError, SkillProperties
from .models import ParseError, ValidationError, SkillProperties, SkillRequirements
def find_skill_md(skill_dir: Path) -> Optional[Path]:
@@ -100,7 +100,7 @@ def read_properties(skill_dir: Path) -> SkillProperties:
if skill_md is None:
raise ParseError(f"SKILL.md not found in {skill_dir}")
content = skill_md.read_text()
content = skill_md.read_text(encoding="utf-8")
metadata, _ = parse_frontmatter(content)
if "name" not in metadata:
@@ -143,4 +143,6 @@ def read_properties(skill_dir: Path) -> SkillProperties:
hooks=metadata.get("hooks") if isinstance(metadata.get("hooks"), dict) else None,
paths=metadata.get("paths"),
shell=metadata.get("shell"),
# Parse capability requirements
requirements=SkillRequirements.from_frontmatter(metadata),
)
@@ -7,7 +7,7 @@ plugin bundle, enterprise policy) without touching the core.
from __future__ import annotations
from typing import Protocol, Iterable, Optional, runtime_checkable
from typing import Protocol, Iterable, Optional, runtime_checkable, List, Dict, Any
from .models import SkillProperties
@@ -45,7 +45,8 @@ class SkillMutatorProtocol(Protocol):
"""Agent-managed skill CRUD operations.
Allows agents to create, edit, and manage their own skills at runtime.
Implementations should provide safe-by-default behavior (e.g., propose mode).
Implementations should provide safe-by-default behaviour: use ``propose=True``
(default) to stage mutations for human approval before writing to disk.
"""
def create(self, name: str, content: str, category: Optional[str] = None,
@@ -163,3 +164,30 @@ class SkillMutatorProtocol(Protocol):
Status message describing the action taken
"""
...
@runtime_checkable
class SkillsCatalogProtocol(Protocol):
"""Metadata listing for skills — UI catalog pages."""
def list_skills(self) -> List[Dict[str, Any]]:
"""Return skill metadata dicts (name, description, location)."""
...
def list_skills_for_api() -> List[Dict[str, Any]]:
"""Default catalog adapter using SkillManager when available."""
try:
from .manager import SkillManager
manager = SkillManager()
return [
{
"name": s.name,
"description": s.description,
"location": getattr(s, "location", ""),
}
for s in manager.get_available_skills()
]
except Exception:
return []
@@ -191,9 +191,11 @@ def validate(skill_dir: Path, strict: bool = False) -> List[str]:
return ["Missing required file: SKILL.md"]
try:
content = skill_md.read_text()
content = skill_md.read_text(encoding="utf-8")
metadata, _ = parse_frontmatter(content)
except ParseError as e:
return [str(e)]
except UnicodeDecodeError as e:
return [f"SKILL.md is not valid UTF-8: {e}"]
return validate_metadata(metadata, skill_dir, strict=strict)
@@ -100,3 +100,107 @@ class InMemoryTokenUsageSink:
def clear(self) -> None:
"""Clear all records."""
self.records.clear()
@runtime_checkable
class UsageQueryProtocol(Protocol):
"""Read path for token usage — UI dashboards and analytics."""
def get_summary(self) -> Dict[str, Any]:
"""Aggregate totals and breakdowns by model/agent."""
...
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
"""Recent usage records, newest last."""
...
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
...
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
...
class InMemoryUsageQuery:
"""Query adapter backed by InMemoryTokenUsageSink records."""
def __init__(self, sink: InMemoryTokenUsageSink):
self._sink = sink
def get_summary(self) -> Dict[str, Any]:
records = self._sink.records
total_in = 0
total_out = 0
total_all = 0
by_model: Dict[str, Dict[str, int]] = {}
by_agent: Dict[str, Dict[str, int]] = {}
# Single-pass optimization: calculate everything in one loop
for r in records:
r_in = r.get("input_tokens", 0)
r_out = r.get("output_tokens", 0)
r_total = r.get("total_tokens", r_in + r_out)
total_in += r_in
total_out += r_out
total_all += r_total
model = r.get("model", "unknown")
agent = r.get("agent_name", "unknown")
# Update by_model and by_agent efficiently
for bucket, key in ((by_model, model), (by_agent, agent)):
stats = bucket.setdefault(key, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
stats["input_tokens"] += r_in
stats["output_tokens"] += r_out
stats["total_tokens"] += r_total
return {
"total_requests": len(records),
"total_input_tokens": total_in,
"total_output_tokens": total_out,
"total_tokens": total_all, # Use consistent calculation
"by_model": by_model,
"by_agent": by_agent,
}
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
return self._sink.records[-limit:]
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
return self._sink.get_by_task(task_id)
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
return self._sink.get_by_agent(agent_name)
class TokenCollectorUsageQuery:
"""Query adapter reading from the global TokenCollector + optional sink."""
def __init__(self, collector: Any):
self._collector = collector
def get_summary(self) -> Dict[str, Any]:
summary = self._collector.get_session_summary()
return {
"total_requests": summary.get("total_interactions", 0),
"total_input_tokens": summary.get("total_metrics", {}).get("input_tokens", 0),
"total_output_tokens": summary.get("total_metrics", {}).get("output_tokens", 0),
"total_tokens": summary.get("total_tokens", 0),
"by_model": summary.get("by_model", {}),
"by_agent": summary.get("by_agent", {}),
}
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
return self._collector.get_recent_interactions(limit)
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
sink = getattr(self._collector, "_sink", None)
if sink and hasattr(sink, "get_by_task"):
return sink.get_by_task(task_id)
return []
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
sink = getattr(self._collector, "_sink", None)
if sink and hasattr(sink, "get_by_agent"):
return sink.get_by_agent(agent_name)
return []
@@ -191,4 +191,9 @@ class TokenCollector:
# Global token collector instance
_token_collector = TokenCollector()
_token_collector = TokenCollector()
def get_token_collector() -> TokenCollector:
"""Return the global TokenCollector singleton."""
return _token_collector
@@ -7,7 +7,7 @@ import logging
from praisonaiagents._logging import get_logger
# Import dependencies
from .token_collector import _token_collector, TokenMetrics
from .token_collector import get_token_collector, TokenMetrics
from .telemetry import get_telemetry
logger = get_logger(__name__)
@@ -56,11 +56,12 @@ class TokenTelemetryBridge:
def export_token_metrics(self) -> Dict[str, Any]:
"""Export token metrics for telemetry reporting."""
if not _token_collector:
collector = get_token_collector()
if not collector:
return {}
try:
metrics = _token_collector.export_metrics()
metrics = collector.export_metrics()
# Prepare telemetry-friendly format
return {
@@ -77,8 +78,7 @@ class TokenTelemetryBridge:
def reset_token_metrics(self):
"""Reset token metrics collection."""
if _token_collector:
_token_collector.reset()
get_token_collector().reset()
# Global telemetry bridge instance
_token_telemetry_bridge = TokenTelemetryBridge()
@@ -14,10 +14,31 @@ import json
from typing import Any, Dict, List, Union
from praisonaiagents.tools.decorator import tool
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
def _unwrap_messages(messages: Union[List[Dict[str, Any]], Dict[str, Any], str]) -> List[Dict[str, Any]]:
"""Minimal unwrap for common LLM arg shapes (not full UI normalisation)."""
if isinstance(messages, str):
try:
messages = json.loads(messages)
except json.JSONDecodeError as exc:
raise ValueError(f"messages JSON string is not valid JSON: {exc}") from exc
if isinstance(messages, dict):
if "messages" in messages:
messages = messages["messages"]
else:
messages = [messages]
if not isinstance(messages, list):
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
return messages
@tool
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str, Any]:
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], Dict[str, Any], str]) -> Dict[str, Any]:
"""
Send validated A2UI JSON messages to a connected A2UI renderer client.
@@ -32,21 +53,14 @@ def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str,
"""
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part
if isinstance(messages, str):
try:
messages = json.loads(messages)
except json.JSONDecodeError as exc:
raise ValueError(f"messages JSON string is not valid JSON: {exc}") from exc
if not isinstance(messages, list):
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
messages = _unwrap_messages(messages)
part = create_a2ui_part({"messages": messages})
data = getattr(getattr(part, "root", part), "data", messages)
metadata = getattr(getattr(part, "root", part), "metadata", {}) or {}
return {
"mime_type": metadata.get("mimeType", "application/json+a2ui"),
"mime_type": metadata.get("mimeType", A2UI_MIME_TYPE),
"messages": messages,
"a2ui_part": data,
}
@@ -15,7 +15,7 @@ Usage:
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Type, get_type_hints
from typing import Any, Dict, List, Optional, Type, get_type_hints
import inspect
import logging
@@ -234,18 +234,81 @@ class BaseTool(ABC):
if getattr(self.run, '__isabstractmethod__', False):
errors.append("Tool must implement the 'run()' method")
# Check parameters schema is valid
# Check parameters schema is valid and OpenAI-compatible
if self.parameters:
if not isinstance(self.parameters, dict):
errors.append("'parameters' must be a dictionary")
elif "type" not in self.parameters:
errors.append("'parameters' must have a 'type' field")
elif "properties" not in self.parameters:
errors.append("'parameters' must have a 'properties' field for OpenAI compatibility")
# Validate schema structure for OpenAI compatibility
try:
schema = self.get_schema()
if not isinstance(schema, dict):
errors.append("get_schema() must return a dictionary")
elif schema.get("type") != "function":
errors.append("get_schema() must return schema with type='function'")
elif "function" not in schema:
errors.append("get_schema() must have 'function' key")
else:
func = schema["function"]
if not isinstance(func.get("parameters"), dict):
errors.append("Schema function.parameters must be a dictionary")
elif "properties" not in func["parameters"]:
errors.append("Schema function.parameters must have 'properties' for OpenAI compatibility")
except Exception as e:
errors.append(f"Schema generation failed: {e}")
if errors:
raise ToolValidationError(f"Tool '{self.name}' validation failed: {'; '.join(errors)}")
return True
def validate_schema_roundtrip(self) -> bool:
"""Validate that schema can round-trip through OpenAI-style serialization.
This ensures the tool schema is compatible with LLM providers that expect
OpenAI function calling format and that parameters can be properly parsed.
Raises:
ToolValidationError: If round-trip fails
Returns:
True if round-trip validation passes
"""
try:
import json
# Get the schema
schema = self.get_schema()
# Serialize to JSON (what gets sent to LLM)
serialized = json.dumps(schema)
# Deserialize back
deserialized = json.loads(serialized)
# Validate structure matches expectations
if deserialized != schema:
raise ToolValidationError(f"Schema serialization round-trip failed for tool '{self.name}'")
# Validate OpenAI format requirements
func_schema = deserialized.get("function", {})
parameters = func_schema.get("parameters", {})
if not isinstance(parameters.get("properties"), dict):
raise ToolValidationError(f"Tool '{self.name}' parameters.properties must be a dict for OpenAI compatibility")
if "required" in parameters and not isinstance(parameters["required"], list):
raise ToolValidationError(f"Tool '{self.name}' parameters.required must be a list")
return True
except Exception as e:
raise ToolValidationError(f"Schema round-trip validation failed for tool '{self.name}': {e}")
@classmethod
def validate_class(cls) -> bool:
"""Validate a tool class before instantiation.
@@ -282,7 +345,9 @@ def validate_tool(tool: Any) -> bool:
ToolValidationError: If validation fails
"""
if isinstance(tool, BaseTool):
return tool.validate()
tool.validate()
tool.validate_schema_roundtrip()
return True
if callable(tool):
# For plain functions, check they have a name
@@ -294,5 +359,67 @@ def validate_tool(tool: Any) -> bool:
raise ToolValidationError(f"Invalid tool type: {type(tool)}")
def validate_tool_schema_consistency(tools: List[Any]) -> bool:
"""Validate a list of tools for schema consistency with OpenAI format.
This function ensures all tools in a list can be properly serialized
and have consistent schema structures for use with LLM providers.
Args:
tools: List of tool objects to validate
Returns:
True if all tools are valid and consistent
Raises:
ToolValidationError: If validation fails
"""
if not tools:
return True
import json
from .decorator import get_tool_schema
schemas = []
for i, tool in enumerate(tools):
try:
# Validate individual tool
validate_tool(tool)
# Get schema from different tool types
if isinstance(tool, BaseTool):
schema = tool.get_schema()
elif hasattr(tool, 'get_schema') and callable(getattr(tool, 'get_schema')):
schema = tool.get_schema()
elif callable(tool):
schema = get_tool_schema(tool)
else:
raise ToolValidationError(f"Cannot extract schema from tool at index {i}: {type(tool)}")
if not schema:
raise ToolValidationError(f"Tool at index {i} returned empty schema")
# Validate JSON serialization
try:
json.dumps(schema)
except (TypeError, ValueError) as e:
raise ToolValidationError(f"Tool schema at index {i} is not JSON serializable: {e}")
schemas.append(schema)
except Exception as e:
raise ToolValidationError(f"Tool validation failed at index {i}: {e}")
# Check for duplicate tool names
names = set()
for schema in schemas:
name = schema.get("function", {}).get("name")
if name in names:
raise ToolValidationError(f"Duplicate tool name '{name}' found in tool list")
names.add(name)
return True
# For backward compatibility - tools can also just be functions
# The @tool decorator (in decorator.py) wraps functions into BaseTool instances
@@ -214,6 +214,13 @@ def tool(
availability=availability
)
# Validate the tool at creation time for early error detection
try:
tool_instance.validate()
tool_instance.validate_schema_roundtrip()
except Exception as e:
logging.warning(f"Tool validation warning for {tool_instance.name}: {e}")
# Register with global registry if available
try:
from .registry import get_registry
@@ -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
@@ -253,7 +253,7 @@ class A2A:
from praisonaiagents.ui.a2a.conversion import (
extract_user_input,
praisonai_to_a2a_message,
create_artifact,
create_response_artifact,
)
# Validate params
@@ -286,7 +286,8 @@ class A2A:
else:
raise RuntimeError("No agent or agents configured")
response_msg = praisonai_to_a2a_message(str(response), task_id=task_id)
artifact = create_artifact(str(response))
history = getattr(self.agent, "chat_history", None) if self.agent else None
artifact = create_response_artifact(str(response), chat_history=history)
self.task_store.add_to_history(task_id, response_msg)
self.task_store.add_artifact(task_id, artifact)
self.task_store.update_status(task_id, TaskState.COMPLETED)
@@ -322,7 +323,8 @@ class A2A:
response_msg = praisonai_to_a2a_message(
str(response), task_id=task.id
)
artifact = create_artifact(str(response))
history = getattr(self.agent, "chat_history", None) if self.agent else None
artifact = create_response_artifact(str(response), chat_history=history)
# Update task store
self.task_store.add_to_history(task.id, response_msg)
@@ -16,6 +16,7 @@ from praisonaiagents.ui.a2a.types import (
DataPart,
Artifact,
)
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
def a2a_to_praisonai_messages(messages: List[Message]) -> List[Dict[str, Any]]:
@@ -107,6 +108,75 @@ def praisonai_to_a2a_message(
)
def find_a2ui_tool_result(
chat_history: Optional[List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""Return the last A2UI tool result dict from agent chat history, if any."""
if not chat_history:
return None
for msg in reversed(chat_history):
if msg.get("role") != "tool":
continue
content = msg.get("content")
if content is None:
continue
data: Any = content if isinstance(content, dict) else None
if data is None:
try:
data = json.loads(content)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(data, dict) and data.get("mime_type") == A2UI_MIME_TYPE:
return data
return None
def create_a2ui_artifact(
result: Dict[str, Any],
artifact_id: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
) -> Artifact:
"""Create an A2A Artifact with a DataPart for an A2UI tool result."""
art_id = artifact_id or f"art-{uuid.uuid4().hex[:12]}"
messages = result.get("messages") or []
a2ui_data = result.get("a2ui_part")
if not isinstance(a2ui_data, dict):
a2ui_data = {"messages": messages}
elif "messages" not in a2ui_data:
a2ui_data = {**a2ui_data, "messages": messages}
return Artifact(
artifact_id=art_id,
name=name,
description=description,
parts=[
DataPart(
data=a2ui_data,
metadata={"mimeType": result.get("mime_type", A2UI_MIME_TYPE)},
)
],
)
def create_response_artifact(
content: str,
chat_history: Optional[List[Dict[str, Any]]] = None,
artifact_id: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
) -> Artifact:
"""Build artifact from response text, preferring A2UI DataPart when present in history."""
a2ui = find_a2ui_tool_result(chat_history)
if a2ui:
return create_a2ui_artifact(
a2ui, artifact_id=artifact_id, name=name, description=description
)
return create_artifact(
content, artifact_id=artifact_id, name=name, description=description
)
def create_artifact(
content: str,
artifact_id: Optional[str] = None,
@@ -12,9 +12,12 @@ from praisonaiagents.ui.a2ui.adapter import (
is_a2ui_part,
parse_a2ui_response,
)
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE, A2UIToolResultProtocol
__all__ = [
"A2UI",
"A2UI_MIME_TYPE",
"A2UIToolResultProtocol",
"create_a2ui_part",
"generate_a2ui_system_prompt",
"get_schema_manager",
@@ -12,6 +12,7 @@ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Set
from praisonaiagents.ui.agui.types import (
BaseEvent,
CustomEvent,
TextMessageStartEvent,
TextMessageContentEvent,
TextMessageEndEvent,
@@ -26,6 +27,7 @@ from praisonaiagents.ui.agui.types import (
StepFinishedEvent,
StateSnapshotEvent,
)
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
@dataclass
@@ -292,13 +294,41 @@ def stream_event_to_agui_events(
if event.type == StreamEventType.TOOL_CALL_RESULT and event.tool_call:
tool_call_id = event.tool_call.get("id") or str(uuid.uuid4())
content = event.content if event.content is not None else str(event.tool_call.get("result", ""))
raw_result = event.tool_call.get("result")
content = event.content if event.content is not None else str(raw_result or "")
buffer.end_tool_call(tool_call_id)
return [create_tool_result_event(tool_call_id, str(content), message_id)]
events: List[BaseEvent] = [
create_tool_result_event(tool_call_id, str(content), message_id)
]
if isinstance(raw_result, dict) and raw_result.get("mime_type") == A2UI_MIME_TYPE:
events.append(
CustomEvent(
name="a2ui",
value={
"mime_type": A2UI_MIME_TYPE,
"messages": raw_result.get("messages", []),
"surface_id": _infer_a2ui_surface_id(raw_result),
},
)
)
return events
return []
def _infer_a2ui_surface_id(result: Dict[str, Any]) -> str:
"""Infer surface id from send_a2ui_messages result (inline, no UI-layer deps)."""
for msg in result.get("messages") or []:
if isinstance(msg, dict):
create = msg.get("createSurface")
if isinstance(create, dict):
sid = create.get("surfaceId") or create.get("id")
if sid:
return str(sid)
sid = result.get("surface_id") or result.get("surfaceId")
return str(sid) if sid else "main"
async def async_stream_agent_response(
agent,
user_input: str,
@@ -133,6 +133,13 @@ class StateSnapshotEvent(BaseEvent):
snapshot: Dict[str, Any]
class CustomEvent(BaseEvent):
"""Custom extension event (e.g. structured A2UI payloads)."""
type: Literal["CUSTOM"] = "CUSTOM"
name: str
value: Any
# Message Types
class FunctionCall(BaseModel):
"""Function call in a tool call."""
@@ -175,6 +182,7 @@ Event = Union[
ToolCallEndEvent,
ToolCallResultEvent,
StateSnapshotEvent,
CustomEvent,
RunStartedEvent,
RunFinishedEvent,
RunErrorEvent,
@@ -0,0 +1,26 @@
"""
UI protocol types — lightweight contracts for agent-to-UI integration.
Zero runtime cost: TypedDict and constants only. No optional dependencies.
"""
from __future__ import annotations
from typing import Any, Dict, List, TypedDict, Union
# Standard MIME type for A2UI payloads (Google A2UI / A2A transport).
A2UI_MIME_TYPE = "application/json+a2ui"
class A2UIToolResultProtocol(TypedDict, total=False):
"""
Output shape from ``send_a2ui_messages`` — the integrator contract for any UI.
Example:
if result.get("mime_type") == A2UI_MIME_TYPE:
render(result["messages"])
"""
mime_type: str
messages: List[Dict[str, Any]]
a2ui_part: Union[Dict[str, Any], List[Any]]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.43"
version = "1.6.52"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -233,6 +233,47 @@ class TestA2AJsonRpc:
assert result["status"]["state"] == "completed"
assert result["artifacts"] is not None
assert len(result["artifacts"]) > 0
def test_post_a2a_message_send_a2ui_artifact(self):
"""message/send with A2UI tool result in chat_history → DataPart artifact."""
import json
from fastapi.testclient import TestClient
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
app, a2a = _make_app()
a2ui_payload = {
"mime_type": A2UI_MIME_TYPE,
"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}],
"a2ui_part": {"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}]},
}
a2a.agent.chat = MagicMock(return_value="Surface ready.")
a2a.agent.chat_history = [
{"role": "tool", "content": json.dumps(a2ui_payload)},
]
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "a2ui-1",
"params": {
"message": {
"messageId": "msg-a2ui",
"role": "user",
"parts": [{"text": "Show UI"}],
}
},
})
assert response.status_code == 200
result = response.json()["result"]
artifact = result["artifacts"][0]
parts = artifact["parts"]
data_parts = [p for p in parts if "data" in p]
assert data_parts, "Expected DataPart in artifact"
assert data_parts[0]["metadata"]["mimeType"] == A2UI_MIME_TYPE
def test_post_a2a_message_send_creates_task_in_store(self):
"""message/send should create a task in TaskStore."""
@@ -0,0 +1,66 @@
"""Tests for A2UI artifact conversion in A2A transport."""
import json
import pytest
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
SAMPLE_A2UI_RESULT = {
"mime_type": A2UI_MIME_TYPE,
"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}],
"a2ui_part": {"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}]},
}
class TestFindA2uiToolResult:
def test_finds_last_a2ui_tool_message(self):
from praisonaiagents.ui.a2a.conversion import find_a2ui_tool_result
history = [
{"role": "user", "content": "show ui"},
{"role": "tool", "content": json.dumps({"mime_type": "text/plain", "data": "x"})},
{"role": "tool", "content": json.dumps(SAMPLE_A2UI_RESULT)},
]
result = find_a2ui_tool_result(history)
assert result is not None
assert result["mime_type"] == A2UI_MIME_TYPE
def test_returns_none_without_a2ui(self):
from praisonaiagents.ui.a2a.conversion import find_a2ui_tool_result
assert find_a2ui_tool_result([]) is None
assert find_a2ui_tool_result([{"role": "tool", "content": "plain text"}]) is None
class TestCreateA2uiArtifact:
def test_creates_datapart_with_mime(self):
from praisonaiagents.ui.a2a.conversion import create_a2ui_artifact
from praisonaiagents.ui.a2a.types import DataPart
artifact = create_a2ui_artifact(SAMPLE_A2UI_RESULT, name="ui-surface")
assert artifact.name == "ui-surface"
assert len(artifact.parts) == 1
part = artifact.parts[0]
assert isinstance(part, DataPart)
assert part.metadata.get("mimeType") == A2UI_MIME_TYPE
assert "messages" in part.data
class TestCreateResponseArtifact:
def test_prefers_a2ui_from_history(self):
from praisonaiagents.ui.a2a.conversion import create_response_artifact
from praisonaiagents.ui.a2a.types import DataPart
history = [{"role": "tool", "content": json.dumps(SAMPLE_A2UI_RESULT)}]
artifact = create_response_artifact("Done.", chat_history=history)
assert isinstance(artifact.parts[0], DataPart)
def test_falls_back_to_text(self):
from praisonaiagents.ui.a2a.conversion import create_response_artifact
from praisonaiagents.ui.a2a.types import TextPart
artifact = create_response_artifact("Hello", chat_history=[])
assert isinstance(artifact.parts[0], TextPart)
assert artifact.parts[0].text == "Hello"
@@ -0,0 +1,51 @@
"""Tests for send_a2ui_messages minimal arg unwrap."""
import json
import pytest
from praisonaiagents.tools.a2ui_tools import _unwrap_messages
try:
import a2ui # noqa: F401
HAS_A2UI_SDK = True
except ImportError:
HAS_A2UI_SDK = False
SAMPLE_MSG = {"createSurface": {"surfaceId": "main", "catalogId": "basic"}}
class TestUnwrapMessages:
def test_list_input(self):
assert _unwrap_messages([SAMPLE_MSG]) == [SAMPLE_MSG]
def test_json_string_list(self):
assert _unwrap_messages(json.dumps([SAMPLE_MSG])) == [SAMPLE_MSG]
def test_wrapped_messages_dict(self):
assert _unwrap_messages({"messages": [SAMPLE_MSG]}) == [SAMPLE_MSG]
def test_json_string_wrapped_object(self):
assert _unwrap_messages(json.dumps({"messages": [SAMPLE_MSG]})) == [SAMPLE_MSG]
def test_single_message_dict(self):
assert _unwrap_messages(SAMPLE_MSG) == [SAMPLE_MSG]
def test_invalid_json_raises(self):
with pytest.raises(ValueError, match="not valid JSON"):
_unwrap_messages("{not json")
def test_invalid_shape_raises(self):
with pytest.raises(ValueError, match="must be"):
_unwrap_messages(123)
@pytest.mark.skipif(not HAS_A2UI_SDK, reason="a2ui-agent-sdk not installed")
class TestSendA2uiMessagesIntegration:
def test_full_tool_call(self):
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
result = send_a2ui_messages(messages=[SAMPLE_MSG])
assert result["mime_type"] == "application/json+a2ui"
assert len(result["messages"]) == 1
@@ -34,3 +34,27 @@ class TestA2UITool:
)
assert result["mime_type"] == "application/json+a2ui"
assert len(result["messages"]) == 1
class TestA2UIAdapterSmoke:
def test_is_a2ui_part_with_sdk(self):
pytest.importorskip("a2ui", reason="a2ui-agent-sdk not installed")
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part, is_a2ui_part
part = create_a2ui_part({"messages": [{"createSurface": {"surfaceId": "m"}}]})
assert is_a2ui_part(part) is True
def test_missing_sdk_import_error_message(self):
from unittest.mock import patch
from praisonaiagents.ui.a2ui import adapter
def _raise(_name):
raise ImportError(
"A2UI support requires the optional dependency. "
"Install with: pip install praisonaiagents[a2ui]"
)
with patch.object(adapter, "_import", side_effect=_raise):
with pytest.raises(ImportError, match="praisonaiagents\\[a2ui\\]"):
adapter.create_a2ui_part({"messages": []})
@@ -0,0 +1,27 @@
"""Tests for UI protocol types (zero-dep contracts)."""
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE, A2UIToolResultProtocol
class TestA2UIProtocols:
def test_mime_type_constant(self):
assert A2UI_MIME_TYPE == "application/json+a2ui"
def test_tool_result_protocol_shape(self):
result: A2UIToolResultProtocol = {
"mime_type": A2UI_MIME_TYPE,
"messages": [{"createSurface": {"surfaceId": "main"}}],
"a2ui_part": {"messages": []},
}
assert result["mime_type"] == A2UI_MIME_TYPE
def test_exported_from_a2ui_package(self):
from praisonaiagents.ui.a2ui import A2UI_MIME_TYPE, A2UIToolResultProtocol
assert A2UI_MIME_TYPE == "application/json+a2ui"
assert A2UIToolResultProtocol is not None
def test_top_level_lazy_a2ui_export(self):
from praisonaiagents import A2UI
assert A2UI is not None
@@ -129,6 +129,29 @@ class TestStreamEventBridge:
assert agui_events[0].type == "TOOL_CALL_START"
assert agui_events[0].tool_call_name == "search"
def test_tool_call_result_a2ui_emits_custom_event(self):
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
from praisonaiagents.ui.agui.streaming import EventBuffer, stream_event_to_agui_events
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
buffer = EventBuffer()
a2ui_result = {
"mime_type": A2UI_MIME_TYPE,
"messages": [{"createSurface": {"surfaceId": "panel-1"}}],
}
event = StreamEvent(
type=StreamEventType.TOOL_CALL_RESULT,
tool_call={"id": "tc-a2ui", "name": "send_a2ui_messages", "result": a2ui_result},
)
agui_events = stream_event_to_agui_events(event, "msg-1", buffer)
assert len(agui_events) == 2
assert agui_events[0].type == "TOOL_CALL_RESULT"
assert agui_events[1].type == "CUSTOM"
assert agui_events[1].name == "a2ui"
assert agui_events[1].value["surface_id"] == "panel-1"
assert agui_events[1].value["messages"] == a2ui_result["messages"]
class TestRunLifecycleEvents:
"""Test run lifecycle events."""
@@ -349,6 +349,175 @@ 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
from praisonaiagents.db.protocol import DbMessage
persisted: list = []
mock_db = MagicMock()
def _on_start(agent_name, session_id):
return list(persisted)
def _on_user(sid, content, **kw):
persisted.append(DbMessage(role="user", content=content))
def _on_assistant(sid, content, **kw):
persisted.append(DbMessage(role="assistant", content=content))
mock_db.on_agent_start.side_effect = _on_start
mock_db.on_user_message.side_effect = _on_user
mock_db.on_agent_message.side_effect = _on_assistant
conv = MagicMock()
conv.delete_messages.side_effect = lambda sid, ids: persisted.clear()
mock_db.conversation = conv
adapter = DbSessionAdapter(mock_db)
sid = "bot-trim-session"
adapter.add_message(sid, "user", "old")
adapter.add_message(sid, "assistant", "old-reply")
assert len(persisted) == 2
adapter.clear_session(sid)
conv.delete_messages.assert_called_once_with(sid, None)
assert persisted == []
assert adapter.get_chat_history(sid) == []
adapter.add_message(sid, "user", "new-only")
assert len(adapter.get_chat_history(sid)) == 1
assert len(persisted) == 1
adapter2 = DbSessionAdapter(mock_db)
assert adapter2.get_chat_history(sid) == [{"role": "user", "content": "new-only"}]
def test_metadata_survives_adapter_restart(self):
"""set_metadata must persist to the conversation store for resume after restart."""
from praisonai.integrations.db_session_adapter import DbSessionAdapter
from praisonai.persistence.conversation.base import ConversationSession
sessions: dict = {}
def _get_session(sid):
return sessions.get(sid)
def _create_session(session):
sessions[session.session_id] = session
def _update_session(session):
sessions[session.session_id] = session
conv = MagicMock()
conv.get_session.side_effect = _get_session
conv.create_session.side_effect = _create_session
conv.update_session.side_effect = _update_session
mock_db = MagicMock()
mock_db.on_agent_start.return_value = []
mock_db.conversation = None # Prevent auto-creation of conversation attr
mock_db._conversation_store = conv
sid = "meta-restart-session"
adapter1 = DbSessionAdapter(mock_db)
adapter1.set_metadata(
sid,
{
"agent_id": "agent-xyz",
"total_input_tokens": 99,
"session_history": [{"turn": 1}],
},
)
adapter2 = DbSessionAdapter(mock_db)
meta = adapter2.get_metadata(sid)
assert meta["agent_id"] == "agent-xyz"
assert meta["total_input_tokens"] == 99
assert meta["session_history"] == [{"turn": 1}]
def test_managed_agent_metadata_persistence_e2e(self):
"""Integration test: ManagedAgent metadata survives restart via real adapter."""
import tempfile
import os
from praisonaiagents.managed import LocalManagedAgent
from praisonai.integrations.db_session_adapter import DbSessionAdapter
# Create a real SQLite database for this test
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
db_path = tmp.name
try:
# Mock PraisonDB that uses file-based conversation store
mock_db = MagicMock()
mock_db.on_agent_start.return_value = []
# Real conversation store backed by file
from praisonai.persistence.conversation.base import ConversationSession
from praisonai.persistence.conversation.file_store import FileConversationStore
conv_store = FileConversationStore(store_path=db_path + "_conv.json")
mock_db.conversation = None
mock_db._conversation_store = conv_store
# Create and run agent with metadata
adapter = DbSessionAdapter(mock_db)
agent = LocalManagedAgent(
name="test-agent",
instructions="You are a helpful assistant.",
session_store=adapter,
llm="gpt-4o-mini"
)
# Start agent and collect session metadata
session_id = "integration-test-session"
adapter._ensure_session(session_id, agent.name)
# Set some metadata that should persist
test_metadata = {
"agent_id": "test-agent-123",
"total_input_tokens": 150,
"session_history": [{"turn": 1, "action": "started"}],
"compute_instance": "test-instance-1"
}
adapter.set_metadata(session_id, test_metadata)
# Simulate process restart with new adapter and agent
adapter2 = DbSessionAdapter(mock_db)
agent2 = LocalManagedAgent(
name="test-agent",
instructions="You are a helpful assistant.",
session_store=adapter2,
llm="gpt-4o-mini"
)
# Metadata should be restored from persistence
restored_meta = adapter2.get_metadata(session_id)
assert restored_meta["agent_id"] == "test-agent-123"
assert restored_meta["total_input_tokens"] == 150
assert restored_meta["session_history"] == [{"turn": 1, "action": "started"}]
assert restored_meta["compute_instance"] == "test-instance-1"
finally:
# Clean up temp files
for path in [db_path, db_path + "_conv.json"]:
if os.path.exists(path):
os.unlink(path)
# ===========================================================================
# 7. ManagedAgent factory wiring
@@ -0,0 +1,215 @@
"""Test tool schema parity and OpenAI compatibility.
This module tests the fixes for issue #1696 to ensure BaseTool ↔ OpenAI-compatible
tool schema parity in chat/agent dispatch.
"""
import pytest
import json
from typing import Any, Dict, List
from praisonaiagents.tools.base import BaseTool, ToolValidationError, validate_tool_schema_consistency
from praisonaiagents.tools.decorator import tool, FunctionTool
class MockTool(BaseTool):
"""A simple mock tool for testing."""
name = "mock_tool"
description = "A mock tool for testing"
def run(self, query: str, count: int = 1) -> str:
return f"Mock result for {query} (count: {count})"
class TestBasicToolSchemaValidation:
"""Test basic tool schema validation and OpenAI compatibility."""
def test_base_tool_schema_structure(self):
"""Test that BaseTool generates correct OpenAI schema structure."""
tool = MockTool()
schema = tool.get_schema()
# Validate top-level structure
assert isinstance(schema, dict)
assert schema["type"] == "function"
assert "function" in schema
# Validate function structure
func = schema["function"]
assert func["name"] == "mock_tool"
assert func["description"] == "A mock tool for testing"
assert "parameters" in func
# Validate parameters structure
params = func["parameters"]
assert params["type"] == "object"
assert "properties" in params
assert "required" in params
# Validate specific properties
props = params["properties"]
assert "query" in props
assert "count" in props
assert props["query"]["type"] == "string"
assert props["count"]["type"] == "integer"
# Validate required fields
assert "query" in params["required"]
assert "count" not in params["required"] # Has default value
def test_tool_schema_json_serialization(self):
"""Test that tool schemas can be JSON serialized (round-trip test)."""
tool = MockTool()
schema = tool.get_schema()
# Should serialize without errors
serialized = json.dumps(schema)
assert isinstance(serialized, str)
# Should deserialize back to same structure
deserialized = json.loads(serialized)
assert deserialized == schema
def test_tool_validation_passes(self):
"""Test that valid tools pass validation."""
tool = MockTool()
# Basic validation should pass
assert tool.validate() == True
# Round-trip validation should pass
assert tool.validate_schema_roundtrip() == True
def test_function_tool_schema_parity(self):
"""Test that @tool decorated functions have same schema structure."""
@tool
def test_function(query: str, count: int = 1) -> str:
"""A test function for schema validation."""
return f"Result for {query} (count: {count})"
schema = test_function.get_schema()
# Should have same structure as BaseTool
assert schema["type"] == "function"
func = schema["function"]
assert func["name"] == "test_function"
assert "parameters" in func
params = func["parameters"]
assert params["type"] == "object"
assert "properties" in params
assert "required" in params
class TestToolSchemaValidationErrors:
"""Test that validation catches schema errors."""
def test_invalid_tool_missing_properties(self):
"""Test validation fails for tools with invalid parameters."""
class BadTool(BaseTool):
name = "bad_tool"
description = "Bad tool"
parameters = {"type": "object"} # Missing properties
def run(self, query: str) -> str:
return query
tool = BadTool()
with pytest.raises(ToolValidationError, match="must have a 'properties' field"):
tool.validate()
def test_invalid_tool_missing_type(self):
"""Test validation fails for tools with missing type."""
class BadTool(BaseTool):
name = "bad_tool"
description = "Bad tool"
parameters = {"properties": {}} # Missing type
def run(self, query: str) -> str:
return query
tool = BadTool()
with pytest.raises(ToolValidationError, match="must have a 'type' field"):
tool.validate()
class TestToolListValidation:
"""Test validation of tool lists for consistency."""
def test_valid_tool_list(self):
"""Test that valid tool lists pass consistency checks."""
@tool
def tool_one(x: str) -> str:
"""First tool."""
return x
@tool
def tool_two(y: int) -> int:
"""Second tool."""
return y
tool_three = MockTool()
tools = [tool_one, tool_two, tool_three]
# Should pass validation
assert validate_tool_schema_consistency(tools) == True
def test_duplicate_tool_names_error(self):
"""Test that duplicate tool names are caught."""
@tool(name="duplicate_name")
def tool_one(x: str) -> str:
return x
@tool(name="duplicate_name")
def tool_two(y: int) -> int:
return y
tools = [tool_one, tool_two]
with pytest.raises(ToolValidationError, match="Duplicate tool name"):
validate_tool_schema_consistency(tools)
def test_empty_tool_list(self):
"""Test that empty tool lists are valid."""
assert validate_tool_schema_consistency([]) == True
class TestRealAgenticScenario:
"""Test real agentic scenarios to ensure end-to-end compatibility."""
def test_tool_schema_agent_integration(self):
"""Test that tools work with agent in real scenario."""
from praisonaiagents import Agent
@tool
def echo_tool(message: str) -> str:
"""Echo the input message."""
return f"Echo: {message}"
# Create agent with tool - should not raise validation errors
agent = Agent(
name="test_agent",
instructions="You are a helpful assistant",
tools=[echo_tool]
)
# Schema should be valid for LLM use
schema = echo_tool.get_schema()
assert schema["type"] == "function"
# Should be JSON serializable
json.dumps(schema)
# Agent should format tools properly
formatted_tools = agent._format_tools_for_completion(agent.tools)
assert len(formatted_tools) == 1
assert formatted_tools[0]["type"] == "function"
print("✅ Real agentic test passed: Tool schema integration works end-to-end")
if __name__ == "__main__":
pytest.main([__file__, "-v"])
@@ -0,0 +1,82 @@
"""Tests for Agent.__deepcopy__ and Agent.clone_for_channel() (issue #1746).
Validates:
- copy.deepcopy(agent) works without threading.RLock errors
- clone_for_channel() returns an independent instance
- Cloned agent has its own lock objects (not shared)
- Key configuration attributes are preserved in the clone
- Multiple clones are isolated from each other
"""
import copy
import threading
import pytest
class TestAgentDeepCopy:
"""Test Agent.__deepcopy__ and clone_for_channel()."""
def _make_agent(self, **kwargs):
from praisonaiagents.agent.agent import Agent
return Agent(name="TestAgent", instructions="test", **kwargs)
def test_deepcopy_does_not_raise(self):
"""copy.deepcopy(agent) must not raise TypeError for RLock."""
agent = self._make_agent()
cloned = copy.deepcopy(agent)
assert cloned is not agent
def test_clone_for_channel_returns_new_instance(self):
agent = self._make_agent()
cloned = agent.clone_for_channel()
assert cloned is not agent
def test_clone_has_independent_cache_lock(self):
"""__cache_lock must be a new RLock, not shared with original."""
agent = self._make_agent()
cloned = agent.clone_for_channel()
assert cloned._Agent__cache_lock is not agent._Agent__cache_lock
assert isinstance(cloned._Agent__cache_lock, type(threading.RLock()))
def test_clone_has_independent_cost_lock(self):
"""_cost_lock must be a new Lock, not shared with original."""
agent = self._make_agent()
cloned = agent.clone_for_channel()
assert cloned._cost_lock is not agent._cost_lock
def test_clone_preserves_name(self):
agent = self._make_agent(name="MyAgent")
assert agent.clone_for_channel().name == "MyAgent"
def test_clone_preserves_instructions(self):
agent = self._make_agent(instructions="Do the thing")
assert agent.clone_for_channel().instructions == "Do the thing"
def test_clone_preserves_role_goal_backstory(self):
agent = self._make_agent(
role="Analyst",
goal="Analyse",
backstory="Expert analyst",
)
cloned = agent.clone_for_channel()
assert cloned.role == "Analyst"
assert cloned.goal == "Analyse"
assert cloned.backstory == "Expert analyst"
def test_clone_preserves_llm(self):
agent = self._make_agent(llm="gpt-4o-mini")
assert agent.clone_for_channel().llm == "gpt-4o-mini"
def test_multiple_clones_are_isolated(self):
"""Regression: creating two clones (simulating 2nd+ gateway channel) must work."""
agent = self._make_agent()
clone1 = agent.clone_for_channel()
clone2 = agent.clone_for_channel()
assert clone1 is not clone2
assert clone1._Agent__cache_lock is not clone2._Agent__cache_lock
def test_deepcopy_multiple_times(self):
"""Successive deepcopies must all succeed (no cumulative state corruption)."""
agent = self._make_agent()
for _ in range(3):
cloned = copy.deepcopy(agent)
assert cloned is not agent
@@ -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
)
@@ -0,0 +1,45 @@
from types import SimpleNamespace
import sys
def test_start_verbose_composed_callbacks_forward_all_arguments(monkeypatch):
from praisonaiagents import Agent, AgentTeam
on_start_calls = []
on_complete_calls = []
debug_messages = []
def on_task_start(task, task_id):
on_start_calls.append((task, task_id))
def on_task_complete(task, task_output):
on_complete_calls.append((task, task_output))
team = AgentTeam(
agents=[
Agent(name="Researcher", instructions="Research"),
Agent(name="Writer", instructions="Write"),
],
hooks={
"on_task_start": on_task_start,
"on_task_complete": on_task_complete,
},
)
def fake_run_all_tasks():
task_id, task = next(iter(team.tasks.items()))
task_output = SimpleNamespace(raw="done")
team.on_task_start(task, task_id)
team.on_task_complete(task, task_output)
monkeypatch.setattr(team, "run_all_tasks", fake_run_all_tasks)
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
monkeypatch.setattr("praisonaiagents.agents.agents.logging.debug", lambda msg: debug_messages.append(str(msg)))
team.start(output="verbose")
assert len(on_start_calls) == 1
assert len(on_complete_calls) == 1
assert on_start_calls[0][1] == next(iter(team.tasks.keys()))
assert on_complete_calls[0][1].raw == "done"
assert all("Error in verbose task" not in message for message in debug_messages)
@@ -5,6 +5,8 @@ Tests Mem0Adapter lazy loading and normalization.
"""
import sys
import importlib
from types import SimpleNamespace
import pytest
from unittest.mock import MagicMock
@@ -174,3 +176,54 @@ class TestProtocolCompliance:
for method in required_methods:
assert hasattr(Mem0Adapter, method), f"Missing method: {method}"
class TestChromaKnowledgeAdapterWhereFilters:
"""Tests for ChromaKnowledgeAdapter where-filter formatting."""
def _make_adapter(self):
from praisonaiagents.knowledge.adapters.factories import ChromaKnowledgeAdapter
adapter = ChromaKnowledgeAdapter.__new__(ChromaKnowledgeAdapter)
adapter.collection = MagicMock()
adapter.collection.query.return_value = {
"ids": [[]],
"documents": [[]],
"metadatas": [[]],
"distances": [[]],
}
return adapter
def test_search_wraps_multiple_where_filters_with_and(self, monkeypatch):
embedding_module = importlib.import_module("praisonaiagents.embedding")
adapter = self._make_adapter()
monkeypatch.setattr(
embedding_module,
"embedding",
lambda *args, **kwargs: SimpleNamespace(embeddings=[[0.1, 0.2]]),
raising=False,
)
adapter.search("q", user_id="u1", agent_id="a1", run_id="r1")
where = adapter.collection.query.call_args.kwargs["where"]
assert where == {
"$and": [{"user_id": "u1"}, {"agent_id": "a1"}, {"run_id": "r1"}]
}
def test_search_keeps_single_where_filter_unwrapped(self, monkeypatch):
embedding_module = importlib.import_module("praisonaiagents.embedding")
adapter = self._make_adapter()
monkeypatch.setattr(
embedding_module,
"embedding",
lambda *args, **kwargs: SimpleNamespace(embeddings=[[0.1, 0.2]]),
raising=False,
)
adapter.search("q", user_id="u1")
where = adapter.collection.query.call_args.kwargs["where"]
assert where == {"user_id": "u1"}
@@ -223,3 +223,188 @@ class TestDefaultContextBuilder:
context = builder.build(results)
assert "\n\n" in context
class TestExtractValue:
"""Tests for the _extract_value helper function."""
def test_extract_value_from_dict(self):
"""Test extracting value from dictionary."""
from praisonaiagents.rag.context import _extract_value
item = {"text": "test content", "metadata": {"source": "test.pdf"}}
assert _extract_value(item, "text") == "test content"
assert _extract_value(item, "metadata")["source"] == "test.pdf"
assert _extract_value(item, "nonexistent", "default") == "default"
def test_extract_value_from_searchresultitem(self):
"""Test extracting value from SearchResultItem object."""
from praisonaiagents.rag.context import _extract_value
from praisonaiagents.knowledge.models import SearchResultItem
item = SearchResultItem(
text="test content",
source="test.pdf",
filename="test.pdf",
metadata={"extra": "data"}
)
assert _extract_value(item, "text") == "test content"
assert _extract_value(item, "source") == "test.pdf"
assert _extract_value(item, "filename") == "test.pdf"
assert _extract_value(item, "metadata")["extra"] == "data"
assert _extract_value(item, "nonexistent", "default") == "default"
class TestExtractMetadataValue:
"""Tests for the _extract_metadata_value helper function."""
def test_extract_metadata_value_from_metadata(self):
"""Test extracting value from metadata dict."""
from praisonaiagents.rag.context import _extract_metadata_value
from praisonaiagents.knowledge.models import SearchResultItem
item = SearchResultItem(text="content", source="fallback.pdf")
metadata = {"source": "metadata.pdf"}
# Should prefer metadata over top-level
result = _extract_metadata_value(item, metadata, "source", "")
assert result == "metadata.pdf"
def test_extract_metadata_value_fallback_to_toplevel(self):
"""Test fallback to top-level when metadata doesn't have key."""
from praisonaiagents.rag.context import _extract_metadata_value
from praisonaiagents.knowledge.models import SearchResultItem
item = SearchResultItem(text="content", source="toplevel.pdf")
metadata = {} # Empty metadata
# Should fall back to top-level source
result = _extract_metadata_value(item, metadata, "source", "")
assert result == "toplevel.pdf"
def test_extract_metadata_value_with_default(self):
"""Test fallback to default when neither metadata nor top-level have key."""
from praisonaiagents.rag.context import _extract_metadata_value
from praisonaiagents.knowledge.models import SearchResultItem
item = SearchResultItem(text="content") # No source
metadata = {} # No source in metadata
# Should use default
result = _extract_metadata_value(item, metadata, "source", "default.pdf")
assert result == "default.pdf"
class TestSearchResultItemSupport:
"""Tests for SearchResultItem object support in context functions."""
def test_deduplicate_chunks_with_searchresultitem(self):
"""Test deduplication with SearchResultItem objects."""
from praisonaiagents.rag.context import deduplicate_chunks
from praisonaiagents.knowledge.models import SearchResultItem
results = [
SearchResultItem(text="Same content", source="a.pdf"),
SearchResultItem(text="Same content", source="a.pdf"), # Duplicate
SearchResultItem(text="Different content", source="b.pdf"),
]
deduped = deduplicate_chunks(results)
assert len(deduped) == 2 # One duplicate should be removed
def test_deduplicate_chunks_mixed_formats(self):
"""Test deduplication with mixed dict and SearchResultItem formats."""
from praisonaiagents.rag.context import deduplicate_chunks
from praisonaiagents.knowledge.models import SearchResultItem
results = [
{"text": "Content A", "metadata": {"source": "a.pdf"}},
SearchResultItem(text="Content A", source="a.pdf"), # Should be deduped
SearchResultItem(text="Content B", source="b.pdf"),
]
deduped = deduplicate_chunks(results)
assert len(deduped) == 2 # One duplicate should be removed
def test_deduplicate_with_top_level_source(self):
"""Test deduplication considers top-level source from SearchResultItem."""
from praisonaiagents.rag.context import deduplicate_chunks
from praisonaiagents.knowledge.models import SearchResultItem
results = [
SearchResultItem(text="Same text", source="source1.pdf"),
SearchResultItem(text="Same text", source="source2.pdf"),
]
deduped = deduplicate_chunks(results)
assert len(deduped) == 2 # Different sources should not be deduped
def test_build_context_with_searchresultitem(self):
"""Test building context with SearchResultItem objects."""
from praisonaiagents.rag.context import build_context
from praisonaiagents.knowledge.models import SearchResultItem
results = [
SearchResultItem(
text="First content",
source="source1.pdf",
filename="file1.pdf"
),
SearchResultItem(
text="Second content",
source="source2.pdf",
filename="file2.pdf"
),
]
context, used = build_context(results)
assert "First content" in context
assert "Second content" in context
assert "file1.pdf" in context # Should use filename
assert "file2.pdf" in context
assert len(used) == 2
def test_build_context_source_fallback(self):
"""Test that build_context falls back to top-level source when metadata is empty."""
from praisonaiagents.rag.context import build_context
from praisonaiagents.knowledge.models import SearchResultItem
# SearchResultItem with source but empty metadata
results = [
SearchResultItem(
text="Content with source",
source="fallback.pdf",
metadata={} # Empty metadata, should fall back to top-level source
)
]
context, used = build_context(results, include_source=True)
assert "Content with source" in context
assert "fallback.pdf" in context # Should use top-level source
assert len(used) == 1
def test_build_context_mixed_formats_with_sources(self):
"""Test building context with mixed dict/SearchResultItem formats."""
from praisonaiagents.rag.context import build_context
from praisonaiagents.knowledge.models import SearchResultItem
results = [
{"text": "Dict content", "metadata": {"filename": "dict.pdf"}},
SearchResultItem(
text="Object content",
filename="object.pdf",
metadata={}
),
]
context, used = build_context(results, include_source=True)
assert "Dict content" in context
assert "Object content" in context
assert "dict.pdf" in context
assert "object.pdf" in context # Should use top-level filename
assert len(used) == 2
@@ -8,6 +8,7 @@ from praisonaiagents.rag.context import (
deduplicate_chunks,
build_context,
)
from praisonaiagents.knowledge.models import SearchResultItem
class TestDeduplicateChunksNormalization:
@@ -122,3 +123,25 @@ class TestEdgeCases:
context, used = build_context(results)
assert len(used) == 1
assert "valid content" in context
class TestSearchResultItemCompatibility:
"""Tests for SearchResultItem object compatibility."""
def test_deduplicate_uses_source_attribute_fallback(self):
"""Different source attributes should produce unique chunks."""
results = [
SearchResultItem(text="same content", source="a.pdf"),
SearchResultItem(text="same content", source="b.pdf"),
]
deduped = deduplicate_chunks(results)
assert len(deduped) == 2
def test_build_context_uses_filename_attribute_fallback(self):
"""Source label should use object filename/source attributes."""
results = [
SearchResultItem(text="chunk", filename="doc.md"),
]
context, used = build_context(results, include_source=True)
assert len(used) == 1
assert "doc.md" in context
@@ -103,6 +103,73 @@ class TestHierarchicalSessionStore:
"""Clean up test fixtures."""
shutil.rmtree(self.temp_dir, ignore_errors=True)
def test_add_message_preserves_concurrent_writes(self):
"""Concurrent add_message must not lose messages (same as DefaultSessionStore)."""
with tempfile.TemporaryDirectory() as tmpdir:
writer = HierarchicalSessionStore(session_dir=tmpdir)
reader = HierarchicalSessionStore(session_dir=tmpdir)
writer.add_user_message("session-1", "first")
reader._load_session("session-1")
writer.add_user_message("session-1", "second")
history = writer.get_chat_history("session-1")
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")
self.store.add_message(session_id, "user", "hello")
parent_id = self.store.create_session(title="Child", parent_id=session_id)
snapshot_id = self.store.create_snapshot(session_id, label="checkpoint")
assert self.store.update_session_metadata(session_id, model="gpt-4o-mini")
session = self.store.get_extended_session(session_id)
assert session.metadata.get("model") == "gpt-4o-mini"
assert session.title == "Parent"
assert parent_id in session.children_ids
assert any(s.id == snapshot_id for s in session.snapshots)
def test_create_session(self):
"""Test creating a session."""
session_id = self.store.create_session(title="Test Session")
@@ -295,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."""
@@ -282,6 +305,116 @@ class TestDefaultSessionStore:
session = temp_store.get_session("session-1")
assert session.agent_name == "TestBot"
assert session.user_id == "user-123"
def test_update_session_metadata_preserves_messages(self, temp_store):
"""Metadata updates must not drop messages added by another store instance."""
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")
assert reader.update_session_metadata(
"session-1", model="gpt-4o-mini", total_tokens=42
)
writer.invalidate_cache("session-1")
history = writer.get_chat_history("session-1")
assert len(history) == 2
assert history[1]["content"] == "second"
session = writer.get_session("session-1")
assert session.metadata.get("model") == "gpt-4o-mini"
assert session.metadata.get("total_tokens") == 42
def test_set_agent_info_preserves_messages(self, temp_store):
"""Agent info updates must not drop messages added by another store instance."""
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")
assert reader.set_agent_info("session-1", agent_name="TestBot", user_id="u-1")
writer.invalidate_cache("session-1")
history = writer.get_chat_history("session-1")
assert len(history) == 2
assert history[1]["content"] == "second"
session = writer.get_session("session-1")
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:
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"
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."""
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")
assert reader.clear_session("session-1")
writer.invalidate_cache("session-1")
history = writer.get_chat_history("session-1")
assert len(history) == 0
def test_set_gateway_info_preserves_messages(self, temp_store):
"""Gateway info updates must not drop messages added by another store instance."""
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")
assert reader.set_gateway_info("session-1", gateway_session_id="gw-123", agent_id="agent-456")
writer.invalidate_cache("session-1")
history = writer.get_chat_history("session-1")
assert len(history) == 2
assert history[1]["content"] == "second"
session = writer.get_session("session-1")
assert session.gateway_session_id == "gw-123"
assert session.agent_id == "agent-456"
def test_concurrent_writes(self, temp_store):
"""Test concurrent writes to same session."""
@@ -313,7 +446,7 @@ class TestDefaultSessionStore:
assert history == []
def test_invalidate_cache(self, temp_store):
"""Test cache invalidation."""
"""Test that reads always see latest disk state."""
temp_store.add_user_message("session-1", "Hello")
# Modify file directly
@@ -324,14 +457,28 @@ class TestDefaultSessionStore:
with open(filepath, "w") as f:
json.dump(data, f)
# Without invalidation, cache returns old data
# Reads always reload from disk (no stale cache)
history = temp_store.get_chat_history("session-1")
assert len(history) == 1 # Cached
# After invalidation, new data is loaded
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,311 @@
"""Tests for skill capability validation."""
import pytest
import os
from pathlib import Path
from unittest.mock import Mock, patch
from praisonaiagents.skills.models import SkillProperties, SkillRequirements, SkillState
from praisonaiagents.skills.capability_validator import (
CapabilityValidator,
EnforcementLevel,
ValidationResult
)
class TestSkillRequirements:
"""Test SkillRequirements parsing and normalization."""
def test_from_frontmatter_empty(self):
"""Test parsing empty frontmatter."""
requirements = SkillRequirements.from_frontmatter({})
assert requirements.is_empty()
assert not requirements # Test __bool__
def test_from_frontmatter_tools_list(self):
"""Test parsing tools from list."""
metadata = {"requires_tools": ["web_search", "file_write"]}
requirements = SkillRequirements.from_frontmatter(metadata)
assert requirements.tools == ["web_search", "file_write"]
assert requirements.servers == []
assert not requirements.is_empty()
def test_from_frontmatter_tools_string(self):
"""Test parsing tools from string."""
metadata = {"requires-tools": "web_search file_write"}
requirements = SkillRequirements.from_frontmatter(metadata)
assert requirements.tools == ["web_search", "file_write"]
def test_from_frontmatter_backward_compatibility(self):
"""Test backward compatibility with allowed-tools."""
metadata = {"allowed-tools": ["existing_tool"]}
requirements = SkillRequirements.from_frontmatter(metadata)
assert requirements.tools == ["existing_tool"]
def test_from_frontmatter_servers(self):
"""Test parsing server requirements."""
metadata = {"requires_servers": ["mcp:filesystem", "http:internal"]}
requirements = SkillRequirements.from_frontmatter(metadata)
assert requirements.servers == ["mcp:filesystem", "http:internal"]
def test_from_frontmatter_env_vars(self):
"""Test parsing environment variable requirements."""
metadata = {"requires_env": ["API_KEY", "SECRET_TOKEN"]}
requirements = SkillRequirements.from_frontmatter(metadata)
assert requirements.env_vars == ["API_KEY", "SECRET_TOKEN"]
def test_from_frontmatter_openclaw_hints(self):
"""Test parsing OpenClaw hints."""
metadata = {"openclaw": {"version": "1.0", "compatibility": "hermes"}}
requirements = SkillRequirements.from_frontmatter(metadata)
assert requirements.openclaw_hints == {"version": "1.0", "compatibility": "hermes"}
def test_normalize_list(self):
"""Test list normalization utility."""
assert SkillRequirements._normalize_list("a b c") == ["a", "b", "c"]
assert SkillRequirements._normalize_list("a,b,c") == ["a", "b", "c"]
assert SkillRequirements._normalize_list("a, b c") == ["a", "b", "c"]
assert SkillRequirements._normalize_list(["a", "b", "c"]) == ["a", "b", "c"]
assert SkillRequirements._normalize_list("") == []
assert SkillRequirements._normalize_list([]) == []
assert SkillRequirements._normalize_list(123) == []
class TestCapabilityValidator:
"""Test capability validation logic."""
def test_enforcement_levels(self):
"""Test different enforcement levels."""
assert EnforcementLevel.DISABLED.value == "disabled"
assert EnforcementLevel.WARN.value == "warn"
assert EnforcementLevel.STRICT.value == "strict"
def test_validator_initialization(self):
"""Test validator initialization."""
validator = CapabilityValidator()
assert validator.enforcement_level == EnforcementLevel.WARN
validator = CapabilityValidator(EnforcementLevel.STRICT)
assert validator.enforcement_level == EnforcementLevel.STRICT
def test_validate_skill_no_requirements(self):
"""Test validation of skill with no requirements."""
skill = SkillProperties(
name="test-skill",
description="Test skill",
requirements=None
)
validator = CapabilityValidator()
result = validator.validate_skill(skill)
assert result.skill_name == "test-skill"
assert result.state == SkillState.ACTIVE
assert result.is_fully_satisfied
assert not result.has_critical_missing
def test_validate_skill_empty_requirements(self):
"""Test validation of skill with empty requirements."""
requirements = SkillRequirements()
skill = SkillProperties(
name="test-skill",
description="Test skill",
requirements=requirements
)
validator = CapabilityValidator()
result = validator.validate_skill(skill)
assert result.state == SkillState.ACTIVE
assert result.is_fully_satisfied
@patch.dict(os.environ, {"TEST_VAR": "value"})
def test_validate_skill_satisfied_requirements(self):
"""Test validation with all requirements satisfied."""
requirements = SkillRequirements(
tools=["available_tool"],
servers=["available_server"],
env_vars=["TEST_VAR"]
)
skill = SkillProperties(
name="test-skill",
description="Test skill",
requirements=requirements
)
available_tools = {"available_tool"}
available_servers = {"available_server"}
validator = CapabilityValidator()
result = validator.validate_skill(
skill,
available_tools=available_tools,
available_servers=available_servers
)
assert result.state == SkillState.ACTIVE
assert result.satisfied_tools == ["available_tool"]
assert result.satisfied_servers == ["available_server"]
assert result.satisfied_env_vars == ["TEST_VAR"]
assert result.is_fully_satisfied
def test_validate_skill_missing_tools_warn_mode(self):
"""Test validation with missing tools in warn mode."""
requirements = SkillRequirements(tools=["missing_tool"])
skill = SkillProperties(
name="test-skill",
description="Test skill",
requirements=requirements
)
validator = CapabilityValidator(EnforcementLevel.WARN)
result = validator.validate_skill(
skill,
available_tools=set(),
available_servers=set()
)
assert result.state == SkillState.DEGRADED
assert result.missing_tools == ["missing_tool"]
assert not result.is_fully_satisfied
assert result.has_critical_missing
assert len(result.warnings) == 1
assert "missing_tool" in result.warnings[0]
def test_validate_skill_missing_tools_strict_mode(self):
"""Test validation with missing tools in strict mode."""
requirements = SkillRequirements(tools=["missing_tool"])
skill = SkillProperties(
name="test-skill",
description="Test skill",
requirements=requirements
)
validator = CapabilityValidator(EnforcementLevel.STRICT)
result = validator.validate_skill(
skill,
available_tools=set(),
available_servers=set()
)
assert result.state == SkillState.UNAVAILABLE
assert result.missing_tools == ["missing_tool"]
assert not result.is_fully_satisfied
assert result.has_critical_missing
assert len(result.errors) == 1
assert "missing_tool" in result.errors[0]
def test_validate_skill_missing_env_vars_warn(self):
"""Test validation with missing environment variables in warn mode."""
requirements = SkillRequirements(env_vars=["MISSING_VAR"])
skill = SkillProperties(
name="test-skill",
description="Test skill",
requirements=requirements
)
validator = CapabilityValidator(EnforcementLevel.WARN)
result = validator.validate_skill(skill)
assert result.state == SkillState.DEGRADED
assert result.missing_env_vars == ["MISSING_VAR"]
assert len(result.warnings) == 1
assert len(result.errors) == 0
def test_validate_skill_missing_env_vars_strict(self):
"""Test validation with missing environment variables in strict mode."""
requirements = SkillRequirements(env_vars=["MISSING_VAR"])
skill = SkillProperties(
name="test-skill",
description="Test skill",
requirements=requirements
)
validator = CapabilityValidator(EnforcementLevel.STRICT)
result = validator.validate_skill(skill)
assert result.state == SkillState.UNAVAILABLE
assert result.missing_env_vars == ["MISSING_VAR"]
assert len(result.warnings) == 0
assert len(result.errors) == 1
def test_validation_result_to_dict(self):
"""Test ValidationResult serialization."""
result = ValidationResult(
skill_name="test-skill",
state=SkillState.DEGRADED,
satisfied_tools=["tool1"],
missing_tools=["tool2"],
satisfied_servers=[],
missing_servers=[],
satisfied_env_vars=[],
missing_env_vars=["VAR1"],
warnings=["Missing tool2"],
errors=[]
)
data = result.to_dict()
assert data["skill_name"] == "test-skill"
assert data["state"] == "degraded"
assert data["satisfied_tools"] == ["tool1"]
assert data["missing_tools"] == ["tool2"]
assert data["warnings"] == ["Missing tool2"]
assert data["is_fully_satisfied"] == False
assert data["has_critical_missing"] == True
class TestIntegration:
"""Integration tests for the full capability validation system."""
def test_skill_parsing_with_requirements(self):
"""Test that skills parse requirements correctly."""
from praisonaiagents.skills.parser import read_properties
from tempfile import TemporaryDirectory
with TemporaryDirectory() as temp_dir:
skill_dir = Path(temp_dir) / "test-skill"
skill_dir.mkdir()
skill_md = skill_dir / "SKILL.md"
skill_md.write_text('''---
name: test-skill
description: A test skill
requires_tools:
- web_search
- file_write
requires_servers:
- mcp:filesystem
requires_env:
- API_KEY
openclaw:
version: "1.0"
---
# Test Skill
This is a test skill.
''')
properties = read_properties(skill_dir)
assert properties.requirements is not None
assert properties.requirements.tools == ["web_search", "file_write"]
assert properties.requirements.servers == ["mcp:filesystem"]
assert properties.requirements.env_vars == ["API_KEY"]
assert properties.requirements.openclaw_hints == {"version": "1.0"}
def test_skill_manager_enforcement(self):
"""Test that SkillManager respects enforcement levels."""
from praisonaiagents.skills.manager import SkillManager
# Test that manager initializes with default enforcement
manager = SkillManager()
assert manager._validator.enforcement_level in [
EnforcementLevel.WARN,
EnforcementLevel.DISABLED,
EnforcementLevel.STRICT,
EnforcementLevel.TELEMETRY
]
# Test explicit enforcement level
strict_manager = SkillManager(EnforcementLevel.STRICT)
assert strict_manager._validator.enforcement_level == EnforcementLevel.STRICT
@@ -241,3 +241,89 @@ Body.
loader.activate(skill)
assert skill.is_activated is True
def test_load_utf8_content(self):
"""Test loading UTF-8 encoded skill content.
Regression test for Windows encoding issues (Issue #1695).
Tests UTF-8 handling in SKILL.md body, scripts, and references.
"""
from praisonaiagents.skills.loader import SkillLoader
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "utf8-loader-test"
skill_dir.mkdir()
# Create SKILL.md with UTF-8 characters
skill_content = """---
name: utf8-loader-test
description: Loader test with UTF-8 — em dashes — and more
---
# UTF-8 Instructions
This skill handles UTF-8 content:
- Smart quotes: "hello" and 'world'
- Em dash: —
- Arrows: → ← ↑ ↓
- Accented: café, naïve, résumé
- Emoji: 🔥 📝 ✨
"""
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
f.write(skill_content)
# Create scripts directory with UTF-8 script
scripts_dir = skill_dir / "scripts"
scripts_dir.mkdir()
script_content = """#!/bin/bash
# Script with UTF-8: café processing → résumé generation
echo "Processing naïve input with émphasis"
"""
with open(scripts_dir / "process.sh", 'w', encoding='utf-8') as f:
f.write(script_content)
# Create references directory with UTF-8 content
refs_dir = skill_dir / "references"
refs_dir.mkdir()
ref_content = """Reference with UTF-8:
Café automation guide — step by step
Arrow symbols: → ← ↑ ↓
Special chars: "smart quotes" and émphasis
"""
with open(refs_dir / "readme.txt", 'w', encoding='utf-8') as f:
f.write(ref_content)
# Test loading and activation
loader = SkillLoader()
skill = loader.load_metadata(str(skill_dir))
assert skill is not None
assert "—" in skill.properties.description # UTF-8 in metadata
# Test activation (loads SKILL.md body)
success = loader.activate(skill)
assert success is True
assert "—" in skill.instructions # UTF-8 in instructions
assert "café" in skill.instructions
assert "🔥" in skill.instructions # Emoji
# Test script loading
scripts = loader.load_scripts(skill)
assert "process.sh" in scripts
script_text = scripts["process.sh"]
assert "café" in script_text
assert "résumé" in script_text
assert "naïve" in script_text
# Test references loading
refs = loader.load_references(skill)
assert "readme.txt" in refs
ref_text = refs["readme.txt"]
assert "Café" in ref_text
assert "—" in ref_text
assert "émphasis" in ref_text
@@ -286,3 +286,54 @@ Body.
assert False, "Should have raised ValidationError"
except ValidationError as e:
assert "description" in str(e).lower()
def test_read_properties_utf8_encoding(self):
"""Test reading UTF-8 encoded SKILL.md files.
Regression test for Windows encoding issues (Issue #1695).
Verifies that skills with non-ASCII characters load correctly.
"""
from praisonaiagents.skills.parser import read_properties
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "utf8-skill"
skill_dir.mkdir()
# Create SKILL.md with various UTF-8 characters
utf8_content = """---
name: utf8-test
description: UTF-8 encoding test — quality — verification
license: MIT
compatibility: Supports café files → résumé processing
metadata:
author: "José María"
notes: "Handles naïve input with émphasis"
---
# UTF-8 Test Skill
This skill demonstrates UTF-8 character handling:
- Em dash: —
- Smart quotes: "hello" and 'world'
- Arrow: →
- Accented chars: café, naïve, résumé
- Emoji: 🔥 📝 ✨
Works with internationalization!
"""
# Explicitly write as UTF-8 to simulate real-world scenario
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
f.write(utf8_content)
# Test that parsing works without UnicodeDecodeError
props = read_properties(skill_dir)
# Verify UTF-8 characters are preserved correctly
assert props.name == "utf8-test"
assert "—" in props.description # em dash
assert "café" in props.compatibility # accented character
assert "résumé" in props.compatibility # accented character
assert "José María" in props.metadata["author"] # accented characters
assert "naïve" in props.metadata["notes"] # accented character
assert "émphasis" in props.metadata["notes"] # accented character
@@ -295,3 +295,58 @@ description: A test skill for validation
assert len(errors) > 0
assert any("directory" in e.lower() for e in errors)
def test_validate_utf8_skill(self):
"""Test validation of UTF-8 encoded skill.
Regression test for Windows encoding issues (Issue #1695).
Ensures validator can handle UTF-8 characters without errors.
"""
from praisonaiagents.skills.validator import validate
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "utf8-validation-test"
skill_dir.mkdir()
# Create SKILL.md with UTF-8 content
utf8_content = """---
name: utf8-validation-test
description: Validation test with UTF-8 — em dashes — and accents
license: MIT
compatibility: Works with café files → résumé output
---
# UTF-8 Validation Test
Testing validation with various UTF-8 characters.
"""
# Write as UTF-8 explicitly
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
f.write(utf8_content)
# Validate should not produce errors for UTF-8 content
errors = validate(skill_dir)
assert errors == [], f"Validation should pass for UTF-8 content, got errors: {errors}"
def test_validate_non_utf8_skill_returns_error(self):
"""Test validation returns a structured error for non-UTF-8 SKILL.md."""
from praisonaiagents.skills.validator import validate
with tempfile.TemporaryDirectory() as tmpdir:
skill_dir = Path(tmpdir) / "latin1-validation-test"
skill_dir.mkdir()
latin1_content = """---
name: latin1-validation-test
description: Café skill
---
"""
with open(skill_dir / "SKILL.md", "wb") as f:
f.write(latin1_content.encode("cp1252"))
errors = validate(skill_dir)
assert len(errors) > 0
assert any("utf-8" in e.lower() for e in errors)
@@ -225,3 +225,19 @@ class TestToolCallIdPropagation:
)
assert event.tool_call["id"] == "call_abc123xyz"
class TestToolExecutionMixinStreamEmitterLookup:
def test_mixin_resolves_name_mangled_stream_emitter_without_agent_class_name(self):
from praisonaiagents.agent.tool_execution import ToolExecutionMixin
from praisonaiagents.streaming.events import StreamEventEmitter
class CustomAgent(ToolExecutionMixin):
def __init__(self):
self.__stream_emitter = StreamEventEmitter()
agent = CustomAgent()
emitter = agent._get_existing_stream_emitter()
assert emitter is not None
assert isinstance(emitter, StreamEventEmitter)
@@ -134,3 +134,37 @@ class TestTokenCollectorSinkIntegration:
)
assert sink.records[0]["task_id"] == "task-abc"
assert sink.records[0]["agent_name"] == "researcher"
class TestInMemoryUsageQuery:
"""Test query adapter backed by in-memory sink records."""
def test_get_summary_uses_total_tokens_when_present(self):
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink, InMemoryUsageQuery
sink = InMemoryTokenUsageSink()
sink.records.extend([
{
"task_id": "t1",
"agent_name": "a1",
"model": "m1",
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 50,
},
{
"task_id": "t2",
"agent_name": "a1",
"model": "m2",
"input_tokens": 2,
"output_tokens": 3,
},
])
summary = InMemoryUsageQuery(sink).get_summary()
assert summary["total_input_tokens"] == 12
assert summary["total_output_tokens"] == 8
assert summary["total_tokens"] == 55
assert summary["by_model"]["m1"]["total_tokens"] == 50
assert summary["by_model"]["m2"]["total_tokens"] == 5
@@ -0,0 +1,293 @@
"""
Unit tests for kanban protocols and hook events.
Tests the protocol contracts and hook events without any heavy
implementations like SQLite or LLM calls.
"""
import pytest
import json
from typing import runtime_checkable
from praisonaiagents.kanban.protocols import (
KanbanStoreProtocol,
KanbanTaskProtocol,
KanbanCommentingProtocol,
KanbanLinkingProtocol,
VALID_KANBAN_STATUSES,
)
from praisonaiagents.hooks.types import (
HookEvent,
KanbanHookInput,
)
class TestKanbanProtocol:
"""Test KanbanStoreProtocol runtime checking and constants."""
def test_kanban_store_protocol_is_runtime_checkable(self):
"""Test that KanbanStoreProtocol is @runtime_checkable."""
assert runtime_checkable(KanbanStoreProtocol)
def test_valid_kanban_statuses_includes_ui_columns(self):
"""Test that VALID_KANBAN_STATUSES includes all PraisonAIUI columns."""
expected_statuses = {
"triage",
"todo",
"ready",
"running",
"blocked",
"review",
"done",
"archived"
}
assert VALID_KANBAN_STATUSES == expected_statuses
def test_valid_kanban_statuses_is_frozen(self):
"""Test that VALID_KANBAN_STATUSES is immutable."""
assert isinstance(VALID_KANBAN_STATUSES, frozenset)
def test_mock_store_passes_isinstance_check(self):
"""Test that a mock store implementing the core protocol passes isinstance check."""
class MockKanbanStore:
"""Mock implementation for testing - only core methods required."""
def get_board(self, *, board="default", tenant=None, include_archived=False):
return {"board": board, "tasks": []}
def get_task(self, task_id: str):
return {"id": task_id, "title": "Test Task"}
def create_task(self, data: dict):
return {"id": "task_123", **data}
def update_task(self, task_id: str, data: dict):
return {"id": task_id, **data}
def move_task(self, task_id: str, status: str):
return {"id": task_id, "status": status}
def bulk_update(self, task_ids: list[str], status: str):
return {"updated": len(task_ids), "status": status}
def delete_task(self, task_id: str):
return True
def list_events(self, since: float = 0.0, board: str = "default"):
return []
def health(self):
return {"status": "healthy"}
mock_store = MockKanbanStore()
assert isinstance(mock_store, KanbanStoreProtocol)
def test_extension_protocol_checks(self):
"""Test that extension protocols work independently."""
class CommentingStore:
"""Store that implements commenting functionality."""
def add_comment(self, task_id: str, text: str, author: str | None = None):
return {"id": "comment_1", "text": text, "author": author}
class LinkingStore:
"""Store that implements linking functionality."""
def link_tasks(self, parent_id: str, child_id: str):
return True
def unlink_tasks(self, parent_id: str, child_id: str):
return True
class FullFeaturedStore:
"""Store that implements all protocols."""
# Core protocol
def get_board(self, *, board="default", tenant=None, include_archived=False):
return {"board": board, "tasks": []}
def get_task(self, task_id: str):
return {"id": task_id}
def create_task(self, data: dict):
return {"id": "task_123", **data}
def update_task(self, task_id: str, data: dict):
return {"id": task_id, **data}
def move_task(self, task_id: str, status: str):
return {"id": task_id, "status": status}
def bulk_update(self, task_ids: list[str], status: str):
return {"updated": len(task_ids)}
def delete_task(self, task_id: str):
return True
def list_events(self, since: float = 0.0, board: str = "default"):
return []
def health(self):
return {"status": "healthy"}
# Extension protocols
def add_comment(self, task_id: str, text: str, author: str | None = None):
return {"id": "comment_1", "text": text}
def link_tasks(self, parent_id: str, child_id: str):
return True
def unlink_tasks(self, parent_id: str, child_id: str):
return True
commenting_store = CommentingStore()
linking_store = LinkingStore()
full_store = FullFeaturedStore()
# Test individual extension protocols
assert isinstance(commenting_store, KanbanCommentingProtocol)
assert isinstance(linking_store, KanbanLinkingProtocol)
# Test that extension stores don't implement core protocol
assert not isinstance(commenting_store, KanbanStoreProtocol)
assert not isinstance(linking_store, KanbanStoreProtocol)
# Test full featured store implements all protocols
assert isinstance(full_store, KanbanStoreProtocol)
assert isinstance(full_store, KanbanCommentingProtocol)
assert isinstance(full_store, KanbanLinkingProtocol)
def test_kanban_task_protocol_shape(self):
"""Test KanbanTaskProtocol typed dict structure."""
# This tests that the TypedDict can be used for type hints
# The shape is validated at runtime by mypy/type checkers
task: KanbanTaskProtocol = {
"id": "task_123",
"title": "Test Task",
"body": "Task description",
"status": "todo",
"assignee": "[email protected]",
"priority": "high",
"tenant": "test_tenant",
"board": "default",
"created_at": 1640995200.0,
"updated_at": 1640995200.0,
}
# Basic validation that we can access the fields
assert task["id"] == "task_123"
assert task["title"] == "Test Task"
assert task["status"] == "todo"
class TestKanbanHookEvents:
"""Test kanban hook events and payload types."""
def test_kanban_hook_events_in_enum(self):
"""Test that all kanban hook events are registered in HookEvent."""
expected_events = [
HookEvent.KANBAN_TASK_CREATED,
HookEvent.KANBAN_TASK_CLAIMED,
HookEvent.KANBAN_TASK_MOVED,
HookEvent.KANBAN_TASK_DONE,
HookEvent.KANBAN_TASK_BLOCKED,
HookEvent.KANBAN_TASK_FAILED,
]
for event in expected_events:
assert event in HookEvent
def test_kanban_hook_events_serializable(self):
"""Test that kanban hook events can be serialized."""
events = [
HookEvent.KANBAN_TASK_CREATED,
HookEvent.KANBAN_TASK_CLAIMED,
HookEvent.KANBAN_TASK_MOVED,
HookEvent.KANBAN_TASK_DONE,
HookEvent.KANBAN_TASK_BLOCKED,
HookEvent.KANBAN_TASK_FAILED,
]
for event in events:
# Should be able to serialize to JSON and reconstruct enum value
serialized = json.dumps({"event": event.value})
restored = HookEvent(json.loads(serialized)["event"])
assert restored == event
def test_kanban_hook_input_creation(self):
"""Test KanbanHookInput dataclass creation and fields."""
hook_input = KanbanHookInput(
session_id="session_123",
cwd="/home/user",
event_name="kanban_task_created",
timestamp="2023-01-01T00:00:00Z",
task_id="task_456",
board="project_board",
status="todo",
assignee="[email protected]",
from_status=None,
to_status="running"
)
assert hook_input.session_id == "session_123"
assert hook_input.task_id == "task_456"
assert hook_input.board == "project_board"
assert hook_input.status == "todo"
assert hook_input.assignee == "[email protected]"
assert hook_input.from_status is None
assert hook_input.to_status == "running"
def test_kanban_hook_input_defaults(self):
"""Test KanbanHookInput with default values."""
hook_input = KanbanHookInput(
session_id="session_123",
cwd="/home/user",
event_name="kanban_task_created",
timestamp="2023-01-01T00:00:00Z"
)
# Check defaults
assert hook_input.task_id == ""
assert hook_input.board == "default"
assert hook_input.status == ""
assert hook_input.assignee is None
assert hook_input.from_status is None
assert hook_input.to_status is None
def test_kanban_hook_input_to_dict(self):
"""Test KanbanHookInput serialization to dict."""
hook_input = KanbanHookInput(
session_id="session_123",
cwd="/home/user",
event_name="kanban_task_moved",
timestamp="2023-01-01T00:00:00Z",
task_id="task_456",
board="project_board",
from_status="todo",
to_status="running"
)
result = hook_input.to_dict()
assert result["session_id"] == "session_123"
assert result["event_name"] == "kanban_task_moved"
assert result["task_id"] == "task_456"
assert result["board"] == "project_board"
assert result["from_status"] == "todo"
assert result["to_status"] == "running"
class TestProtocolIntegration:
"""Integration tests for protocol usage patterns."""
def test_protocol_import_structure(self):
"""Test that protocols can be imported from expected paths."""
# Test direct imports
from praisonaiagents.kanban.protocols import KanbanStoreProtocol
from praisonaiagents.hooks.types import KanbanHookInput
assert KanbanStoreProtocol is not None
assert KanbanHookInput is not None
def test_lazy_import_from_package_root(self):
"""Test that kanban protocols can be imported from package root."""
from praisonaiagents.kanban import (
KanbanStoreProtocol,
KanbanTaskProtocol,
KanbanCommentingProtocol,
KanbanLinkingProtocol,
VALID_KANBAN_STATUSES,
)
assert KanbanStoreProtocol is not None
assert KanbanTaskProtocol is not None
assert KanbanCommentingProtocol is not None
assert KanbanLinkingProtocol is not None
assert VALID_KANBAN_STATUSES is not None
@@ -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.43"
version = "1.6.52"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff. Show more