Compare commits

...
Author SHA1 Message Date
praisonai-triage-agent[bot]andMervin Praison 7e8ca38749 fix: update broken Full Flow docs link in praisonai package README
- Fix duplicate broken link in src/praisonai/README.md
- Change from /docs/concepts/flow (404) to /docs/concepts/agentflow (200)
- Ensures PyPI package readers get working documentation link
- Addresses issue identified by Qodo code review

Co-authored-by: Mervin Praison <[email protected]>
2026-06-01 02:13:45 +00:00
dymux 5c8d1149fb docs: fix broken Full Flow docs link in README
The link pointed to /docs/concepts/flow which returns 404. The actual
page is published at /docs/concepts/agentflow.
2026-06-01 09:11:18 +08: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
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
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
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
Edward Liang dbf8b0d6e1 feat: Add Valkey Integration
Signed-off-by: Edward Liang <[email protected]>
2026-05-20 10:23:20 -07: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
116 changed files with 9777 additions and 1997 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)
+16 -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__, arun, run
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,6 +905,7 @@ 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>
@@ -1054,6 +1068,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.45" \
"praisonai>=4.6.48" \
"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.45" \
"praisonai>=4.6.48" \
"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.45" \
"praisonai>=4.6.48" \
"praisonai[ui]" \
"praisonai[crewai]"
+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.
@@ -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
@@ -1952,6 +1952,129 @@ 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 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."""
@@ -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}")
@@ -42,6 +42,7 @@ class MemoryBackend(str, Enum):
FILE = "file"
SQLITE = "sqlite"
REDIS = "redis"
VALKEY = "valkey"
POSTGRES = "postgres"
MEM0 = "mem0"
MONGODB = "mongodb"
@@ -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,
@@ -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,
@@ -144,6 +144,35 @@ class HierarchicalSessionStore(DefaultSessionStore):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._extended_cache: Dict[str, ExtendedSessionData] = {}
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 add_message(
self,
@@ -157,25 +186,22 @@ 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."""
@@ -22,7 +22,7 @@ else:
_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
@@ -141,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):
@@ -290,6 +291,72 @@ class DefaultSessionStore:
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)
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."""
@@ -439,13 +506,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,
@@ -454,39 +521,41 @@ 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:
session.messages.clear()
self._cache[session_id] = session
return self._save_session(session)
return self._modify_session_locked(
session_id,
lambda session: session.messages.clear(),
error_label="clear session",
)
def update_session_metadata(self, session_id: str, **fields: Any) -> bool:
"""Merge run stats / metadata fields into a persisted session."""
session = self._load_session(session_id)
with self._lock:
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)
self._cache[session_id] = session
return self._save_session(session)
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."""
@@ -586,7 +655,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,
@@ -640,16 +709,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),
)
@@ -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)
@@ -346,13 +346,15 @@ class _LazyStorageModule:
_BACKENDS = {
"RedisBackend": "praisonai.storage",
"MongoDBBackend": "praisonai.storage",
"MongoDBBackend": "praisonai.storage",
"PostgreSQLBackend": "praisonai.storage",
"DynamoDBBackend": "praisonai.storage",
"ValkeyBackend": "praisonai.storage",
"ValkeySearchBackend": "praisonai.storage",
# Legacy names for compatibility
"RedisStorageAdapter": "praisonai.storage",
"MongoDBStorageAdapter": "praisonai.storage",
"PostgreSQLStorageAdapter": "praisonai.storage",
"PostgreSQLStorageAdapter": "praisonai.storage",
"DynamoDBStorageAdapter": "praisonai.storage",
}
@@ -390,7 +392,7 @@ def get_backend(
Factory function to get a storage backend.
Args:
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb")
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb", "valkey")
**kwargs: Backend-specific arguments
Returns:
@@ -431,9 +433,13 @@ def get_backend(
# Lazy load from wrapper
backend_class = getattr(_heavy_backends, "DynamoDBBackend")
return backend_class(**kwargs)
elif backend_type == "valkey":
# Lazy load from wrapper
backend_class = getattr(_heavy_backends, "ValkeyBackend")
return backend_class(**kwargs)
else:
raise ValueError(f"Unknown backend type: {backend_type}. "
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb")
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb, valkey")
def __getattr__(name: str):
"""
@@ -442,7 +448,8 @@ def __getattr__(name: str):
This allows `from praisonaiagents.storage.backends import RedisBackend` to work
while maintaining lazy loading.
"""
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend"}:
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend",
"ValkeyBackend", "ValkeySearchBackend"}:
return getattr(_heavy_backends, name)
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
@@ -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
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.45"
version = "1.6.48"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -349,6 +349,161 @@ class TestDbSessionAdapter:
assert meta["agent_id"] == "abc"
assert meta["total_input_tokens"] == 42
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
@@ -6,6 +6,7 @@ Databases tested (all via local Docker):
2. PostgreSQL — ConversationStore + ManagedAgent roundtrip
3. MySQL — ConversationStore + ManagedAgent roundtrip
4. Redis — StateStore + metadata persistence
4a. Valkey — StateStore + metadata persistence
5. MongoDB — StateStore + metadata persistence
6. ClickHouse — Raw connectivity + data write/read
7. JSON file — DefaultSessionStore + ManagedAgent roundtrip
@@ -27,7 +28,6 @@ from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -562,6 +562,206 @@ class TestRedisStateStore:
r.close()
# ===========================================================================
# 4a. Valkey — StateStore operations + metadata persistence
# ===========================================================================
class TestValkeyStateStore:
"""Valkey StateStore: real operations against a live Valkey instance."""
@pytest.fixture(autouse=True)
def check_valkey(self):
"""Skip if Valkey not available."""
try:
from praisonai.persistence._valkey_client import create_valkey_client
client = create_valkey_client(host="localhost", port=6379)
client.ping()
client.close()
except Exception:
pytest.skip("Valkey not available on localhost:6379")
def test_valkey_state_operations(self):
"""StateStore get/set/delete/exists/hash with real Valkey."""
from praisonai.persistence.state.valkey import ValkeyStateStore
prefix = f"test_{uuid.uuid4().hex[:6]}:"
store = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
try:
# Basic set/get
store.set("agent_id", "agent_valkey_001")
assert store.get("agent_id") == "agent_valkey_001"
# JSON roundtrip
state = {
"agent_id": "agent_valkey_001",
"agent_version": 3,
"total_input_tokens": 500,
"total_output_tokens": 200,
"compute_instance_id": "docker_valkey_test",
"session_history": [{"id": "s1", "status": "idle"}],
}
store.set_json("managed_state", state)
recovered = store.get_json("managed_state")
assert recovered["agent_id"] == "agent_valkey_001"
assert recovered["total_input_tokens"] == 500
assert recovered["compute_instance_id"] == "docker_valkey_test"
assert len(recovered["session_history"]) == 1
# Hash operations
store.hset("agent_meta", "version", "5")
store.hset("agent_meta", "env_id", "env_abc")
assert str(store.hget("agent_meta", "version")) == "5"
all_meta = store.hgetall("agent_meta")
assert str(all_meta["env_id"]) == "env_abc"
# Exists and delete
assert store.exists("agent_id")
store.delete("agent_id")
assert not store.exists("agent_id")
finally:
for k in store.keys():
store.delete(k)
store.close()
def test_valkey_managed_state_persist_restore(self):
"""Simulate managed agent state persist/restore across store instances."""
from praisonai.persistence.state.valkey import ValkeyStateStore
prefix = f"mgd_{uuid.uuid4().hex[:6]}:"
store = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
try:
session_id = f"valkey_session_{uuid.uuid4().hex[:8]}"
state = {
"agent_id": "agent_valkey_mgd",
"agent_version": 2,
"environment_id": "env_valkey_001",
"total_input_tokens": 1000,
"total_output_tokens": 500,
"compute_instance_id": "modal_valkey_xyz",
"session_history": [
{"id": session_id, "status": "idle", "title": "Valkey test"},
],
}
store.set_json(f"managed:{session_id}", state)
# Simulate restart — new store instance, same prefix
store2 = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
recovered = store2.get_json(f"managed:{session_id}")
assert recovered is not None
assert recovered["agent_id"] == "agent_valkey_mgd"
assert recovered["total_input_tokens"] == 1000
assert recovered["compute_instance_id"] == "modal_valkey_xyz"
assert len(recovered["session_history"]) == 1
store.close()
store2.close()
finally:
store3 = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
for k in store3.keys():
store3.delete(k)
store3.close()
# ===========================================================================
# 4b. Valkey Vector Search — ValkeyVectorKnowledgeStore against a live instance
# ===========================================================================
class TestValkeyVectorSearch:
"""Valkey vector search: real operations against a live ValkeySearch instance."""
@pytest.fixture(autouse=True)
def check_valkey_search(self):
"""Skip if Valkey is unreachable or ValkeySearch (FT.*) is not loaded."""
try:
from glide_sync import ft
except ImportError:
pytest.skip("valkey-glide-sync not installed")
try:
from praisonai.persistence._valkey_client import create_valkey_client
except ImportError as e:
pytest.skip(f"praisonai not importable: {e}")
client = None
try:
client = create_valkey_client(host="localhost", port=6379)
client.ping()
except Exception as e:
if client is not None:
client.close()
pytest.skip(f"Valkey not available on localhost:6379: {e}")
try:
# Probe FT.* support; raises if module not loaded
ft.list(client)
except Exception as e:
pytest.skip(f"ValkeySearch (FT.*) module not loaded on localhost:6379: {e}")
finally:
if client is not None:
client.close()
def test_valkey_vector_search_roundtrip(self):
"""Full create → insert → search → delete_collection roundtrip."""
from praisonai.persistence.knowledge.valkey_vector import ValkeyVectorKnowledgeStore
from praisonai.persistence.knowledge.base import KnowledgeDocument
prefix = f"praisontest_{uuid.uuid4().hex[:6]}_"
collection = f"vec_{uuid.uuid4().hex[:6]}"
dimension = 4
store = ValkeyVectorKnowledgeStore(host="localhost", port=6379, prefix=prefix)
try:
# Create index
store.create_collection(collection, dimension=dimension, distance="cosine")
assert store.collection_exists(collection)
# Insert documents with simple, distinguishable embeddings
docs = [
KnowledgeDocument(
id="doc_a",
content="alpha document",
embedding=[1.0, 0.0, 0.0, 0.0],
),
KnowledgeDocument(
id="doc_b",
content="bravo document",
embedding=[0.0, 1.0, 0.0, 0.0],
),
KnowledgeDocument(
id="doc_c",
content="charlie document",
embedding=[0.0, 0.0, 1.0, 0.0],
),
]
inserted = store.insert(collection, docs)
assert set(inserted) == {"doc_a", "doc_b", "doc_c"}
# Vector search: query closest to doc_a
results = store.search(collection, query_embedding=[1.0, 0.0, 0.0, 0.0], limit=2)
assert len(results) >= 1, "Expected at least one search result"
assert results[0].id == "doc_a", f"Expected doc_a as nearest neighbor, got {results[0].id}"
assert results[0].content == "alpha document"
# Verify the bare ID is returned (no prefix leakage)
for r in results:
assert ":" not in r.id or r.id in {"doc_a", "doc_b", "doc_c"}
# get() roundtrip
got = store.get(collection, ["doc_b"])
assert len(got) == 1
assert got[0].id == "doc_b"
assert got[0].content == "bravo document"
# Delete one doc
deleted = store.delete(collection, ids=["doc_c"])
assert deleted == 1
finally:
try:
store.delete_collection(collection)
finally:
store.close()
# ===========================================================================
# 5. MongoDB — StateStore operations + metadata persistence
# ===========================================================================
@@ -673,7 +873,6 @@ class TestClickHouseConnectivity:
def test_clickhouse_write_read(self):
"""Write and read data from ClickHouse."""
import clickhouse_connect
table = f"praison_test_{uuid.uuid4().hex[:8]}"
c = clickhouse_connect.get_client(
host="localhost", port=8123, username="clickhouse", password="clickhouse",
@@ -0,0 +1,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,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)
@@ -48,6 +48,12 @@ class TestResolveMemory:
result = resolve_memory("redis")
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.REDIS
def test_string_valkey_backend(self):
"""'valkey' string returns MemoryConfig with VALKEY backend."""
result = resolve_memory("valkey")
assert isinstance(result, MemoryConfig)
assert result.backend == MemoryBackend.VALKEY
def test_string_custom_backend(self):
"""Custom string backend is preserved."""
@@ -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,35 @@ 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_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")
@@ -282,6 +282,101 @@ 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_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"
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 +408,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,11 +419,11 @@ 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
assert len(history) == 2 # Fresh from disk
# After invalidation, new data is loaded
# invalidate_cache still works but is now essentially a no-op for reads
temp_store.invalidate_cache("session-1")
history = temp_store.get_chat_history("session-1")
assert len(history) == 2
@@ -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)
@@ -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
+1 -1
View File
@@ -3125,7 +3125,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.45"
version = "1.6.48"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+15 -7
View File
@@ -1,18 +1,19 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-21",
"lastUpdated": "2026-05-30",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"status": "PARITY_ACHIEVED",
"summary": {
"pythonCoreFeatures": 334,
"pythonCoreFeatures": 335,
"rustFeatures": 667,
"gapCount": 55,
"parityPercentage": 199.7
"gapCount": 56,
"parityPercentage": 199.1
},
"pythonCoreSDK": {
"exports": [
"A2A",
"A2UI",
"AGUI",
"AUTONOMY_PRESETS",
"Agent",
@@ -1104,6 +1105,7 @@
"WebSearchProvider"
],
"config_loader": [
"AutoRagConfig",
"SessionConfig"
],
"context": [
@@ -1739,9 +1741,6 @@
"sandbox": [],
"session": [],
"skills": [],
"specialized": [
"AutoRagConfig"
],
"specialized_agents": [],
"streaming": [],
"task": [],
@@ -2821,6 +2820,15 @@
"status": "DONE",
"category": "ui"
},
{
"feature": "A2UI",
"python": true,
"rust": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "ui"
},
{
"feature": "AGUI",
"python": true,
+5 -4
View File
@@ -1,16 +1,16 @@
# Rust Feature Parity Tracker
> **Python Features:** 334 | **Rust Features:** 667 | **Parity:** 85.3%
> **Python Features:** 335 | **Rust Features:** 667 | **Parity:** 85.1%
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 334 |
| Python Core Features | 335 |
| Rust Features | 667 |
| **Actual Gap Count** | **49** |
| **Actual Gap Count** | **50** |
| Language Limitations (N/A) | 5 |
| **Parity** | **85.3%** |
| **Parity** | **85.1%** |
## Implemented Features
@@ -694,6 +694,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
## Missing Features
- ❌ `A2UI`
- ❌ `AgentMessageEvent`
- ❌ `AsyncLearnProtocol`
- ❌ `AutoApproveBackend`
+15 -4
View File
@@ -1,22 +1,23 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-21",
"lastUpdated": "2026-05-30",
"generatedBy": "praisonai._dev.parity.generator",
"sourceOfTruth": "Python SDK (praisonaiagents)",
"summary": {
"pythonCoreFeatures": 334,
"pythonCoreFeatures": 335,
"pythonWrapperFeatures": 99,
"typescriptFeatures": 1192,
"gapCount": 42,
"gapCount": 43,
"priorityP0": 1,
"priorityP1": 0,
"priorityP2": 0,
"priorityP3": 41
"priorityP3": 42
},
"pythonCoreSDK": {
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
"exports": [
"A2A",
"A2UI",
"AGUI",
"AUTONOMY_PRESETS",
"Agent",
@@ -727,6 +728,7 @@
],
"ui": [
"A2A",
"A2UI",
"AGUI"
],
"workflows": [
@@ -3911,6 +3913,15 @@
"status": "DONE",
"category": "ui"
},
{
"feature": "A2UI",
"python": true,
"typescript": false,
"priority": "P3",
"effort": "low",
"status": "TODO",
"category": "ui"
},
{
"feature": "AGUI",
"python": true,
+8 -7
View File
@@ -1,20 +1,20 @@
# Feature Parity Tracker
> **Version:** 1.5.87 | **Last Updated:** 2026-05-21
> **Version:** 1.5.87 | **Last Updated:** 2026-05-30
> **Source of Truth:** Python SDK (praisonaiagents)
## Summary
| Metric | Count |
|--------|-------|
| Python Core Features | 334 |
| Python Core Features | 335 |
| Python Wrapper Features | 99 |
| TypeScript Features | 1192 |
| **Gap Count** | **42** |
| **Gap Count** | **43** |
| P0 (Critical) | 1 |
| P1 (High) | 0 |
| P2 (Medium) | 0 |
| P3 (Low) | 41 |
| P3 (Low) | 42 |
## Gap Matrix
@@ -147,10 +147,11 @@
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
### P3_Advanced (179 done, 41 todo)
### P3_Advanced (179 done, 42 todo)
| Feature | Python | TypeScript | Effort | Status |
|---------|--------|------------|--------|--------|
| `A2UI` | ✅ | ❌ | low | ⏳ TODO |
| `AgentMessageEvent` | ✅ | ❌ | high | ⏳ TODO |
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ TODO |
@@ -629,10 +630,10 @@ from praisonaiagents import ContextEvent, ContextEventType, ContextListSink, Con
</details>
<details>
<summary><strong>ui</strong> (2 exports)</summary>
<summary><strong>ui</strong> (3 exports)</summary>
```python
from praisonaiagents import A2A, AGUI
from praisonaiagents import A2A, A2UI, AGUI
```
</details>
+1 -1
View File
@@ -382,7 +382,7 @@ praisonai flow
Open **http://localhost:7861** — use the **Agent** and **Agent Team** components to create sequential or parallel workflows. Connect Chat Input → Agent Team → Chat Output for instant multi-agent pipelines.
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/flow) — visual agent building, component reference, and deployment
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/agentflow) — visual agent building, component reference, and deployment
### 8. PraisonAI UI 🤖 (Clean Chat)
+2 -2
View File
@@ -3,8 +3,8 @@ class Praisonai < Formula
desc "AI tools for various AI applications"
homepage "https://github.com/MervinPraison/PraisonAI"
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.45.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.45.tar.gz | shasum -a 256`.split.first
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.48.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.48.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+5
View File
@@ -32,6 +32,8 @@ __all__ = [
'HostedAgentConfig',
'LocalAgent',
'LocalAgentConfig',
# Integration functions
'run_integrated_gateway',
]
# Telemetry initialization state - thread-safe (threading imported above)
@@ -144,6 +146,9 @@ def __getattr__(name):
elif name == 'configure_host':
from .integration.host_app import configure_host
return configure_host
elif name == 'run_integrated_gateway':
from .integration.gateway_host import run_integrated_gateway
return run_integrated_gateway
elif name == 'AgentApp':
# Silent alias for AgentOS (backward compatibility)
from .app import AgentOS
+25 -3
View File
@@ -8,7 +8,8 @@ on every call (which is expensive and breaks multi-agent workflows).
import asyncio
import os
import threading
from concurrent.futures import Future
import concurrent.futures
from concurrent.futures import CancelledError as FutureCancelledError, Future
from typing import Awaitable, TypeVar
T = TypeVar("T")
@@ -112,9 +113,30 @@ def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) ->
with _BG._lock:
loop = _BG.get_unlocked()
fut: Future = asyncio.run_coroutine_threadsafe(coro, loop)
return fut.result(timeout=timeout)
try:
return fut.result(timeout=timeout)
except (TimeoutError, concurrent.futures.TimeoutError):
# Propagate cancellation into the background loop so the underlying
# awaitable (DB query, HTTP call, subprocess wait) actually unwinds.
fut.cancel()
try:
# Give cancellation a short grace period to release resources.
fut.exception(timeout=1.0)
except (
TimeoutError,
concurrent.futures.TimeoutError,
asyncio.CancelledError,
FutureCancelledError,
):
pass
raise
except BaseException:
# Ctrl-C / GeneratorExit / SystemExit must also cancel the bg task.
fut.cancel()
raise
def shutdown() -> None:
"""Public hook for long-running server processes to stop the bridge cleanly."""
_BG.shutdown()
_BG.shutdown()
File diff suppressed because it is too large. Load diff
+11 -413
View File
@@ -7,419 +7,17 @@ scheduler package for full functionality including async support.
import warnings
warnings.warn(
"praisonai.async_agent_scheduler is pending deprecation; it will be moved to "
"praisonai.scheduler.async_agent_scheduler in a future release. Continue "
"importing from praisonai.async_agent_scheduler until then.",
PendingDeprecationWarning,
"praisonai.async_agent_scheduler is deprecated; "
"use 'from praisonai.scheduler import AsyncAgentScheduler' instead.",
DeprecationWarning,
stacklevel=2,
)
# TODO: Once AsyncAgentScheduler is moved to scheduler package, import from there
# For now, re-export the existing implementation to avoid breaking changes
from .scheduler.shared import ScheduleParser, backoff_delay, safe_call
import asyncio
import logging
import threading
from datetime import datetime
from typing import Optional, Dict, Any, Callable, Union
from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
class AsyncAgentExecutorInterface(ABC):
"""Abstract interface for async agent execution."""
@abstractmethod
async def execute(self, task: str) -> Any:
"""Execute the agent with given task asynchronously."""
pass
class AsyncPraisonAgentExecutor(AsyncAgentExecutorInterface):
"""Async executor for PraisonAI agents."""
def __init__(self, agent):
"""
Initialize executor with a PraisonAI agent.
Args:
agent: PraisonAI Agent instance
"""
self.agent = agent
async def execute(self, task: str) -> Any:
"""
Execute the agent with the given task.
Args:
task: Task description to execute
Returns:
Agent execution result
"""
try:
# Check if agent has async support
if hasattr(self.agent, 'astart'):
result = await self.agent.astart(task)
elif hasattr(self.agent, 'start'):
# Wrap sync call in executor
result = await asyncio.to_thread(self.agent.start, task)
else:
raise AttributeError("Agent must have either 'start' or 'astart' method")
return result
except Exception as e:
logger.error(f"Async agent execution failed: {e}")
raise
class AsyncAgentScheduler:
"""
Async-native scheduler for running PraisonAI agents periodically.
Features:
- Proper async/await execution
- Cancellation support
- No global state pollution
- Native async coordination
Example:
scheduler = AsyncAgentScheduler(agent, task="Check news")
await scheduler.start(schedule_expr="hourly")
await asyncio.sleep(3600) # Let it run
await scheduler.stop()
"""
def __init__(
self,
agent,
task: str,
config: Optional[Dict[str, Any]] = None,
on_success: Optional[Callable] = None,
on_failure: Optional[Callable] = None
):
"""
Initialize async agent scheduler.
Args:
agent: PraisonAI Agent instance
task: Task description to execute
config: Optional configuration dict
on_success: Callback function on successful execution
on_failure: Callback function on failed execution
"""
self.agent = agent
self.task = task
self.config = config or {}
self.on_success = on_success
self.on_failure = on_failure
self.is_running = False
self._task: Optional[asyncio.Task] = None
self._executor = AsyncPraisonAgentExecutor(agent)
self._execution_count = 0
self._success_count = 0
self._failure_count = 0
# Sync lock for async primitives creation and bound loop tracking
self._primitives_lock = threading.Lock()
self._stop_event: Optional[asyncio.Event] = None
self._cancel_event: Optional[asyncio.Event] = None
self._stats_lock: Optional[asyncio.Lock] = None
self._bound_loop: Optional[asyncio.AbstractEventLoop] = None
def _ensure_async_primitives(self) -> None:
"""Create async primitives if they don't exist yet.
Thread-safe and loop-aware: primitives are bound to the current running loop.
If called from a different loop, new primitives are created.
"""
loop = asyncio.get_running_loop() # must be called from a coroutine
with self._primitives_lock:
if self._bound_loop is not loop:
self._stop_event = asyncio.Event()
self._cancel_event = asyncio.Event()
self._stats_lock = asyncio.Lock()
self._bound_loop = loop
async def start(
self,
schedule_expr: str,
max_retries: int = 3,
run_immediately: bool = False
) -> bool:
"""
Start scheduled agent execution.
Args:
schedule_expr: Schedule expression (e.g., "hourly", "*/6h", "3600")
max_retries: Maximum retry attempts on failure
run_immediately: If True, run agent immediately before starting schedule
Returns:
True if scheduler started successfully
"""
if self.is_running:
logger.warning("Async scheduler is already running")
return False
try:
interval = ScheduleParser.parse(schedule_expr)
self.is_running = True
self._ensure_async_primitives() # NEW — bind to the loop start() runs on
self._stop_event.clear()
logger.info(f"Starting async agent scheduler: {getattr(self.agent, 'name', 'Agent')}")
logger.info(f"Task: {self.task}")
logger.info(f"Schedule: {schedule_expr} ({interval}s interval)")
# Run immediately if requested
if run_immediately:
logger.info("Running agent immediately before starting schedule...")
await self._execute_with_retry(max_retries)
# Start background task
self._task = asyncio.create_task(
self._run_schedule(interval, max_retries)
)
logger.info("Async agent scheduler started successfully")
return True
except Exception as e:
logger.error(f"Failed to start async scheduler: {e}")
self.is_running = False
return False
async def stop(self) -> bool:
"""
Stop the scheduler gracefully.
IMPORTANT: This method must be called from the same event loop
that was used to start the scheduler.
Returns:
True if stopped successfully
Raises:
RuntimeError: If called from a different event loop than start()
"""
if not self.is_running:
logger.info("Async scheduler is not running")
return True
logger.info("Stopping async agent scheduler...")
# _stop_event is guaranteed non-None after a successful start()
if self._stop_event is None:
logger.warning("stop() called before start(); nothing to stop.")
self.is_running = False
return True
# Ensure we're on the same loop that was bound during start()
current_loop = asyncio.get_running_loop()
if self._bound_loop is not None and current_loop is not self._bound_loop:
raise RuntimeError(
"stop() must be called from the same event loop as start(). "
f"Expected: {self._bound_loop}, got: {current_loop}"
)
self._stop_event.set()
try:
if self._task:
try:
await asyncio.wait_for(self._task, timeout=10)
except asyncio.TimeoutError:
logger.warning("Scheduler task didn't stop gracefully, cancelling")
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Scheduler task raised on cancel: {e}")
except asyncio.CancelledError:
# Task already cancelled; treat as expected during shutdown
pass
except Exception as e:
logger.error(f"Scheduler task raised during stop: {e}")
finally:
self.is_running = False
logger.info("Async agent scheduler stopped")
# Log final stats with consistent snapshot
if self._stats_lock is not None:
async with self._stats_lock:
total = self._execution_count
ok = self._success_count
fail = self._failure_count
else:
total = self._execution_count
ok = self._success_count
fail = self._failure_count
logger.info(f"Execution stats - Total: {total}, Success: {ok}, Failed: {fail}")
return True
async def get_stats(self) -> Dict[str, Any]:
"""
Get current execution statistics (synchronous, best-effort).
Warning: This method provides a best-effort view of stats without
guaranteeing atomicity. For consistent snapshots in async context,
use get_stats_async() instead.
Returns:
Dictionary with execution stats
"""
# Primitives are created lazily in _ensure_async_primitives(); if not yet
# initialized there are no concurrent writers, so a direct read is safe.
if self._stats_lock is not None:
async with self._stats_lock:
total = self._execution_count
ok = self._success_count
fail = self._failure_count
running = self.is_running
else:
total = self._execution_count
ok = self._success_count
fail = self._failure_count
running = self.is_running
return {
"is_running": running,
"total_executions": total,
"successful_executions": ok,
"failed_executions": fail,
"success_rate": (ok / total * 100) if total > 0 else 0
}
async def get_stats_async(self) -> Dict[str, Any]:
"""
Get current execution statistics with atomic snapshot (async).
Returns:
Dictionary with execution stats
"""
if self._stats_lock is None:
# Not yet started: stats are all zero, no lock needed
execs, success, failed = 0, 0, 0
else:
# Take atomic snapshot of all counters
async with self._stats_lock:
execs = self._execution_count
success = self._success_count
failed = self._failure_count
return {
"is_running": self.is_running,
"total_executions": execs,
"successful_executions": success,
"failed_executions": failed,
"success_rate": (success / execs * 100) if execs > 0 else 0
}
def get_stats_sync(self) -> Dict[str, Any]:
"""
Alias for get_stats() for clarity.
Returns:
Dictionary with execution stats
"""
return self.get_stats()
async def _run_schedule(self, interval: int, max_retries: int):
"""Internal method to run scheduled agent executions."""
try:
self._ensure_async_primitives()
while not self._stop_event.is_set():
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Starting async scheduled agent execution")
await self._execute_with_retry(max_retries)
# Wait for next scheduled time or stop event
logger.info(f"Next execution in {interval} seconds ({interval/3600:.1f} hours)")
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=interval)
break # Stop event was set
except asyncio.TimeoutError:
continue # Timeout reached, continue with next execution
finally:
self.is_running = False
async def _execute_with_retry(self, max_retries: int):
"""Execute agent with retry logic."""
self._ensure_async_primitives() # guarantees _stats_lock is bound to current loop
async with self._stats_lock:
self._execution_count += 1
last_exc: Optional[Exception] = None
for attempt in range(max_retries):
try:
logger.info(f"Async attempt {attempt + 1}/{max_retries}")
result = await self._executor.execute(self.task)
logger.info(f"Async agent execution successful on attempt {attempt + 1}")
logger.info(f"Result: {result}")
async with self._stats_lock:
self._success_count += 1
safe_call(self.on_success, result)
return
except Exception as e:
last_exc = e
logger.error(f"Async agent execution failed on attempt {attempt + 1}: {e}")
if attempt < max_retries - 1:
wait_time = backoff_delay(attempt)
logger.info(f"Waiting {wait_time}s before async retry...")
await asyncio.sleep(wait_time)
async with self._stats_lock:
self._failure_count += 1
logger.error(f"Async agent execution failed after {max_retries} attempts")
safe_call(
self.on_failure,
last_exc if last_exc is not None
else RuntimeError(f"Failed after {max_retries} attempts")
)
async def execute_once(self) -> Any:
"""
Execute agent immediately (one-time execution).
Returns:
Agent execution result
"""
logger.info("Executing agent once (async)")
try:
result = await self._executor.execute(self.task)
logger.info(f"One-time async execution successful: {result}")
return result
except Exception as e:
logger.error(f"One-time async execution failed: {e}")
raise
def create_async_agent_scheduler(
agent,
task: str,
config: Optional[Dict[str, Any]] = None
) -> AsyncAgentScheduler:
"""
Factory function to create async agent scheduler.
Args:
agent: PraisonAI Agent instance
task: Task description
config: Optional configuration
Returns:
Configured AsyncAgentScheduler instance
"""
return AsyncAgentScheduler(agent, task, config)
# Re-export from the canonical location
from .scheduler.async_agent_scheduler import ( # noqa: F401
AsyncAgentScheduler,
AsyncPraisonAgentExecutor,
AsyncAgentExecutorInterface,
create_async_agent_scheduler,
logger,
)
+176 -8
View File
@@ -14,6 +14,7 @@ from pydantic import BaseModel
from typing import Any, Dict, List, Optional, Type, TypeVar
import os
import json
import asyncio
import yaml
import threading
from rich import print
@@ -423,12 +424,13 @@ class BaseAutoGenerator:
}
]
self._openai_client = None # lazy, per-instance
self._openai_client_lock = threading.Lock()
self._async_openai_client = None # lazy, per-instance async client
self._client_lock = threading.Lock()
def _get_openai_client(self):
"""Get or create the OpenAI client for this instance."""
if self._openai_client is None:
with self._openai_client_lock:
with self._client_lock:
if self._openai_client is None:
try:
from openai import OpenAI
@@ -442,18 +444,43 @@ class BaseAutoGenerator:
return self._openai_client
def close(self):
"""Close the OpenAI client if it exists."""
if not hasattr(self, '_openai_client_lock'):
"""Close the sync OpenAI client if it exists."""
if not hasattr(self, '_client_lock'):
return # Object was never fully initialized
with self._openai_client_lock:
with self._client_lock:
client = getattr(self, '_openai_client', None)
self._openai_client = None
if client is not None:
client.close()
def __del__(self):
"""Best-effort cleanup, but the canonical path is explicit close()."""
async def aclose(self):
"""Close both sync and async OpenAI clients if they exist."""
if not hasattr(self, '_client_lock'):
return # Object was never fully initialized
with self._client_lock:
sync_client = getattr(self, '_openai_client', None)
self._openai_client = None
async_client = getattr(self, '_async_openai_client', None)
self._async_openai_client = None
if sync_client is not None:
await asyncio.to_thread(sync_client.close)
if async_client is not None:
await async_client.close()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
self.close()
return False
async def __aenter__(self):
return self
async def __aexit__(self, exc_type, exc, tb):
await self.aclose()
return False
def _structured_completion(self, response_model: Type[T], messages: List[Dict], **kwargs) -> T:
"""
@@ -505,6 +532,68 @@ class BaseAutoGenerator:
"Install with: pip install litellm OR pip install openai"
)
async def _astructured_completion(self, response_model: Type[T], messages: List[Dict], **kwargs) -> T:
"""
Make an async structured LLM completion with provider fallback.
Priority:
1. LiteLLM async (if available) - supports 100+ LLM providers
2. OpenAI AsyncSDK (fallback) - uses beta.chat.completions.parse
Args:
response_model: Pydantic model class for structured output
messages: List of message dicts for the LLM
**kwargs: Additional arguments passed to the LLM
Returns:
Instance of response_model with parsed response
Raises:
ImportError: If neither litellm nor openai is installed
"""
model_name = self.config_list[0]['model']
# Try LiteLLM async first (preferred - supports 100+ providers)
if _check_litellm_available():
litellm = _get_litellm()
response = await litellm.acompletion(
model=model_name,
messages=messages,
response_format=response_model,
**kwargs
)
content = response.choices[0].message.content
return response_model.model_validate_json(content)
# Fallback to OpenAI AsyncSDK (uses beta.chat.completions.parse)
if _check_openai_available():
if self._async_openai_client is None:
with self._client_lock:
if self._async_openai_client is None:
try:
from openai import AsyncOpenAI
except ImportError as e:
raise ImportError("Install with: pip install openai") from e
cfg = self.config_list[0]
self._async_openai_client = AsyncOpenAI(
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
base_url=cfg.get("base_url"),
)
response = await self._async_openai_client.beta.chat.completions.parse(
model=model_name,
messages=messages,
response_format=response_model,
**kwargs
)
return response.choices[0].message.parsed
# Neither available - raise helpful error
raise ImportError(
"Structured output requires either litellm or openai. "
"Install with: pip install litellm OR pip install openai"
)
@staticmethod
def get_available_tools() -> List[str]:
"""Return list of available tools for agent assignment."""
@@ -708,6 +797,36 @@ class AutoGenerator(BaseAutoGenerator):
self.convert_and_save(json_data, merge=merge)
full_path = os.path.abspath(self.agent_file)
return full_path
async def agenerate(self, merge=False):
"""
Async version of generate() - generates a team structure for the specified topic.
Args:
merge (bool): Whether to merge with existing agents.yaml file instead of overwriting.
Returns:
str: The full path of the YAML file containing the generated team structure.
Raises:
Exception: If the generation process fails.
Usage:
async with AutoGenerator(framework="crewai", topic="Create a movie script about Cat in Mars") as gen:
path = await gen.agenerate()
print(path)
"""
response = await self._astructured_completion(
response_model=TeamStructure,
messages=[
{"role": "system", "content": "You are a helpful assistant designed to output complex team structures."},
{"role": "user", "content": self.get_user_content()}
]
)
json_data = json.loads(response.model_dump_json())
self.convert_and_save(json_data, merge=merge)
full_path = os.path.abspath(self.agent_file)
return full_path
def convert_and_save(self, json_data, merge=False):
"""Converts the provided JSON data into the desired YAML format and saves it to a file.
@@ -1121,6 +1240,32 @@ Respond with:
return self._save_workflow(self.merge_with_existing_workflow(json_data), pattern)
return self._save_workflow(json_data, pattern)
async def agenerate(self, pattern: str = "sequential", merge: bool = False) -> str:
"""
Async version of generate() - Generate a workflow YAML file.
Args:
pattern: Workflow pattern - "sequential", "routing", "parallel", "loop",
"orchestrator-workers", "evaluator-optimizer"
merge: If True, merge with existing workflow file instead of overwriting
Returns:
Path to the generated workflow file
"""
response = await self._astructured_completion(
response_model=WorkflowStructure,
messages=[
{"role": "system", "content": "You are a helpful assistant that designs workflow structures."},
{"role": "user", "content": self._get_prompt(pattern)}
]
)
json_data = json.loads(response.model_dump_json())
if merge and os.path.exists(self.workflow_file):
return self._save_workflow(self.merge_with_existing_workflow(json_data), pattern)
return self._save_workflow(json_data, pattern)
def merge_with_existing_workflow(self, new_data: Dict) -> Dict:
"""
Merge new workflow data with existing workflow file.
@@ -1421,6 +1566,29 @@ class JobWorkflowAutoGenerator(BaseAutoGenerator):
return self._save_workflow(response)
async def agenerate(self, include_judge: bool = True, include_approve: bool = False) -> str:
"""
Async version of generate() - Generate a job workflow YAML file.
Args:
include_judge: Include a judge step for quality gating
include_approve: Include an approve step for human approval
Returns:
Path to the generated workflow file
"""
prompt = self._get_prompt(include_judge, include_approve)
response = await self._astructured_completion(
response_model=JobWorkflowStructure,
messages=[
{"role": "system", "content": "You are a helpful assistant that designs job workflow structures with AI agent steps."},
{"role": "user", "content": prompt}
]
)
return self._save_workflow(response)
def _get_prompt(self, include_judge: bool, include_approve: bool) -> str:
"""Generate the prompt for job workflow generation."""
tools_list = ", ".join(self.get_available_tools())
@@ -0,0 +1,116 @@
"""
Telegram typing indicator renewal utility.
Provides a background task to periodically renew typing indicators
during long-running operations like agent.chat() calls.
"""
import asyncio
import logging
from typing import Any, Callable, Coroutine, Optional, TypeVar
logger = logging.getLogger(__name__)
T = TypeVar("T")
class TypingIndicator:
"""Manages typing indicator renewal during long operations."""
def __init__(self, interval: float = 4.0):
"""
Initialize typing indicator manager.
Args:
interval: Seconds between typing renewals (default 4.0 for Telegram)
"""
self.interval = interval
self._task: Optional[asyncio.Task] = None
self._cancelled = False
async def start(self, typing_func: Callable[[], Any]) -> None:
"""
Start typing indicator renewal task.
Args:
typing_func: Async function that sends typing action
"""
if self._task and not self._task.done():
# Already running
return
self._cancelled = False
self._task = asyncio.create_task(self._typing_loop(typing_func))
def cancel(self) -> None:
"""Cancel the typing indicator renewal."""
self._cancelled = True
if self._task and not self._task.done():
self._task.cancel()
async def _typing_loop(self, typing_func: Callable[[], Any]) -> None:
"""
Background loop that periodically sends typing action.
Args:
typing_func: Function to call for sending typing indicator
"""
try:
while not self._cancelled:
try:
# Send typing indicator
if asyncio.iscoroutinefunction(typing_func):
await typing_func()
else:
typing_func()
# Wait for interval or until cancelled
await asyncio.sleep(self.interval)
except asyncio.CancelledError:
break
except Exception as e:
logger.debug(f"Typing indicator send failed: {e}")
# Continue trying even if one fails
await asyncio.sleep(self.interval)
except asyncio.CancelledError:
pass
finally:
logger.debug("Typing indicator loop ended")
async def with_typing_renewal(
typing_func: Callable[[], Any],
operation_coro: Coroutine[Any, Any, T],
interval: float = 4.0,
) -> T:
"""
Execute an operation with typing indicator renewal.
Args:
typing_func: Function to call for typing indication
operation_coro: Coroutine to execute while showing typing
interval: Seconds between typing renewals
Returns:
Result of the operation
"""
indicator = TypingIndicator(interval=interval)
try:
# Start typing renewal
await indicator.start(typing_func)
# Execute the operation
result = await operation_coro
return result
finally:
# Always cancel typing renewal and await cleanup
indicator.cancel()
if indicator._task is not None:
try:
await indicator._task
except (asyncio.CancelledError, Exception):
pass
+26 -10
View File
@@ -33,6 +33,7 @@ from ._debounce import InboundDebouncer
from ._ack import AckReactor
from ._unknown_user import UnknownUserHandler, BotContext
from ._pairing_ui import PairingUIBuilder, PairingCallbackHandler
from ..gateway.unicode_utils import safe_error_message, safe_log_message, extract_root_cause_from_error
from ..gateway.pairing import PairingStore
logger = logging.getLogger(__name__)
@@ -222,9 +223,6 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
logger.error(f"Message handler error: {e}")
if self._agent and not message.is_command:
if self.config.typing_indicator:
await update.message.chat.send_action("typing")
# Ack reaction
ack_ctx = None
if self._ack.enabled:
@@ -255,11 +253,28 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
) if update.message.from_user else ""
try:
message_text = await self._debouncer.debounce(user_id, message_text)
response = await self._session.chat(
self._agent, user_id, message_text,
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
user_name=user_name,
)
# Show typing indicator with renewal during long operation
if self.config.typing_indicator:
from ._typing_indicator import with_typing_renewal
async def _typing_action():
await update.message.chat.send_action("typing")
response = await with_typing_renewal(
typing_func=_typing_action,
operation_coro=self._session.chat(
self._agent, user_id, message_text,
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
user_name=user_name,
)
)
else:
response = await self._session.chat(
self._agent, user_id, message_text,
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
user_name=user_name,
)
send_result = self.fire_message_sending(
str(update.message.chat_id), str(response),
reply_to=str(update.message.message_id),
@@ -278,8 +293,9 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
if ack_ctx:
await self._ack.done(ack_ctx, react_fn=_tg_react, unreact_fn=_tg_unreact)
except Exception as e:
logger.error(f"Agent error: {e}")
await update.message.reply_text(f"Error: {str(e)}")
logger.error(f"Agent error: {safe_log_message(e)}")
user_error = extract_root_cause_from_error(str(e))
await update.message.reply_text(f"Error: {safe_error_message(user_error)}")
async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
"""Handle voice messages."""
@@ -77,6 +77,7 @@ configure_host(
"debug",
],
agents=_build_praison_agents(),
modules=["jobs"],
)
@@ -237,6 +237,23 @@ def doctor_cleanup(
raise typer.Exit(0 if removed == len(stale_dirs) else 1)
@app.command("skills")
def doctor_skills(
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
show_requirements: bool = typer.Option(False, "--requirements", help="Show detailed requirements"),
):
"""Check skills capabilities and requirements."""
args = ["skills"]
if deep:
args.append("--deep")
if json_output:
args.append("--json")
if show_requirements:
args.append("--requirements")
raise typer.Exit(_run_doctor(args))
@app.command("troubleshoot")
def doctor_troubleshoot():
"""
@@ -17,7 +17,7 @@ app = typer.Typer(
@app.command("start")
def gateway_start(
host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to"),
port: int = typer.Option(8765, "--port", help="Port to listen on"),
port: Optional[int] = typer.Option(None, "--port", help="Port to listen on"),
agents: Optional[str] = typer.Option(None, "--agents", help="Path to agent configuration file"),
config: Optional[str] = typer.Option(None, "--config", help="Path to gateway.yaml for multi-bot mode"),
):
@@ -27,17 +27,54 @@ def gateway_start(
praisonai gateway start
praisonai gateway start --config gateway.yaml
praisonai gateway start --agents agents.yaml --port 9000
GATEWAY_PORT=9000 praisonai gateway start
"""
import os
from ..features.gateway import GatewayHandler
# Check for GATEWAY_PORT environment variable if port not specified
if port is None:
try:
port = int(os.environ.get("GATEWAY_PORT", "8765"))
except ValueError:
port = 8765
handler = GatewayHandler()
handler.start(host=host, port=port, agent_file=agents, config_file=config)
@app.command("stop")
def gateway_stop(
host: str = typer.Option("127.0.0.1", "--host", help="Gateway host"),
port: Optional[int] = typer.Option(None, "--port", help="Gateway port"),
force: bool = typer.Option(False, "--force", help="Force stop (kill process)"),
):
"""Stop a running gateway instance.
Examples:
praisonai gateway stop
praisonai gateway stop --port 9000
praisonai gateway stop --force
"""
import os
from ..features.gateway import GatewayHandler
from ..output.console import get_output_controller
# Check for GATEWAY_PORT environment variable if port not specified
if port is None:
try:
port = int(os.environ.get("GATEWAY_PORT", "8765"))
except ValueError:
port = 8765
handler = GatewayHandler()
handler.stop(host=host, port=port, force=force)
@app.command("status")
def gateway_status(
host: str = typer.Option("127.0.0.1", "--host", help="Gateway host"),
port: int = typer.Option(8765, "--port", help="Gateway port"),
port: Optional[int] = typer.Option(None, "--port", help="Gateway port"),
daemon_only: bool = typer.Option(False, "--daemon-only", help="Show only daemon status"),
):
"""Check gateway status and daemon service status.
@@ -47,10 +84,18 @@ def gateway_status(
praisonai gateway status --port 9000
praisonai gateway status --daemon-only
"""
import os
from ..features.gateway import GatewayHandler
from praisonai.daemon import get_daemon_status
from ..output.console import get_output_controller
# Check for GATEWAY_PORT environment variable if port not specified
if port is None:
try:
port = int(os.environ.get("GATEWAY_PORT", "8765"))
except ValueError:
port = 8765
output = get_output_controller()
# Show daemon status
@@ -394,6 +439,7 @@ Manage the gateway server: praisonai gateway <command>
[bold]Commands:[/bold]
[green]start[/green] Start the gateway server
[green]stop[/green] Stop a running gateway instance
[green]status[/green] Check gateway and daemon status
[green]channels[/green] List channels from gateway.yaml
[green]send[/green] Send a test message to a channel
@@ -113,6 +113,7 @@ Start any PraisonAI server with: praisonai serve <type>
[bold]Server Types:[/bold]
[green]agents[/green] HTTP REST API for agents (port 8000)
[green]gateway[/green] WebSocket multi-agent coordination (port 8765)
[green]ui-gateway[/green] Integrated UI-Gateway (Pattern C) (port 8765)
[green]mcp[/green] MCP server for Claude/Cursor (port 8080)
[green]acp[/green] Agent Client Protocol for IDEs (STDIO)
[green]lsp[/green] Language Server Protocol (STDIO)
@@ -369,6 +370,42 @@ def serve_ui(
raise typer.Exit(4)
@app.command("ui-gateway")
def serve_ui_gateway(
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
port: int = typer.Option(8765, "--port", "-p", help="Port to bind to"),
title: str = typer.Option("PraisonAI", "--title", "-t", help="Application title"),
style: str = typer.Option("dashboard", "--style", "-s", help="UI style: dashboard, chat"),
agents_file: Optional[str] = typer.Option(None, "--agents", "-a", help="Agents YAML file"),
):
"""Start integrated UI-Gateway (Pattern C) - aiui with backend bridges.
Combines PraisonAI host integration with AIUIGateway for unified dashboard + chat.
Examples:
praisonai serve ui-gateway
praisonai serve ui-gateway --style chat --port 8765
praisonai serve ui-gateway --agents agents.yaml --title "My App"
"""
output = get_output_controller()
try:
from ...integration.gateway_host import run_integrated_gateway
run_integrated_gateway(
host=host,
port=port,
title=title,
style=style
)
except ImportError as e:
output.print_error(f"UI-Gateway module not available: {e}")
output.print("Install with: pip install praisonai[ui]")
raise typer.Exit(4)
except Exception as e:
output.print_error(f"Failed to start UI-Gateway: {e}")
raise typer.Exit(1)
@app.command("rag")
def serve_rag(
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
@@ -110,21 +110,8 @@ def create_agent_centric_tools(
if orchestrator is None:
orchestrator = ActionOrchestrator(runtime)
# Helper to run async functions synchronously
def run_async(coro):
"""Run async coroutine synchronously."""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Create a new loop in a thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, coro)
return future.result(timeout=60)
else:
return loop.run_until_complete(coro)
except RuntimeError:
return asyncio.run(coro)
# Helper to run async functions synchronously using the shared bridge
from praisonai._async_bridge import run_sync
# =========================================================================
# ACP-Powered File Tools
@@ -204,7 +191,7 @@ def create_agent_centric_tools(
"verified": result.success
})
return run_async(_create())
return run_sync(_create())
def acp_edit_file(filepath: str, new_content: str) -> str:
"""
@@ -261,7 +248,7 @@ def create_agent_centric_tools(
"error": result.error
})
return run_async(_edit())
return run_sync(_edit())
def acp_delete_file(filepath: str) -> str:
"""
@@ -313,7 +300,7 @@ def create_agent_centric_tools(
"verified": result.success
})
return run_async(_delete())
return run_sync(_delete())
def acp_execute_command(command: str, cwd: str = None) -> str:
"""
@@ -372,7 +359,7 @@ def create_agent_centric_tools(
"error": result.error
})
return run_async(_execute())
return run_sync(_execute())
# =========================================================================
# LSP-Powered Code Intelligence Tools
@@ -404,7 +391,7 @@ def create_agent_centric_tools(
except asyncio.TimeoutError:
return json.dumps({"error": "LSP list_symbols timed out (10s)", "success": False})
return run_async(_list())
return run_sync(_list())
def lsp_find_definition(symbol: str, file_path: str = None) -> str:
"""
@@ -432,7 +419,7 @@ def create_agent_centric_tools(
except asyncio.TimeoutError:
return json.dumps({"error": "LSP find_definition timed out (10s)", "success": False})
return run_async(_find())
return run_sync(_find())
def lsp_find_references(symbol: str, file_path: str = None) -> str:
"""
@@ -460,7 +447,7 @@ def create_agent_centric_tools(
except asyncio.TimeoutError:
return json.dumps({"error": "LSP find_references timed out (10s)", "success": False})
return run_async(_find())
return run_sync(_find())
def lsp_get_diagnostics(file_path: str = None) -> str:
"""
@@ -487,7 +474,7 @@ def create_agent_centric_tools(
except asyncio.TimeoutError:
return json.dumps({"error": "LSP diagnostics timed out (10s)", "success": False})
return run_async(_get())
return run_sync(_get())
# =========================================================================
# Basic File Tools (for read-only operations)
@@ -200,3 +200,141 @@ def check_bot_security(config: DoctorConfig) -> CheckResult:
)
@register_check(
id="multi_channel_tokens",
title="Multi-Channel Token Configuration",
description="Check for duplicate tokens across channels and validate multi-channel setup",
category=CheckCategory.BOTS,
severity=CheckSeverity.MEDIUM,
)
def check_multi_channel_tokens(config: DoctorConfig) -> CheckResult:
"""Check multi-channel token configuration for duplicates and naming conventions."""
start = time.time()
from praisonai.cli._paths import resolve_bot_config_path
config_path = getattr(config, 'config_file', None) or resolve_bot_config_path("bot.yaml")
# Check if bot.yaml exists
if not os.path.exists(config_path):
return CheckResult(
id="multi_channel_tokens",
title="Multi-Channel Token Configuration",
category=CheckCategory.BOTS,
status=CheckStatus.SKIP,
message=f"{config_path} not found - multi-channel check skipped",
duration_ms=(time.time() - start) * 1000,
)
try:
from praisonai.bots._config_schema import load_and_validate_bot_yaml
config_data = load_and_validate_bot_yaml(config_path)
warnings = []
errors = []
# Extract token environment variables from channels
channel_tokens = {} # env_var -> [channel_keys_using_it]
channel_platforms = {} # platform -> [channel_keys]
for channel_name, channel_config in config_data.channels.items():
platform = channel_config.platform
token_ref = channel_config.token
# Track platforms
if platform not in channel_platforms:
channel_platforms[platform] = []
channel_platforms[platform].append(channel_name)
# Extract environment variable from token reference like ${TELEGRAM_BOT_TOKEN}
if token_ref and token_ref.startswith("${") and token_ref.endswith("}"):
env_var = token_ref[2:-1] # Remove ${ and }
if env_var not in channel_tokens:
channel_tokens[env_var] = []
channel_tokens[env_var].append(channel_name)
# Check for duplicate token usage by env var reference
for env_var, channels in channel_tokens.items():
if len(channels) > 1:
errors.append(f"Token {env_var} is used by multiple channels: {', '.join(channels)}")
# Also check duplicate token usage by resolved token value
token_value_to_channels = {} # token_value -> [channel_keys]
for env_var, channels in channel_tokens.items():
token_value = os.environ.get(env_var)
if token_value:
token_value_to_channels.setdefault(token_value, []).extend(channels)
for _, channels in token_value_to_channels.items():
unique_channels = sorted(set(channels))
if len(unique_channels) > 1:
errors.append(
f"Same bot token value is used by multiple channels: {', '.join(unique_channels)}"
)
# Check for multi-platform channels with good naming conventions
for platform, channels in channel_platforms.items():
if len(channels) > 1:
# Multiple channels on same platform - check naming convention
for channel_name in channels:
channel_config = config_data.channels[channel_name]
token_ref = channel_config.token
if token_ref and token_ref.startswith("${") and token_ref.endswith("}"):
env_var = token_ref[2:-1]
# Check if follows naming convention: PLATFORM_ROLE_BOT_TOKEN
expected_pattern = f"{platform.upper()}_"
if not env_var.startswith(expected_pattern) or not env_var.endswith("_BOT_TOKEN"):
warnings.append(f"Channel '{channel_name}' token '{env_var}' doesn't follow naming convention '{platform.upper()}_<ROLE>_BOT_TOKEN'")
# Check for missing tokens
missing_tokens = []
for env_var in channel_tokens.keys():
if not os.environ.get(env_var):
missing_tokens.append(env_var)
# Determine status
if errors:
return CheckResult(
id="multi_channel_tokens",
title="Multi-Channel Token Configuration",
category=CheckCategory.BOTS,
status=CheckStatus.FAIL,
message=f"Token configuration errors: {len(errors)} duplicate(s) found",
details='\n'.join(errors + warnings),
remediation="Each channel must have a unique bot token. Create separate bots in @BotFather and use unique environment variables.",
duration_ms=(time.time() - start) * 1000,
)
elif warnings or missing_tokens:
all_issues = warnings + [f"Missing token: {token}" for token in missing_tokens]
return CheckResult(
id="multi_channel_tokens",
title="Multi-Channel Token Configuration",
category=CheckCategory.BOTS,
status=CheckStatus.WARN,
message=f"Multi-channel setup with {len(all_issues)} recommendation(s)",
details='\n'.join(all_issues),
remediation="Consider following the naming convention PLATFORM_ROLE_BOT_TOKEN for clarity.",
duration_ms=(time.time() - start) * 1000,
)
else:
channel_count = sum(len(channels) for channels in channel_platforms.values())
return CheckResult(
id="multi_channel_tokens",
title="Multi-Channel Token Configuration",
category=CheckCategory.BOTS,
status=CheckStatus.PASS,
message=f"Multi-channel configuration looks good ({channel_count} channels)",
duration_ms=(time.time() - start) * 1000,
)
except Exception as e:
return CheckResult(
id="multi_channel_tokens",
title="Multi-Channel Token Configuration",
category=CheckCategory.BOTS,
status=CheckStatus.ERROR,
message=f"Error checking multi-channel config: {str(e)[:100]}",
remediation="Fix bot.yaml syntax errors first",
duration_ms=(time.time() - start) * 1000,
)
@@ -229,3 +229,99 @@ def check_skills_praisonai_integration(config: DoctorConfig) -> CheckResult:
message="Skills module not available",
details=str(e),
)
@register_check(
id="skills_capabilities",
title="Skills Capability Requirements",
description="Check skill capability requirements and enforcement",
category=CheckCategory.SKILLS,
severity=CheckSeverity.MEDIUM,
dependencies=["skills_praisonai_integration"],
)
def check_skills_capabilities(config: DoctorConfig) -> CheckResult:
"""Check skill capability requirements."""
try:
from praisonaiagents.skills import SkillManager
from praisonaiagents.skills.capability_validator import EnforcementLevel
# Initialize skill manager
manager = SkillManager()
# Discover skills
discovered = manager.discover(include_defaults=True)
if discovered == 0:
return CheckResult(
id="skills_capabilities",
title="Skills Capability Requirements",
category=CheckCategory.SKILLS,
status=CheckStatus.SKIP,
message="No skills found to check",
)
# Get diagnostics for all skills
diagnostics = manager.get_skills_diagnostics()
active_count = 0
degraded_count = 0
unavailable_count = 0
issues = []
for skill_name, result in diagnostics.items():
if result.state.value == "active":
active_count += 1
elif result.state.value == "degraded":
degraded_count += 1
issues.extend([f"{skill_name}: {w}" for w in result.warnings[:2]])
elif result.state.value == "unavailable":
unavailable_count += 1
issues.extend([f"{skill_name}: {e}" for e in result.errors[:2]])
total = active_count + degraded_count + unavailable_count
# Determine status
enforcement_level = manager._validator.enforcement_level
if unavailable_count == 0 and degraded_count == 0:
status = CheckStatus.PASS
message = f"All {total} skill(s) have requirements satisfied"
elif unavailable_count > 0 and enforcement_level == EnforcementLevel.STRICT:
status = CheckStatus.FAIL
message = f"{unavailable_count}/{total} skills unavailable (strict enforcement)"
elif unavailable_count > 0 or degraded_count > 0:
status = CheckStatus.WARN
message = f"{active_count}/{total} skills fully available, {degraded_count} degraded, {unavailable_count} unavailable"
else:
status = CheckStatus.PASS
message = f"Skills capability check completed"
details = "; ".join(issues[:5]) + ("..." if len(issues) > 5 else "") if issues else None
metadata = {
"total_skills": total,
"active": active_count,
"degraded": degraded_count,
"unavailable": unavailable_count,
"enforcement_level": enforcement_level.value,
"sample_issues": issues[:10]
}
return CheckResult(
id="skills_capabilities",
title="Skills Capability Requirements",
category=CheckCategory.SKILLS,
status=status,
message=message,
details=details,
metadata=metadata,
)
except ImportError as e:
return CheckResult(
id="skills_capabilities",
title="Skills Capability Requirements",
category=CheckCategory.SKILLS,
status=CheckStatus.SKIP,
message="Skills capability validation not available",
details=str(e),
)
@@ -168,6 +168,82 @@ class GatewayHandler:
except Exception as e:
print(f"Error loading agents: {e}")
def stop(self, host: str = "127.0.0.1", port: int = 8765, force: bool = False) -> None:
"""Stop a running gateway instance.
Args:
host: Gateway host
port: Gateway port
force: Force stop (kill process)
"""
try:
from praisonai.gateway.port_utils import GatewayPIDLock
except ImportError as e:
print(f"Error: Gateway utilities not available. {e}")
return
pid_lock = GatewayPIDLock(host=host, port=port)
lock_info = pid_lock.get_lock_info()
if not lock_info:
print(f"No gateway PID lock found. Gateway may not be running on {host}:{port}")
return
pid = lock_info['pid']
is_running = lock_info['is_running']
if not is_running:
print(f"Gateway process (PID {pid}) is no longer running. Cleaning up stale lock.")
pid_lock.release_lock()
return
if force:
self._force_kill_process(pid)
else:
self._graceful_stop_process(pid)
# Clean up lock file
pid_lock.release_lock()
print(f"Gateway stopped (PID {pid})")
def _graceful_stop_process(self, pid: int) -> None:
"""Gracefully stop a process by sending SIGTERM."""
import signal
import time
import os
try:
print(f"Sending stop signal to PID {pid}...")
os.kill(pid, signal.SIGTERM)
# Wait up to 10 seconds for graceful shutdown
for _ in range(100):
try:
os.kill(pid, 0) # Check if process exists
time.sleep(0.1)
except (OSError, ProcessLookupError):
return # Process has stopped
print(f"Process {pid} did not stop gracefully, forcing...")
self._force_kill_process(pid)
except (OSError, ProcessLookupError):
print(f"Process {pid} not found or already stopped")
def _force_kill_process(self, pid: int) -> None:
"""Force kill a process with SIGKILL (Windows: SIGTERM)."""
import signal
import os
import sys
try:
print(f"Force killing PID {pid}...")
# Use SIGTERM on Windows since SIGKILL is not available
sig = signal.SIGTERM if sys.platform == "win32" else signal.SIGKILL
os.kill(pid, sig)
except (OSError, ProcessLookupError):
print(f"Process {pid} not found or already stopped")
def status(self, host: str = "127.0.0.1", port: int = 8765) -> None:
"""Check gateway status.
@@ -178,6 +254,35 @@ class GatewayHandler:
import urllib.request
import json
# Check PID lock info first
try:
from praisonai.gateway.port_utils import GatewayPIDLock, is_port_in_use
pid_lock = GatewayPIDLock(host=host, port=port)
lock_info = pid_lock.get_lock_info()
if lock_info:
pid = lock_info['pid']
is_running = lock_info['is_running']
lock_host = lock_info['host']
lock_port = lock_info['port']
if is_running:
print(f"Gateway PID lock: Process {pid} running ({lock_host}:{lock_port})")
else:
print(f"Gateway PID lock: Stale lock (process {pid} not running)")
else:
print("Gateway PID lock: No lock file found")
# Check if port is in use
if is_port_in_use(host, port):
print(f"Port {host}:{port}: In use")
else:
print(f"Port {host}:{port}: Available")
except ImportError:
print("PID lock status: Utilities not available")
# Try to connect to health endpoint
url = f"http://{host}:{port}/health"
try:
@@ -193,23 +193,32 @@ def _load_basic_tools() -> Dict[str, Callable]:
return tools
# ── Shared runtime singleton ────────────────────────────────────────────────
# ACP and LSP tools share ONE InteractiveRuntime to avoid duplicate LSP servers.
_shared_runtime = None
_shared_agent_tools = None
# ── Context-local runtime for multi-agent safety ──────────────────────────
# Each agent/task/request gets its own runtime context to avoid config conflicts and race conditions.
import contextvars
_runtime_var: contextvars.ContextVar = contextvars.ContextVar("interactive_runtime", default=None)
def _get_shared_runtime(config: ToolConfig):
"""Get or create a shared InteractiveRuntime instance.
"""Get or create a context-local InteractiveRuntime instance.
Each context (agent/task/request) gets its own runtime with the config provided.
This prevents race conditions and config capture issues from global singletons.
First-caller-wins policy: Once a runtime is created for a context with specific
config, subsequent calls in the same context will return the cached runtime and
ignore any different config parameters.
Returns (runtime, all_tools) — the runtime and the full list of
agent-centric tool functions created from it.
"""
global _shared_runtime, _shared_agent_tools
if _shared_runtime is not None and _shared_agent_tools is not None:
return _shared_runtime, _shared_agent_tools
bundle = _runtime_var.get()
if bundle is not None:
# Return cached runtime (first-caller-wins for this context)
return bundle
# Create new runtime for this context
from .interactive_runtime import InteractiveRuntime, RuntimeConfig
from .agent_tools import create_agent_centric_tools
@@ -220,37 +229,41 @@ def _get_shared_runtime(config: ToolConfig):
approval_mode=config.approval_mode,
)
_shared_runtime = InteractiveRuntime(runtime_config)
_shared_agent_tools = create_agent_centric_tools(_shared_runtime)
runtime = InteractiveRuntime(runtime_config)
agent_tools = create_agent_centric_tools(runtime)
bundle = (runtime, agent_tools)
_runtime_var.set(bundle)
logger.debug("Created context-local InteractiveRuntime")
logger.debug("Created shared InteractiveRuntime")
return _shared_runtime, _shared_agent_tools
return bundle
def cleanup_runtime():
"""Stop the shared InteractiveRuntime (LSP server, ACP session).
"""Stop the context-local InteractiveRuntime (LSP server, ACP session).
Call this when the agent finishes to release resources.
Safe to call multiple times or when no runtime exists.
Safe to call multiple times or when no runtime exists in this context.
"""
global _shared_runtime, _shared_agent_tools
if _shared_runtime is not None:
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
pool.submit(asyncio.run, _shared_runtime.stop()).result(timeout=5)
else:
loop.run_until_complete(_shared_runtime.stop())
except Exception as e:
logger.debug(f"Runtime cleanup: {e}")
bundle = _runtime_var.get()
if bundle is None:
return
_shared_runtime = None
_shared_agent_tools = None
logger.debug("Shared InteractiveRuntime stopped")
runtime, _ = bundle
import asyncio
try:
loop = asyncio.get_event_loop()
if loop.is_running():
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as pool:
pool.submit(asyncio.run, runtime.stop()).result(timeout=5)
else:
loop.run_until_complete(runtime.stop())
except Exception as e:
logger.debug(f"Runtime cleanup: {e}")
_runtime_var.set(None)
logger.debug("Context-local InteractiveRuntime stopped")
def _load_acp_tools(config: ToolConfig) -> Dict[str, Callable]:
+347 -170
View File
@@ -18,6 +18,7 @@ import asyncio
import getpass
import logging
import os
import re
import sys
from pathlib import Path
from typing import Any, Dict, List, Optional
@@ -206,6 +207,89 @@ def _read_env_value(key: str) -> Optional[str]:
return None
def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str = "assistant", agent_instructions: str = "") -> str:
"""Generate bot.yaml content for multiple channels.
Args:
channels: Dict of channel_key -> {platform, role, env_var, token, info}
agent_name: Default agent name
agent_instructions: Default agent instructions
"""
# Sanitize instructions for YAML safety
instructions = agent_instructions or "You are a helpful AI assistant."
instructions = instructions.rstrip('. ')
instructions_escaped = instructions.replace('\\', '\\\\').replace('"', '\\"')
lines = ["# Generated by `praisonai onboard`. Multi-channel configuration.",
"# Compatible with both `praisonai bot start` and `praisonai gateway start`.",
"# Each channel can route to different agents.",
""]
# Gateway server block
lines.append("gateway:")
lines.append(' host: "127.0.0.1"')
lines.append(" port: 8765")
lines.append("")
# Legacy single-agent block for backward compatibility
lines.append("agent:")
lines.append(f' name: "{agent_name}"')
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
lines.append(' model: "gpt-4o-mini"')
lines.append("")
# Multi-agent block with agents for each role
lines.append("agents:")
agents_added = set()
for channel_key, channel in channels.items():
role = channel["role"]
if role not in agents_added:
lines.append(f" {role}:")
if role == "assistant":
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
else:
lines.append(f' instructions: "You are a {role} agent. {instructions_escaped} Use your tools proactively to help with {role}-related tasks."')
lines.append(' model: gpt-4o-mini')
lines.append(" memory: false")
lines.append("")
agents_added.add(role)
# Channels section with multiple channels per platform supported
lines.append("channels:")
for channel_key, channel in channels.items():
platform = channel["platform"]
role = channel["role"]
env_var = channel["env_var"]
info = channel["info"]
lines.append(f" {channel_key}:")
lines.append(f" platform: {platform}")
lines.append(f" token: ${{{env_var}}}")
lines.append(" group_policy: mention_only")
allowed_env = info.get("allowed_users_env")
if allowed_env:
lines.append(f" allowed_users: ${{{allowed_env}}}")
lines.append(" # auto_approve_tools: true")
lines.append(" # Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.")
if platform == "whatsapp":
lines.append(" phone_number_id: ${WHATSAPP_PHONE_NUMBER_ID}")
# Routing to specific agent
lines.append(" routes:")
lines.append(f" default: {role}")
lines.append("")
# Legacy routing block
lines.append("routing:")
lines.append(f" default: {agent_name}")
lines.append("")
return "\n".join(lines)
def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agent_instructions: str = "") -> str:
"""Generate bot.yaml content compatible with BOTH ``praisonai bot start``
and ``praisonai gateway start``.
@@ -265,6 +349,8 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
lines.append(f" allowed_users: ${{{allowed_env}}}")
lines.append(" # auto_approve_tools: true # Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.")
lines.append(" # Set to false only if you add destructive tools AND have a chat-level approval flow.")
lines.append(' ack_emoji: "⏳" # Show processing indicator during long agent operations')
lines.append(' done_emoji: "✅" # Replace ack_emoji when operation completes')
if plat == "whatsapp":
lines.append(" phone_number_id: ${WHATSAPP_PHONE_NUMBER_ID}")
# Gateway-style routing (ignored by `bot start` which uses top-level routing).
@@ -284,7 +370,8 @@ class OnboardWizard:
def __init__(self):
self.selected_platforms: List[str] = []
self.tokens: Dict[str, str] = {}
self.channels: Dict[str, Dict] = {} # channel_key -> {platform, token, role, env_var}
self.tokens: Dict[str, str] = {} # legacy/fallback compatibility
self.agent_name: str = "assistant"
self.agent_instructions: str = "You are a helpful AI assistant."
self.config_path: str = "bot.yaml"
@@ -318,169 +405,59 @@ class OnboardWizard:
"Existing bot.yaml will be kept (no overwrite).[/dim]"
)
# Step 1: Platform selection
console.print("\n[bold]Step 1: Choose your platform(s)[/bold]\n")
# Step 1: Platform and channel selection
console.print("\n[bold]Step 1: Configure your bot channels[/bold]\n")
console.print(
"You can create multiple bots on the same platform for different roles.\n"
"For example: @company_cfo_bot, @company_ops_bot, @company_content_bot\n"
)
for key, info in PLATFORMS.items():
console.print(f" [cyan]{key}[/cyan] — {info['name']}")
platforms_input = _prompt_ask(
# Start with first platform
first_platform = _prompt_ask(
Prompt,
"\nPlatform(s) [comma-separated]",
"\nChoose your first platform",
default="telegram",
)
self.selected_platforms = [p.strip().lower() for p in platforms_input.split(",") if p.strip()]
# Validate
for plat in self.selected_platforms:
if plat not in PLATFORMS:
console.print(f"[red]Unknown platform: {plat}[/red]")
return
# Step 2: Token configuration (password-hidden; persisted to ~/.praisonai/.env)
# Every setting can be updated even if already present — existing values are
# shown as masked defaults; press Enter to keep, or type a new value to
# overwrite (mirrors hermes-agent setup behaviour).
console.print("\n[bold]Step 2: Configure tokens[/bold]\n")
console.print(
" [dim]Press Enter to keep the existing value, or type a new one to update.[/dim]\n"
)
env_to_save: Dict[str, Optional[str]] = {}
def _mask(value: str) -> str:
if not value:
return ""
if len(value) <= 6:
return "*" * len(value)
return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}"
for plat in self.selected_platforms:
info = PLATFORMS[plat]
env_var = info["token_env"]
existing = os.environ.get(env_var, "")
if existing:
console.print(
f" [green]✓[/green] {info['name']}: {env_var} = [cyan]{_mask(existing)}[/cyan]"
)
console.print(f" [dim]{info['token_help']}[/dim]")
new_token = _prompt_ask(
Prompt,
f" Update {info['name']} token? (Enter = keep current)",
password=True,
default="",
show_default=False,
)
if new_token:
self.tokens[plat] = new_token
os.environ[env_var] = new_token
env_to_save[env_var] = new_token
console.print(" [green]✓[/green] Token updated")
else:
self.tokens[plat] = existing
else:
console.print(f" [dim]{info['token_help']}[/dim]")
token = _prompt_ask(
Prompt,
f" {info['name']} token ({env_var})",
password=True,
default="",
show_default=False,
)
if token:
self.tokens[plat] = token
os.environ[env_var] = token
env_to_save[env_var] = token
console.print(" [green]✓[/green] Token captured")
else:
console.print(
f" [yellow]⚠[/yellow] No token — you'll need to set {env_var} before starting"
)
# Extra env vars (e.g., Slack app token) — also hidden by default
for extra_env, extra_desc in info.get("extra_env", {}).items():
existing_extra = os.environ.get(extra_env, "")
if existing_extra:
console.print(
f" [green]✓[/green] {extra_env} = [cyan]{_mask(existing_extra)}[/cyan]"
)
new_extra = _prompt_ask(
Prompt,
f" Update {extra_desc}? (Enter = keep current)",
password=True,
default="",
show_default=False,
)
if new_extra:
os.environ[extra_env] = new_extra
env_to_save[extra_env] = new_extra
console.print(f" [green]✓[/green] {extra_env} updated")
else:
extra_val = _prompt_ask(
Prompt,
f" {extra_desc} ({extra_env})",
password=True,
default="",
show_default=False,
)
if extra_val:
os.environ[extra_env] = extra_val
env_to_save[extra_env] = extra_val
# Step 2b: Security — allowlist (mirrors hermes)
allowed_env = info.get("allowed_users_env")
if allowed_env:
existing_allow = os.environ.get(allowed_env, "").strip()
if existing_allow:
console.print(
f" [green]✓[/green] {allowed_env} = [cyan]{existing_allow}[/cyan]"
)
console.print(
f" [dim]{info.get('user_id_help', 'Comma-separated user IDs')}[/dim]"
)
new_allow = _prompt_ask(
Prompt,
f" Update allowed users for {info['name']}? (Enter = keep, 'clear' = remove)",
default="",
show_default=False,
).strip()
if new_allow.lower() == "clear":
os.environ.pop(allowed_env, None)
env_to_save[allowed_env] = None
console.print(
" [yellow]✓ Allowlist cleared — open access restored[/yellow]"
)
elif new_allow:
new_allow = new_allow.replace(" ", "")
os.environ[allowed_env] = new_allow
env_to_save[allowed_env] = new_allow
console.print(" [green]✓[/green] Allowlist updated")
else:
console.print(
f" [bold]🔒 Security — restrict who can use your {info['name']} bot[/bold]"
)
console.print(
f" [dim]{info.get('user_id_help', 'Enter comma-separated user IDs')}[/dim]"
)
allow_val = _prompt_ask(
Prompt,
" Allowed user IDs (comma-separated, empty = open access)",
default="",
show_default=False,
)
allow_val = allow_val.replace(" ", "")
if allow_val:
os.environ[allowed_env] = allow_val
env_to_save[allowed_env] = allow_val
console.print(
" [green]✓[/green] Allowlist saved — only listed users can talk to the bot"
)
else:
console.print(
" [yellow]⚠ Warning:[/yellow] no allowlist set — anyone who finds your bot can use it."
)
# Home-channel prompt intentionally removed — see PLATFORMS
# docstring above. No code path consumes home_channel today.
).strip().lower()
if first_platform not in PLATFORMS:
console.print(f"[red]Unknown platform: {first_platform}[/red]")
return
self.selected_platforms = [first_platform]
# Configure first channel
self._configure_channel(console, Prompt, first_platform, is_first=True)
# Ask for additional channels
while True:
add_another = _confirm_ask(
Confirm,
f"\nAdd another bot channel?",
default=False,
)
if not add_another:
break
# Ask for platform
platform = _prompt_ask(
Prompt,
"Platform (telegram, discord, slack, whatsapp)",
default=first_platform,
).strip().lower()
if platform not in PLATFORMS:
console.print(f"[red]Unknown platform: {platform}[/red]")
continue
if platform not in self.selected_platforms:
self.selected_platforms.append(platform)
self._configure_channel(console, Prompt, platform, is_first=False)
# Step 2: Configure tokens
env_to_save = self._configure_tokens(console, Prompt)
# Persist everything collected above to ~/.praisonai/.env
env_file = _save_env_vars(env_to_save)
@@ -489,11 +466,11 @@ class OnboardWizard:
# Step 3: Test connection
console.print("\n[bold]Step 3: Test connection[/bold]\n")
for plat in self.selected_platforms:
if plat in self.tokens:
console.print(f" Testing {PLATFORMS[plat]['name']}...", end=" ")
for channel_key, channel in self.channels.items():
if channel["token"]:
console.print(f" Testing {channel_key}...", end=" ")
try:
result = asyncio.run(self._probe(plat))
result = asyncio.run(self._probe_channel(channel))
if result.ok:
console.print(f"[green]✓ @{result.bot_username}[/green] ({result.elapsed_ms:.0f}ms)")
else:
@@ -539,8 +516,8 @@ class OnboardWizard:
# start`` with zero flags. Overwrite-confirm still guards existing
# configs so power users who hand-edited the file aren't surprised.
console.print("\n[bold]Step 5: Generate configuration[/bold]\n")
yaml_content = _generate_bot_yaml(
self.selected_platforms,
yaml_content = _generate_bot_yaml_multi_channel(
self.channels,
agent_name=self.agent_name,
agent_instructions=self.agent_instructions,
)
@@ -564,13 +541,13 @@ class OnboardWizard:
# Step 6: Optional daemon install
# Guard: refuse to install a daemon that has no tokens — it would loop
# in a crash-restart cycle. List the platforms missing tokens so the
# in a crash-restart cycle. List the channels missing tokens so the
# user can rerun onboard once they have them.
daemon_success = False
missing_tokens = [
PLATFORMS[p]["name"]
for p in self.selected_platforms
if p not in self.tokens
channel_key
for channel_key, channel in self.channels.items()
if not channel["token"]
]
if missing_tokens:
console.print(
@@ -654,8 +631,208 @@ class OnboardWizard:
border_style="green",
))
def _configure_channel(self, console, prompt_cls, platform: str, is_first: bool = False) -> None:
"""Configure a single channel for a platform."""
info = PLATFORMS[platform]
if is_first:
# For first channel, use default channel name
role = ""
channel_key = platform
else:
# For additional channels, ask for role
role = _prompt_ask(
prompt_cls,
f"Role for this {info['name']} bot (e.g., 'cfo', 'ops', 'support')",
default="",
).strip().lower()
# Sanitize role: only allow alphanumeric and underscores
role = re.sub(r"[^a-z0-9_]+", "_", role).strip("_")
if role:
channel_key = f"{platform}_{role}"
# Ensure channel key is unique
if channel_key in self.channels:
console.print(f"[red]Channel '{channel_key}' already exists. Choose a different role.[/red]")
return
else:
# Count existing channels for this platform
existing_count = sum(1 for k in self.channels.keys() if k.startswith(f"{platform}"))
channel_key = f"{platform}_{existing_count + 1}"
# Generate environment variable name
if role:
env_var = f"{platform.upper()}_{role.upper()}_BOT_TOKEN"
else:
# Use standard name for single channel
env_var = info["token_env"]
# Store channel configuration
self.channels[channel_key] = {
"platform": platform,
"role": role or "assistant",
"env_var": env_var,
"token": "",
"info": info
}
console.print(f" [green]✓[/green] Configured channel '[cyan]{channel_key}[/cyan]' (env: [cyan]{env_var}[/cyan])")
def _configure_tokens(self, console, prompt_cls) -> Dict[str, Optional[str]]:
"""Configure tokens for all channels."""
console.print("\n[bold]Step 2: Configure tokens[/bold]\n")
console.print(
" [dim]Press Enter to keep the existing value, or type a new one to update.[/dim]\n"
)
env_to_save: Dict[str, Optional[str]] = {}
def _mask(value: str) -> str:
if not value:
return ""
if len(value) <= 6:
return "*" * len(value)
return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}"
for channel_key, channel in self.channels.items():
platform = channel["platform"]
role = channel["role"]
env_var = channel["env_var"]
info = channel["info"]
existing = os.environ.get(env_var, "")
console.print(f"\n[bold]Channel: {channel_key}[/bold]")
if existing:
console.print(
f" [green]✓[/green] {info['name']}: {env_var} = [cyan]{_mask(existing)}[/cyan]"
)
console.print(f" [dim]{info['token_help']}[/dim]")
new_token = _prompt_ask(
prompt_cls,
f" Update token for {channel_key}? (Enter = keep current)",
password=True,
default="",
show_default=False,
)
if new_token:
channel["token"] = new_token
os.environ[env_var] = new_token
env_to_save[env_var] = new_token
console.print(" [green]✓[/green] Token updated")
else:
channel["token"] = existing
else:
console.print(f" [dim]{info['token_help']}[/dim]")
token = _prompt_ask(
prompt_cls,
f" Token for {channel_key} ({env_var})",
password=True,
default="",
show_default=False,
)
if token:
channel["token"] = token
os.environ[env_var] = token
env_to_save[env_var] = token
console.print(" [green]✓[/green] Token captured")
else:
console.print(
f" [yellow]⚠[/yellow] No token — you'll need to set {env_var} before starting"
)
# Handle extra env vars (e.g., Slack app token)
for extra_env, extra_desc in info.get("extra_env", {}).items():
existing_extra = os.environ.get(extra_env, "")
if existing_extra:
console.print(
f" [green]✓[/green] {extra_env} = [cyan]{_mask(existing_extra)}[/cyan]"
)
new_extra = _prompt_ask(
prompt_cls,
f" Update {extra_desc}? (Enter = keep current)",
password=True,
default="",
show_default=False,
)
if new_extra:
os.environ[extra_env] = new_extra
env_to_save[extra_env] = new_extra
console.print(f" [green]✓[/green] {extra_env} updated")
else:
extra_val = _prompt_ask(
prompt_cls,
f" {extra_desc} ({extra_env})",
password=True,
default="",
show_default=False,
)
if extra_val:
os.environ[extra_env] = extra_val
env_to_save[extra_env] = extra_val
# Configure allowlist for this channel
allowed_env = info.get("allowed_users_env")
if allowed_env:
existing_allow = os.environ.get(allowed_env, "").strip()
if existing_allow:
console.print(
f" [green]✓[/green] {allowed_env} = [cyan]{existing_allow}[/cyan]"
)
console.print(
f" [dim]{info.get('user_id_help', 'Comma-separated user IDs')}[/dim]"
)
new_allow = _prompt_ask(
prompt_cls,
f" Update allowed users for {channel_key}? (Enter = keep, 'clear' = remove)",
default="",
show_default=False,
).strip()
if new_allow.lower() == "clear":
os.environ.pop(allowed_env, None)
env_to_save[allowed_env] = None
console.print(
" [yellow]✓ Allowlist cleared — open access restored[/yellow]"
)
elif new_allow:
new_allow = new_allow.replace(" ", "")
os.environ[allowed_env] = new_allow
env_to_save[allowed_env] = new_allow
console.print(" [green]✓[/green] Allowlist updated")
else:
console.print(
f" [bold]🔒 Security — restrict who can use {channel_key}[/bold]"
)
console.print(
f" [dim]{info.get('user_id_help', 'Enter comma-separated user IDs')}[/dim]"
)
allow_val = _prompt_ask(
prompt_cls,
" Allowed user IDs (comma-separated, empty = open access)",
default="",
show_default=False,
)
allow_val = allow_val.replace(" ", "")
if allow_val:
os.environ[allowed_env] = allow_val
env_to_save[allowed_env] = allow_val
console.print(
" [green]✓[/green] Allowlist saved — only listed users can talk to the bot"
)
else:
console.print(
f" [yellow]⚠ Warning:[/yellow] no allowlist set for {channel_key} — anyone who finds your bot can use it."
)
return env_to_save
async def _probe_channel(self, channel: Dict):
"""Run a probe for a channel."""
from praisonai.bots import Bot
bot = Bot(channel["platform"], token=channel["token"])
return await bot.probe()
async def _probe(self, platform: str):
"""Run a probe for a platform."""
"""Run a probe for a platform (legacy method for backward compatibility)."""
from praisonai.bots import Bot
bot = Bot(platform, token=self.tokens.get(platform, ""))
return await bot.probe()
+35
View File
@@ -4117,6 +4117,16 @@ Do NOT add any explanations or formatting."""
planning_tools = getattr(self.args, 'planning_tools', None)
if planning_tools:
cli_config['planning_tools'] = planning_tools
# Extract --planning flag
if getattr(self.args, 'planning', False):
cli_config['planning'] = True
# Extract web flags
if getattr(self.args, 'web', False):
cli_config['web'] = True
if getattr(self.args, 'web_fetch', False):
cli_config['web_fetch'] = True
# Extract --acp flag
if getattr(self.args, 'acp', False):
@@ -4154,6 +4164,31 @@ Do NOT add any explanations or formatting."""
cli_config['stream'] = stream or stream_metrics
if stream_metrics:
cli_config['stream_metrics'] = True
# Extract handoff configuration for YAML CLI parity
handoff = getattr(self.args, 'handoff', None)
if handoff:
cli_config['handoff'] = handoff
handoff_policy = getattr(self.args, 'handoff_policy', None)
if handoff_policy is not None:
cli_config['handoff_policy'] = handoff_policy
handoff_timeout = getattr(self.args, 'handoff_timeout', None)
if handoff_timeout is not None:
cli_config['handoff_timeout'] = handoff_timeout
handoff_max_depth = getattr(self.args, 'handoff_max_depth', None)
if handoff_max_depth is not None:
cli_config['handoff_max_depth'] = handoff_max_depth
handoff_max_concurrent = getattr(self.args, 'handoff_max_concurrent', None)
if handoff_max_concurrent is not None:
cli_config['handoff_max_concurrent'] = handoff_max_concurrent
handoff_detect_cycles = getattr(self.args, 'handoff_detect_cycles', None)
if handoff_detect_cycles is not None:
cli_config['handoff_detect_cycles'] = handoff_detect_cycles
return cli_config
+75 -45
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.6.45 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.6.48 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -140,52 +140,82 @@ class CloudDeployer:
openai_key = ep.api_key or 'Enter your API key'
openai_base = ep.base_url
# Build commands with actual values
commands = [
['gcloud', 'auth', 'configure-docker', 'us-central1-docker.pkg.dev'],
['gcloud', 'artifacts', 'repositories', 'create', 'praisonai-repository',
'--repository-format=docker', '--location=us-central1'],
['docker', 'build', '--platform', 'linux/amd64', '-t',
f'gcr.io/{project_id}/praisonai-app:latest', '.'],
['docker', 'tag', f'gcr.io/{project_id}/praisonai-app:latest',
f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest'],
['docker', 'push',
f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest'],
['gcloud', 'run', 'deploy', 'praisonai-service',
'--image', f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest',
'--platform', 'managed', '--region', 'us-central1', '--allow-unauthenticated',
'--set-env-vars', f'OPENAI_MODEL_NAME={openai_model},OPENAI_API_KEY={openai_key},OPENAI_API_BASE={openai_base}']
]
# Create temporary env vars file to avoid exposing secrets in argv
import tempfile
import yaml
import os
# Run commands with appropriate handling for each platform
for i, cmd in enumerate(commands):
try:
if i == 0: # First command (gcloud auth configure-docker)
if platform.system() != 'Windows':
# On Unix, pipe 'yes' to auto-confirm
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
proc.communicate(input=b'Y\n')
if proc.returncode != 0:
raise subprocess.CalledProcessError(proc.returncode, cmd)
env_vars_file = None
try:
# Create secure temp file for environment variables
fd, env_vars_file = tempfile.mkstemp(suffix=".yaml", prefix="praisonai-deploy-")
os.close(fd)
os.chmod(env_vars_file, 0o600) # Secure file permissions
# Write env vars to file instead of passing in argv
env_vars = {
"OPENAI_MODEL_NAME": openai_model,
"OPENAI_API_KEY": openai_key,
"OPENAI_API_BASE": openai_base
}
with open(env_vars_file, "w") as f:
yaml.safe_dump(env_vars, f)
# Build commands with secure env vars file
commands = [
['gcloud', 'auth', 'configure-docker', 'us-central1-docker.pkg.dev'],
['gcloud', 'artifacts', 'repositories', 'create', 'praisonai-repository',
'--repository-format=docker', '--location=us-central1'],
['docker', 'build', '--platform', 'linux/amd64', '-t',
f'gcr.io/{project_id}/praisonai-app:latest', '.'],
['docker', 'tag', f'gcr.io/{project_id}/praisonai-app:latest',
f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest'],
['docker', 'push',
f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest'],
['gcloud', 'run', 'deploy', 'praisonai-service',
'--image', f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest',
'--platform', 'managed', '--region', 'us-central1', '--allow-unauthenticated',
'--env-vars-file', env_vars_file]
]
# Run commands with appropriate handling for each platform
for i, cmd in enumerate(commands):
try:
if i == 0: # First command (gcloud auth configure-docker)
if platform.system() != 'Windows':
# On Unix, pipe 'yes' to auto-confirm
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
proc.communicate(input=b'Y\n')
if proc.returncode != 0:
raise subprocess.CalledProcessError(proc.returncode, cmd)
else:
# On Windows, try with --quiet flag to avoid prompts
cmd_with_quiet = cmd + ['--quiet']
try:
subprocess.run(cmd_with_quiet, check=True)
except subprocess.CalledProcessError:
# If --quiet fails, try without it
print("Note: You may need to manually confirm the authentication prompt")
subprocess.run(cmd, check=True)
else:
# On Windows, try with --quiet flag to avoid prompts
cmd_with_quiet = cmd + ['--quiet']
try:
subprocess.run(cmd_with_quiet, check=True)
except subprocess.CalledProcessError:
# If --quiet fails, try without it
print("Note: You may need to manually confirm the authentication prompt")
subprocess.run(cmd, check=True)
else:
# Run other commands normally
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"ERROR: Command failed with exit status {e.returncode}")
# Commands 2 (build) and 4 (push) and 5 (deploy) are critical
if i in [2, 4, 5]:
print("Critical command failed. Aborting deployment.")
return
print(f"Continuing with the next command...")
# Run other commands normally
subprocess.run(cmd, check=True)
except subprocess.CalledProcessError as e:
print(f"ERROR: Command failed with exit status {e.returncode}")
# Commands 2 (build) and 4 (push) and 5 (deploy) are critical
if i in [2, 4, 5]:
print("Critical command failed. Aborting deployment.")
return
print(f"Continuing with the next command...")
finally:
# Always cleanup the temporary env vars file
if env_vars_file:
try:
os.remove(env_vars_file)
except Exception:
pass # Don't fail deployment if cleanup fails
# Usage
if __name__ == "__main__":
@@ -71,44 +71,44 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
acp_enabled = global_config.get('acp', False)
lsp_enabled = global_config.get('lsp', False)
interactive_runtime = None
interactive_loop = None
if acp_enabled or lsp_enabled:
try:
import asyncio
from praisonai._async_bridge import run_sync
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
from praisonai.cli.features.agent_tools import create_agent_centric_tools
# Use scoped event loop instead of process-global mutations
# Use scoped configuration instead of process-global mutations
runtime_config = RuntimeConfig(
workspace=os.getcwd(),
acp_enabled=acp_enabled,
lsp_enabled=lsp_enabled,
approval_mode=os.environ.get("PRAISONAI_APPROVAL_MODE", "prompt")
)
interactive_runtime = InteractiveRuntime(runtime_config)
rt = InteractiveRuntime(runtime_config)
logger.info(f"Starting InteractiveRuntime (ACP: {acp_enabled}, LSP: {lsp_enabled})")
# Create a scoped event loop instead of modifying process globals
interactive_loop = asyncio.new_event_loop()
# Start the runtime but keep it alive for agent execution
interactive_loop.run_until_complete(interactive_runtime.start())
centric_tools = create_agent_centric_tools(interactive_runtime)
logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
# Merge with tools_dict
if tools_dict:
tools_dict.update(centric_tools)
else:
tools_dict = centric_tools
# Start the runtime on the shared background loop where it stays alive
# and its asyncio primitives remain valid for the duration of this call
run_sync(rt.start())
interactive_runtime = rt # only assign AFTER start() succeeds
except ImportError as e:
logger.warning(f"InteractiveRuntime not available: {e}")
except Exception as e:
logger.error(f"Error setting up InteractiveRuntime: {e}")
interactive_runtime = None
except (RuntimeError, OSError, ConnectionError) as e:
logger.warning(f"InteractiveRuntime startup failed: {e}")
interactive_runtime = None
try:
# All work that can throw *after* start() lives here, including
# create_agent_centric_tools, tools_dict.update, agent construction,
# team.start(), etc.
if interactive_runtime is not None:
from praisonai.cli.features.agent_tools import create_agent_centric_tools
centric_tools = create_agent_centric_tools(interactive_runtime)
logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
tools_dict = {**(tools_dict or {}), **centric_tools}
# Create agents from roles
for role, details in config.get('roles', {}).items():
role_filled = self._format_template(details.get('role', role), topic=topic)
@@ -204,12 +204,11 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
return result
finally:
# Cleanup InteractiveRuntime if it was started
if interactive_runtime and interactive_loop:
if interactive_runtime is not None:
try:
logger.info("Stopping InteractiveRuntime")
interactive_loop.run_until_complete(interactive_runtime.stop())
from praisonai._async_bridge import run_sync
run_sync(interactive_runtime.stop())
except Exception as e:
logger.error(f"Error stopping InteractiveRuntime: {e}")
finally:
interactive_loop.close()
@@ -0,0 +1,216 @@
"""
Port checking and PID lock utilities for gateway collision prevention.
Provides utilities to check if ports are in use and manage single-instance locks.
"""
from __future__ import annotations
import os
import socket
import time
from pathlib import Path
from typing import Optional, Tuple
def is_port_in_use(host: str = "127.0.0.1", port: int = 8765) -> bool:
"""Check if a port is already in use.
Args:
host: Host to check (default: 127.0.0.1)
port: Port to check (default: 8765)
Returns:
True if port is in use, False otherwise
"""
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
return sock.connect_ex((host, port)) == 0
def check_port_available(host: str, port: int) -> Tuple[bool, Optional[int]]:
"""Check if a port is available and attempt to find the process using it.
Args:
host: Host to check
port: Port to check
Returns:
Tuple of (is_available, pid_using_port)
"""
if not is_port_in_use(host, port):
return True, None
# Try to find the process using the port
try:
import psutil
for proc in psutil.process_iter(['pid', 'name', 'connections']):
try:
connections = proc.info['connections']
if connections:
for conn in connections:
if (hasattr(conn, 'laddr') and conn.laddr and
conn.laddr.port == port and
conn.laddr.ip in ('0.0.0.0', '127.0.0.1', host)):
return False, proc.info['pid']
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
continue
except ImportError:
# psutil not available, can't identify process
pass
return False, None
class GatewayPIDLock:
"""Manages PID lock file for single-instance gateway enforcement."""
def __init__(self, lock_dir: Optional[Path] = None, host: str = "127.0.0.1", port: int = 8765):
"""Initialize PID lock manager.
Args:
lock_dir: Directory for lock file (default: ~/.praisonai/)
host: Gateway host
port: Gateway port
"""
self.lock_dir = lock_dir or Path.home() / ".praisonai"
# Make lock file specific to host and port to allow multiple instances
safe_host = host.replace(":", "_").replace(".", "_")
self.lock_file = self.lock_dir / f"gateway-{safe_host}-{port}.pid"
self.lock_dir.mkdir(exist_ok=True)
def acquire_lock(self, host: str, port: int) -> bool:
"""Acquire the PID lock for the gateway.
Args:
host: Gateway host
port: Gateway port
Returns:
True if lock acquired successfully, False if conflict
"""
current_pid = os.getpid()
# Check if lock file exists
if self.lock_file.exists():
try:
lock_content = self.lock_file.read_text().strip()
if lock_content:
lines = lock_content.split('\n')
if lines:
existing_pid = int(lines[0])
# Check if process is still running
if self._is_process_running(existing_pid):
return False # Lock held by active process
# Process dead, remove stale lock
self._remove_stale_lock()
except (ValueError, OSError):
# Corrupted lock file, remove it
self._remove_stale_lock()
# Write new lock atomically to avoid race condition
lock_content = f"{current_pid}\n{host}\n{port}\n{int(time.time())}\n"
try:
# Use a temporary file and atomic rename to prevent race conditions
temp_lock_file = self.lock_file.with_suffix(".tmp")
temp_lock_file.write_text(lock_content)
temp_lock_file.replace(self.lock_file)
return True
except OSError:
return False
def release_lock(self) -> None:
"""Release the PID lock."""
try:
if self.lock_file.exists():
self.lock_file.unlink()
except OSError:
pass
def get_lock_info(self) -> Optional[dict]:
"""Get information about the current lock.
Returns:
Dict with lock info or None if no valid lock
"""
if not self.lock_file.exists():
return None
try:
lock_content = self.lock_file.read_text().strip()
if not lock_content:
return None
lines = lock_content.split('\n')
if len(lines) < 4:
return None
pid = int(lines[0])
host = lines[1]
port = int(lines[2])
timestamp = int(lines[3])
return {
'pid': pid,
'host': host,
'port': port,
'timestamp': timestamp,
'is_running': self._is_process_running(pid)
}
except (ValueError, OSError):
return None
def _is_process_running(self, pid: int) -> bool:
"""Check if a process with given PID is running."""
try:
# Send signal 0 to check if process exists
os.kill(pid, 0)
return True
except (OSError, ProcessLookupError):
return False
def _remove_stale_lock(self) -> None:
"""Remove stale lock file."""
try:
if self.lock_file.exists():
self.lock_file.unlink()
except OSError:
pass
def format_collision_error(host: str, port: int, lock_info: Optional[dict] = None) -> str:
"""Format a user-friendly error message for port collisions.
Args:
host: Gateway host
port: Gateway port
lock_info: Optional lock information
Returns:
Formatted error message with actionable guidance
"""
port_desc = f"{host}:{port}" if host != "127.0.0.1" else f"port {port}"
lines = [
f"Error: Gateway {port_desc} is already in use.",
""
]
if lock_info and lock_info.get('is_running'):
pid = lock_info['pid']
lines.extend([
f" Another gateway may be running (PID {pid}).",
f" Stop it: praisonai gateway stop",
f" Or use a different port: GATEWAY_PORT={port + 1} praisonai gateway start",
"",
" Only ONE gateway process should poll each Telegram bot token.",
])
else:
lines.extend([
" Another process may be using this port.",
f" Use a different port: praisonai gateway start --port {port + 1}",
f" Or set environment variable: GATEWAY_PORT={port + 1}",
])
return "\n".join(lines)
+67 -12
View File
@@ -29,6 +29,8 @@ from praisonaiagents.gateway import (
logger = logging.getLogger(__name__)
from .unicode_utils import safe_error_message, safe_log_message, extract_root_cause_from_error
@dataclass
class GatewaySession:
@@ -159,7 +161,7 @@ class WebSocketGateway:
logger.warning(f"Config file not found: {config_path}, using defaults")
return cls()
with open(config_path, "r") as f:
with open(config_path, "r", encoding="utf-8") as f:
raw = yaml.safe_load(f) or {}
gateway_config = raw.get(section, {})
@@ -235,6 +237,10 @@ class WebSocketGateway:
except Exception as e:
logger.debug(f"Could not save auth token to .env: {e}")
# Ensure single source of truth: export resolved token so all auth paths use the same secret
if self.config.auth_token:
os.environ["GATEWAY_AUTH_TOKEN"] = self.config.auth_token
# Load allowed origins from environment if not set
if hasattr(self.config, 'allowed_origins') and not self.config.allowed_origins:
env_origins = os.environ.get("GATEWAY_ALLOWED_ORIGINS", "").strip()
@@ -269,6 +275,9 @@ class WebSocketGateway:
# Scheduler tick background task
self._scheduler_task: Optional[asyncio.Task] = None
# PID lock for single-instance enforcement
self._pid_lock: Optional[Any] = None
@property
def is_running(self) -> bool:
@@ -288,6 +297,30 @@ class WebSocketGateway:
logger.warning("Gateway already running")
return
# Preflight port collision check
from .port_utils import check_port_available, GatewayPIDLock, format_collision_error
pid_lock = GatewayPIDLock(host=self._host, port=self._port)
# Check if port is available
port_available, pid_using_port = check_port_available(self._host, self._port)
if not port_available:
# Check if we have a PID lock
lock_info = pid_lock.get_lock_info()
error_msg = format_collision_error(self._host, self._port, lock_info)
logger.error("Gateway startup failed due to port collision")
raise RuntimeError(error_msg)
# Try to acquire PID lock
if not pid_lock.acquire_lock(self._host, self._port):
lock_info = pid_lock.get_lock_info()
error_msg = format_collision_error(self._host, self._port, lock_info)
logger.error("Gateway startup failed - another instance is running")
raise RuntimeError(error_msg)
# Store PID lock for cleanup
self._pid_lock = pid_lock
# Validate bind-aware auth configuration before starting
from .auth import assert_external_bind_safe
self.config.host = self._host
@@ -713,7 +746,15 @@ class WebSocketGateway:
logger.info(f"Gateway started on ws://{self._host}:{self._port}")
await self._server.serve()
try:
await self._server.serve()
except Exception as e:
# Clean up PID lock on any startup failure
if hasattr(self, '_pid_lock') and self._pid_lock:
self._pid_lock.release_lock()
self._pid_lock = None
# Re-raise the original exception
raise
async def stop(self) -> None:
"""Stop the gateway server."""
@@ -737,6 +778,10 @@ class WebSocketGateway:
if self._server:
self._server.should_exit = True
# Release PID lock
if hasattr(self, '_pid_lock'):
self._pid_lock.release_lock()
logger.info("Gateway stopped")
async def _handle_client_message(self, client_id: str, data: Dict[str, Any]) -> None:
@@ -1313,7 +1358,7 @@ class WebSocketGateway:
if not os.path.exists(config_path):
raise FileNotFoundError(f"Gateway config not found: {config_path}")
with open(config_path, "r") as f:
with open(config_path, "r", encoding="utf-8") as f:
raw = yaml.safe_load(f)
if not raw or not isinstance(raw, dict):
@@ -1654,8 +1699,8 @@ class WebSocketGateway:
) -> Any:
"""Create a bot instance for the given channel type."""
# Clone agent to prevent channel-specific settings from leaking between channels
import copy
agent = copy.deepcopy(agent)
# Use clone_for_channel() instead of copy.deepcopy() to avoid RLock issues (fixes #1746)
agent = agent.clone_for_channel()
# Apply smart defaults to agent (same logic as Bot() wrapper)
from praisonai.bots._defaults import apply_bot_smart_defaults
@@ -1859,10 +1904,6 @@ class WebSocketGateway:
if not agent:
agent = bot._agent # fallback to default
# Show typing indicator
if bot.config.typing_indicator:
await update.message.chat.send_action("typing")
# Ack reaction — show processing indicator
ack_ctx = None
if bot._ack.enabled:
@@ -1889,7 +1930,20 @@ class WebSocketGateway:
try:
message_text = await bot._debouncer.debounce(user_id, message_text)
response = await bot._session.chat(agent, user_id, message_text)
# Show typing indicator with renewal during long operation
if bot.config.typing_indicator:
from praisonai.bots._typing_indicator import with_typing_renewal
async def _typing_action():
await update.message.chat.send_action("typing")
response = await with_typing_renewal(
typing_func=_typing_action,
operation_coro=bot._session.chat(agent, user_id, message_text)
)
else:
response = await bot._session.chat(agent, user_id, message_text)
if hasattr(bot, '_send_response_with_media'):
await bot._send_response_with_media(
update.message.chat_id,
@@ -1902,8 +1956,9 @@ class WebSocketGateway:
if ack_ctx:
await bot._ack.done(ack_ctx, react_fn=_tg_react, unreact_fn=_tg_unreact)
except Exception as e:
logger.error(f"Agent error in {name}: {e}")
await update.message.reply_text(f"Error: {str(e)}")
logger.error(f"Agent error in {name}: {safe_log_message(e)}")
user_error = extract_root_cause_from_error(str(e))
await update.message.reply_text(f"Error: {safe_error_message(user_error)}")
async def handle_voice(update: Update, context: Any):
await handle_message(update, context)
@@ -0,0 +1,167 @@
"""
Unicode safety utilities for gateway server on Windows.
Provides safe error message formatting for bot error handlers that may
receive exception text containing Unicode characters that cannot be encoded
with Windows cp1252 default encoding.
"""
import logging
import re
import unicodedata
from typing import Union, Any
logger = logging.getLogger(__name__)
# Module-level constants to avoid recreation on every call
_SYMBOL_MAP: dict = {
'\u26a0': '!', # WARNING SIGN -> !
'\u2713': 'OK', # CHECK MARK -> OK
'\u2717': 'X', # BALLOT X -> X
'\u2192': '->', # RIGHT ARROW -> ->
'\u2190': '<-', # LEFT ARROW -> <-
'\u2022': '*', # BULLET -> *
'\u00ae': '(R)', # REGISTERED SIGN -> (R)
'\u00a9': '(C)', # COPYRIGHT SIGN -> (C)
'\u2026': '...', # HORIZONTAL ELLIPSIS -> ...
'\u201c': '"', # LEFT DOUBLE QUOTATION MARK -> "
'\u201d': '"', # RIGHT DOUBLE QUOTATION MARK -> "
'\u2018': "'", # LEFT SINGLE QUOTATION MARK -> '
'\u2019': "'", # RIGHT SINGLE QUOTATION MARK -> '
'\u2013': '-', # EN DASH -> -
'\u2014': '--', # EM DASH -> --
}
_LATIN1_MAP: dict = {
'\u00e0': 'a', '\u00e1': 'a', '\u00e2': 'a', '\u00e3': 'a',
'\u00e4': 'a', '\u00e5': 'a',
'\u00e8': 'e', '\u00e9': 'e', '\u00ea': 'e', '\u00eb': 'e',
'\u00ec': 'i', '\u00ed': 'i', '\u00ee': 'i', '\u00ef': 'i',
'\u00f2': 'o', '\u00f3': 'o', '\u00f4': 'o', '\u00f5': 'o', '\u00f6': 'o',
'\u00f9': 'u', '\u00fa': 'u', '\u00fb': 'u', '\u00fc': 'u',
'\u00e7': 'c', '\u00f1': 'n',
'\u00c0': 'A', '\u00c1': 'A', '\u00c2': 'A', '\u00c3': 'A',
'\u00c4': 'A', '\u00c5': 'A',
'\u00c8': 'E', '\u00c9': 'E', '\u00ca': 'E', '\u00cb': 'E',
'\u00cc': 'I', '\u00cd': 'I', '\u00ce': 'I', '\u00cf': 'I',
'\u00d2': 'O', '\u00d3': 'O', '\u00d4': 'O', '\u00d5': 'O', '\u00d6': 'O',
'\u00d9': 'U', '\u00da': 'U', '\u00db': 'U', '\u00dc': 'U',
'\u00c7': 'C', '\u00d1': 'N',
}
# Pre-compiled regex patterns for API error extraction
_API_PATTERNS = [
re.compile(r"Error code: (\d+) - (.+)", re.IGNORECASE), # OpenAI format
re.compile(r"HTTP (\d+): (.+)", re.IGNORECASE), # General HTTP error
re.compile(r"exceeded.*quota", re.IGNORECASE), # Quota/billing issues
re.compile(r"insufficient.*quota", re.IGNORECASE), # Quota/billing issues
re.compile(r"Rate limit.*exceeded", re.IGNORECASE), # Rate limiting
re.compile(r"Authentication.*failed", re.IGNORECASE), # Auth issues
]
def safe_error_message(exc: Union[BaseException, str, Any], max_len: int = 500) -> str:
"""Create ASCII-safe error text for Telegram/Discord replies on all platforms.
On Windows, Python's default console encoding is often cp1252 (Windows-1252),
not UTF-8. When an exception contains non-ASCII characters (warning symbols,
emoji, international text from LLM output), the error handler can crash with
a 'charmap' encoding error.
This function sanitizes exception text to prevent secondary encoding failures
while preserving the meaning of the original error.
Args:
exc: Exception, string, or any object to convert to safe error text
max_len: Maximum length of returned message
Returns:
ASCII-safe error string suitable for bot replies
"""
if isinstance(exc, BaseException):
text = str(exc) or type(exc).__name__
else:
text = str(exc)
# Apply specific symbol replacements first to preserve meaning
for unicode_char, ascii_replacement in _SYMBOL_MAP.items():
text = text.replace(unicode_char, ascii_replacement)
# Build ASCII-safe output using module-level maps and NFKD normalization.
# NFKD decomposes e.g. 'e\u0301' (e + combining accent) so the base
# letter survives encode('ascii', errors='ignore').
parts = []
for char in text:
if ord(char) < 128:
parts.append(char)
elif char in _LATIN1_MAP:
parts.append(_LATIN1_MAP[char])
else:
nfkd = unicodedata.normalize('NFKD', char)
ascii_base = nfkd.encode('ascii', errors='ignore').decode('ascii')
parts.append(ascii_base if ascii_base else '?')
safe_text = ''.join(parts)
if len(safe_text) > max_len:
safe_text = safe_text[:max_len - 3] + "..."
return safe_text.strip() or "Error occurred"
def safe_log_message(exc: Union[BaseException, str, Any]) -> str:
"""Create a safe version of an error message for logging.
Preserves Unicode for UTF-8-capable log handlers while replacing
lone surrogates (U+D800-U+DFFF) that cannot be encoded in any standard
encoding.
Args:
exc: Exception, string, or any object to log
Returns:
String safe for logging, with lone surrogates replaced by U+FFFD
"""
if isinstance(exc, BaseException):
text = str(exc) or type(exc).__name__
else:
text = str(exc)
# Replace lone surrogates that cannot be encoded in UTF-8
return text.encode('utf-8', errors='replace').decode('utf-8')
def extract_root_cause_from_error(error_text: str) -> str:
"""Extract the root cause from complex error messages.
When error chaining occurs (e.g., a charmap error masking an API quota
error), try to extract the underlying meaningful error for the user.
The returned string may still contain non-ASCII characters; callers
should pass it through :func:`safe_error_message` before display.
Args:
error_text: Full error message text
Returns:
Simplified error message focusing on root cause (may be non-ASCII)
"""
for pattern in _API_PATTERNS:
match = pattern.search(error_text)
if match:
if len(match.groups()) >= 2:
return f"Error {match.group(1)}: {match.group(2)}"
return match.group(0)
lower = error_text.lower()
if "quota" in lower:
return "API quota exceeded. Check billing."
if "rate limit" in lower:
return "Rate limit exceeded. Try again later."
if "authentication" in lower:
return "Authentication failed. Check API key."
if "timeout" in lower:
return "Request timeout. Try again."
# Return raw error text - caller will sanitize with safe_error_message
return error_text
@@ -2,12 +2,13 @@
from __future__ import annotations
import asyncio
from typing import Any, Optional
async def run_integrated_gateway(
async def _run_integrated_gateway_async(
*,
port: int = 8080,
port: int = 8765,
host: str = "127.0.0.1",
static_dir: Optional[str] = None,
ui_config: Optional[dict[str, Any]] = None,
@@ -25,3 +26,27 @@ async def run_integrated_gateway(
ui_config=ui_config or {},
)
await gateway.start()
def run_integrated_gateway(
*,
port: int = 8765,
host: str = "127.0.0.1",
static_dir: Optional[str] = None,
ui_config: Optional[dict[str, Any]] = None,
**configure_kwargs: Any,
) -> None:
"""Sync wrapper for integrated gateway (for CLI usage)."""
asyncio.run(_run_integrated_gateway_async(
port=port,
host=host,
static_dir=static_dir,
ui_config=ui_config,
**configure_kwargs
))
# Alias for backward compatibility
async def run_integrated_gateway_async(**kwargs) -> None:
"""Async version (for direct usage)."""
await _run_integrated_gateway_async(**kwargs)
@@ -33,6 +33,9 @@ def configure_host(
agent_kwargs: Optional[Dict[str, Any]] = None,
gateway: Any = None,
modules: Optional[Sequence[str]] = None,
style: str = "dashboard",
context_paths: Optional[Sequence[str]] = None,
**kwargs: Any,
) -> None:
"""Apply PraisonAIUI host settings and wire L1 backends (unless legacy mode)."""
global _CONFIGURED
@@ -41,7 +44,7 @@ def configure_host(
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
aiui.set_datastore(PraisonAISessionDataStore())
aiui.set_style("dashboard")
aiui.set_style(style)
aiui.set_branding(title=title, logo=logo)
if pages is not None:
@@ -80,13 +83,21 @@ def configure_host(
if agents:
set_provider(PraisonAIProvider(agents=list(agents), **kwargs))
else:
# Load context files if specified
instructions = kwargs.pop("instructions", "You are a helpful assistant.")
if context_paths:
try:
from praisonai.integration.context_files import load_context_files
context = load_context_files(list(context_paths))
if context:
instructions = f"{instructions}\n\nContext:\n{context}"
except ImportError:
pass # Context files helper is optional
set_provider(
PraisonAIProvider(
name=kwargs.pop("name", "PraisonAI"),
instructions=kwargs.pop(
"instructions",
"You are a helpful assistant.",
),
instructions=instructions,
llm=kwargs.pop(
"llm", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")
),
@@ -95,6 +106,12 @@ def configure_host(
)
setup_bridges()
# Register L3 dashboard pages
try:
from praisonai.integration.pages import workflow_runs, bot_health
except ImportError:
pass # L3 pages are optional
_CONFIGURED = True
@@ -109,15 +126,19 @@ def setup_bridges() -> None:
from praisonai.integration.bridges.usage_bridge import register_usage_sink
sink = register_usage_sink()
except Exception as exc:
except ImportError as exc:
log.debug("usage bridge unavailable: %s", exc)
except Exception as exc:
log.warning("usage bridge unavailable: %s", exc)
try:
from praisonai.integration.bridges.schedules_runner import ensure_schedule_runner
ensure_schedule_runner()
except Exception as exc:
except ImportError as exc:
log.debug("schedule runner unavailable: %s", exc)
except Exception as exc:
log.warning("schedule runner unavailable: %s", exc)
try:
import praisonaiui.backends as backends
@@ -144,8 +165,10 @@ def setup_bridges() -> None:
backends.set_backend("approvals_pending", list_pending_approvals)
backends.set_backend("approvals_policies", get_approval_policies)
except Exception as exc:
except ImportError as exc:
log.debug("aiui backend injection failed: %s", exc)
except Exception as exc:
log.warning("aiui backend injection failed: %s", exc)
def create_host_app():
@@ -0,0 +1,8 @@
"""Optional L3 dashboard pages for PraisonAI integration."""
# Import pages to register them with aiui
try:
from . import workflow_runs, bot_health
except ImportError:
# Pages are optional and may not be available if aiui is not installed
pass
@@ -37,16 +37,122 @@ class DbSessionAdapter:
self._sessions: Set[str] = set()
self._metadata: Dict[str, Dict[str, Any]] = {}
self._history_cache: Dict[str, List[Dict[str, str]]] = {}
self._cleared_sessions: Set[str] = set()
def _conversation_store(self) -> Any:
"""Optional ConversationStore behind the DbAdapter (if present)."""
init = getattr(self._db, "_init_stores", None)
if callable(init):
try:
init()
except Exception as e:
logger.debug("[db_session_adapter] _init_stores failed: %s", e)
for obj in (self._db, getattr(self._db, "_orchestrator", None)):
if obj is None:
continue
conv = getattr(obj, "conversation", None)
if conv is not None:
return conv
conv = getattr(obj, "_conversation_store", None)
if conv is not None:
return conv
return None
def _load_metadata_from_db(self, session_id: str) -> Dict[str, Any]:
"""Load persisted session metadata after a process restart."""
conv = self._conversation_store()
if conv is None or not hasattr(conv, "get_session"):
return {}
try:
session = conv.get_session(session_id)
if session is not None and getattr(session, "metadata", None):
return dict(session.metadata)
except Exception as e:
logger.warning(
"[db_session_adapter] load metadata failed for %s: %s",
session_id,
e,
)
return {}
def _persist_metadata_to_db(self, session_id: str) -> None:
"""Flush in-memory metadata to the conversation session row."""
meta = self._metadata.get(session_id)
if not meta:
return
conv = self._conversation_store()
if conv is None or not hasattr(conv, "get_session"):
return
try:
from ..persistence.conversation.base import ConversationSession
session = conv.get_session(session_id)
if session is None:
conv.create_session(
ConversationSession(session_id=session_id, metadata=dict(meta))
)
else:
session.metadata = {**(session.metadata or {}), **meta}
conv.update_session(session)
except Exception as e:
logger.warning(
"[db_session_adapter] persist metadata failed for %s: %s",
session_id,
e,
)
def _purge_persisted_messages(self, session_id: str) -> None:
"""Remove persisted messages when the session is cleared or deleted."""
conv = self._conversation_store()
if conv is not None and hasattr(conv, "delete_messages"):
try:
conv.delete_messages(session_id, None)
except Exception as e:
logger.warning(
"[db_session_adapter] delete_messages failed for %s: %s",
session_id,
e,
)
def _purge_persisted_metadata(self, session_id: str) -> None:
"""Remove persisted metadata when the session is deleted."""
conv = self._conversation_store()
if conv is None:
return
try:
if hasattr(conv, "delete_session"):
conv.delete_session(session_id)
elif hasattr(conv, "get_session") and hasattr(conv, "update_session"):
session = conv.get_session(session_id)
if session is not None:
session.metadata = {}
conv.update_session(session)
except Exception as e:
logger.warning(
"[db_session_adapter] purge metadata failed for %s: %s",
session_id,
e,
)
def _ensure_session(self, session_id: str, agent_name: str = "ManagedAgent") -> None:
"""Ensure session exists in the DB adapter."""
if session_id not in self._sessions:
skip_history = session_id in self._cleared_sessions
if skip_history:
self._cleared_sessions.discard(session_id)
try:
msgs = self._db.on_agent_start(agent_name, session_id)
if msgs:
if msgs and not skip_history:
self._history_cache[session_id] = [
{"role": m.role, "content": m.content} for m in msgs
]
elif skip_history:
self._history_cache[session_id] = []
if session_id not in self._metadata:
loaded = self._load_metadata_from_db(session_id)
if loaded:
self._metadata[session_id] = loaded
self._sessions.add(session_id)
except Exception as e:
logger.warning("[db_session_adapter] on_agent_start failed: %s", e)
@@ -99,14 +205,25 @@ class DbSessionAdapter:
def clear_session(self, session_id: str) -> bool:
"""Clear all messages from a session."""
self._history_cache.pop(session_id, None)
if session_id in self._sessions:
try:
self._db.on_agent_end(session_id)
except Exception as e:
logger.warning("[db_session_adapter] on_agent_end failed: %s", e)
self._purge_persisted_messages(session_id)
self._history_cache[session_id] = []
self._sessions.discard(session_id)
self._cleared_sessions.add(session_id)
return True
def delete_session(self, session_id: str) -> bool:
"""Delete a session completely."""
self._purge_persisted_messages(session_id)
self._purge_persisted_metadata(session_id)
self._history_cache.pop(session_id, None)
self._metadata.pop(session_id, None)
self._sessions.discard(session_id)
self._cleared_sessions.discard(session_id)
try:
self._db.on_agent_end(session_id)
except Exception:
@@ -126,10 +243,16 @@ class DbSessionAdapter:
if session_id not in self._metadata:
self._metadata[session_id] = {}
self._metadata[session_id].update(metadata)
self._persist_metadata_to_db(session_id)
def get_metadata(self, session_id: str) -> Dict[str, Any]:
"""Retrieve metadata for a session."""
return dict(self._metadata.get(session_id, {}))
if session_id in self._metadata:
return dict(self._metadata[session_id])
loaded = self._load_metadata_from_db(session_id)
if loaded:
self._metadata[session_id] = loaded
return dict(loaded)
# ------------------------------------------------------------------
# Session data access (used by DefaultSessionStore-compatible code)
@@ -486,18 +486,21 @@ class LocalManagedAgent:
store.set_metadata(self._session_id, state)
return
# DefaultSessionStore path — write into SessionData.metadata
# DefaultSessionStore path — merge metadata under file lock (preserves messages)
try:
session = store.get_session(self._session_id)
if session is not None:
if not isinstance(session.metadata, dict):
session.metadata = {}
session.metadata.update(state)
store._save_session(session)
if hasattr(store, "update_session_metadata"):
store.update_session_metadata(self._session_id, **state)
else:
from praisonaiagents.session.store import SessionData
new_session = SessionData(session_id=self._session_id, metadata=state)
store._save_session(new_session)
session = store.get_session(self._session_id)
if session is None:
from praisonaiagents.session.store import SessionData
store._save_session(SessionData(session_id=self._session_id, metadata=state))
else:
if not isinstance(session.metadata, dict):
session.metadata = {}
session.metadata.update(state)
store._save_session(session)
except Exception as e:
logger.debug("[local_managed] _persist_state failed: %s", e)
@@ -0,0 +1,47 @@
"""
Shared Valkey client factory.
Centralises GlideClientSync creation so that ValkeyStateStore,
ValkeyVectorKnowledgeStore, and ValkeyStorageAdapter all use the
same connection logic.
"""
from typing import Optional
try:
from glide_sync import (
GlideClient as GlideClientSync,
GlideClientConfiguration,
NodeAddress,
ServerCredentials,
)
except ImportError:
GlideClientSync = None
GlideClientConfiguration = NodeAddress = ServerCredentials = None
_MISSING_MSG = (
"valkey-glide-sync is required for Valkey support. "
"Install with: pip install 'praisonai[valkey]'"
)
_CLIENT_NAME = "praisonai_persistence_client"
def create_valkey_client(
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
db: int = 0,
):
"""Create and return a GlideClientSync instance."""
if GlideClientSync is None:
raise ImportError(_MISSING_MSG)
addresses = [NodeAddress(host, port)]
creds = ServerCredentials(password=password) if password else None
config = GlideClientConfiguration(
addresses=addresses,
credentials=creds,
database_id=db,
client_name=_CLIENT_NAME,
)
return GlideClientSync.create(config)
@@ -0,0 +1,377 @@
"""
Valkey Vector implementation of KnowledgeStore.
Requires: valkey-glide-sync
Install: pip install 'praisonai[valkey]'
"""
import re
import struct
from typing import Any, Dict, List, Optional
from .base import KnowledgeStore, KnowledgeDocument, validate_identifier
from .._valkey_client import create_valkey_client
try:
from glide_sync import ft
from glide_sync import (
TextField, NumericField, VectorField, VectorFieldAttributesHnsw,
VectorAlgorithm, DistanceMetricType, VectorType,
FtCreateOptions, DataType, FtSearchOptions, ReturnField,
)
from glide_sync import Batch
except ImportError:
ft = None
Batch = None
TextField = NumericField = VectorField = VectorFieldAttributesHnsw = None
VectorAlgorithm = DistanceMetricType = VectorType = None
FtCreateOptions = DataType = FtSearchOptions = ReturnField = None
_SEARCH_SPECIAL_RE = re.compile(r'([,.<>{}\[\]"\'\\:;!@#$%^&*()\-+=~?/| \t])')
_FIELD_NAME_RE = re.compile(r'^[a-zA-Z0-9_]+$')
def _escape_search_value(value: str) -> str:
return _SEARCH_SPECIAL_RE.sub(r'\\\1', str(value))
def _decode(v) -> str:
return v.decode() if isinstance(v, bytes) else v
class ValkeyVectorKnowledgeStore(KnowledgeStore):
"""
Valkey-based knowledge store using ValkeySearch FT commands for vector search.
Requires Valkey with ValkeySearch module.
Example:
store = ValkeyVectorKnowledgeStore(
host="localhost",
port=6379,
)
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
prefix: str = "praison:vec:",
):
self.host = host
self.port = port
self.password = password
self.prefix = prefix
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
)
return self._client
def _index_name(self, collection: str) -> str:
return f"{self.prefix}{collection}:idx"
def _doc_key(self, collection: str, doc_id: str) -> str:
return f"{self.prefix}{collection}:{doc_id}"
def create_collection(
self,
name: str,
dimension: int,
distance: str = "cosine",
metadata: Optional[Dict[str, Any]] = None,
) -> None:
"""Create a new index."""
validate_identifier(name, "collection name")
client = self._get_client()
index_name = self._index_name(name)
try:
dist_enum = {"cosine": DistanceMetricType.COSINE, "euclidean": DistanceMetricType.L2, "dot": DistanceMetricType.IP}.get(distance, DistanceMetricType.COSINE)
schema = [
TextField("content"),
TextField("content_hash"),
NumericField("created_at"),
VectorField("embedding", VectorAlgorithm.HNSW, VectorFieldAttributesHnsw(dimension, dist_enum, VectorType.FLOAT32)),
]
options = FtCreateOptions(DataType.HASH, prefixes=[f"{self.prefix}{name}:"])
ft.create(client, index_name, schema, options)
except Exception as e:
err = str(e)
if "already exists" not in err.lower():
raise RuntimeError(f"Failed to create collection in Valkey: {e}") from e
def delete_collection(self, name: str) -> bool:
"""Delete an index and all documents."""
client = self._get_client()
index_name = self._index_name(name)
try:
ft.dropindex(client, index_name)
except Exception:
return False
# Manually remove orphaned document hashes left behind (no DD flag in ValkeySearch)
pattern = f"{self.prefix}{name}:*"
cursor: bytes = b"0"
while True:
result = client.scan(cursor, match=pattern, count=100)
cursor = result[0]
keys = result[1] or []
if keys:
client.delete(keys)
if not cursor or cursor in (b"0", "0", 0):
break
return True
def collection_exists(self, name: str) -> bool:
"""Check if an index exists."""
client = self._get_client()
index_name = self._index_name(name)
try:
ft.info(client, index_name)
return True
except Exception:
return False
def list_collections(self) -> List[str]:
"""List all collections."""
client = self._get_client()
try:
indexes = ft.list(client)
if not indexes:
return []
result = []
for idx in indexes:
idx_str = _decode(idx)
if idx_str.startswith(self.prefix):
name = idx_str[len(self.prefix):]
if name.endswith(":idx"):
name = name[:-4]
result.append(name)
return result
except Exception:
return []
def insert(
self,
collection: str,
documents: List[KnowledgeDocument],
) -> List[str]:
"""Insert documents using a non-atomic batch (pipeline)."""
client = self._get_client()
ids = []
batch = Batch(is_atomic=False)
for doc in documents:
if doc.embedding is None:
raise ValueError(f"Document {doc.id} has no embedding")
key = self._doc_key(collection, doc.id)
embedding_bytes = struct.pack(f"{len(doc.embedding)}f", *doc.embedding)
batch.hset(key, {
"content": doc.content,
"content_hash": doc.content_hash or "",
"created_at": str(doc.created_at),
"embedding": embedding_bytes,
})
ids.append(doc.id)
try:
client.exec(batch, raise_on_error=True)
except Exception as e:
raise RuntimeError(f"Failed to insert documents into Valkey: {e}") from e
return ids
def upsert(
self,
collection: str,
documents: List[KnowledgeDocument],
) -> List[str]:
"""Insert or update documents."""
return self.insert(collection, documents)
def search(
self,
collection: str,
query_embedding: List[float],
limit: int = 5,
filters: Optional[Dict[str, Any]] = None,
score_threshold: Optional[float] = None,
) -> List[KnowledgeDocument]:
"""Search for similar documents.
Args:
collection: Collection name.
query_embedding: Query vector.
limit: Max results.
filters: Optional dict of field-value equality filters applied as
a ValkeySearch pre-filter (e.g. {"content_hash": "abc"}).
score_threshold: Minimum similarity (1 - distance) to include.
"""
client = self._get_client()
index_name = self._index_name(collection)
packed = struct.pack(f"{len(query_embedding)}f", *query_embedding)
# Build pre-filter expression
if filters:
filter_parts = []
for field, value in filters.items():
if not _FIELD_NAME_RE.match(field):
raise ValueError(f"Invalid filter field name: {field!r}")
if not isinstance(value, str):
raise TypeError(
f"Filter value for {field!r} must be a string, got {type(value).__name__}"
)
escaped = _escape_search_value(value)
filter_parts.append(f"@{field}:({escaped})")
pre_filter = " ".join(filter_parts)
query = f"({pre_filter})=>[KNN {limit} @embedding $vec AS score]"
else:
query = f"*=>[KNN {limit} @embedding $vec AS score]"
try:
options = FtSearchOptions(
return_fields=[
ReturnField("content"),
ReturnField("content_hash"),
ReturnField("created_at"),
ReturnField("score"),
],
params={"vec": packed},
)
raw = ft.search(client, index_name, query, options)
except Exception as e:
raise RuntimeError(f"Failed to search Valkey: {e}") from e
# ft.search returns [count, {doc_id: {field: value, ...}, ...}]
if not raw or len(raw) < 2:
return []
documents = []
key_prefix = f"{self.prefix}{collection}:"
for doc_id_raw, fields in raw[1].items():
doc_id_str = _decode(doc_id_raw)
bare_id = doc_id_str.removeprefix(key_prefix)
field_map: Dict[str, str] = {}
for fname, fval in fields.items():
fname = _decode(fname)
fval = _decode(fval)
field_map[fname] = fval
score = float(field_map.get("score", 0))
similarity = 1 - score
if score_threshold is not None and similarity < score_threshold:
continue
documents.append(KnowledgeDocument(
id=bare_id,
content=field_map.get("content", ""),
embedding=None,
metadata={},
content_hash=field_map.get("content_hash", ""),
created_at=float(field_map.get("created_at", 0)),
))
return documents
def get(
self,
collection: str,
ids: List[str],
) -> List[KnowledgeDocument]:
"""Get documents by IDs using a non-atomic batch (pipeline)."""
client = self._get_client()
keys = [self._doc_key(collection, doc_id) for doc_id in ids]
batch = Batch(is_atomic=False)
for key in keys:
batch.hgetall(key)
try:
results = client.exec(batch, raise_on_error=True)
except Exception as e:
raise RuntimeError(f"Failed to get documents from Valkey: {e}") from e
documents = []
for doc_id, data in zip(ids, results):
if data:
embedding = None
emb_key = b"embedding" if b"embedding" in data else "embedding"
if emb_key in data:
raw_bytes = data[emb_key]
count = len(raw_bytes) // 4
embedding = list(struct.unpack(f"{count}f", raw_bytes))
content = _decode(data.get(b"content", data.get("content", b"")))
content_hash = _decode(data.get(b"content_hash", data.get("content_hash", b"")))
created_at_raw = data.get(b"created_at", data.get("created_at", b"0"))
created_at = float(_decode(created_at_raw))
doc = KnowledgeDocument(
id=doc_id,
content=content,
embedding=embedding,
metadata={},
content_hash=content_hash,
created_at=created_at,
)
documents.append(doc)
return documents
def delete(
self,
collection: str,
ids: Optional[List[str]] = None,
filters: Optional[Dict[str, Any]] = None,
) -> int:
"""Delete documents by IDs. Filter-based deletion is not supported."""
if filters and not ids:
raise NotImplementedError("Filter-based deletion is not supported by the Valkey backend")
if not ids:
return 0
client = self._get_client()
keys = [self._doc_key(collection, doc_id) for doc_id in ids]
try:
deleted = client.delete(keys)
except Exception as e:
raise RuntimeError(f"Failed to delete documents from Valkey: {e}") from e
return deleted
def count(self, collection: str) -> int:
"""Count documents in a collection."""
client = self._get_client()
index_name = self._index_name(collection)
try:
info = ft.info(client, index_name)
# ft.info returns a Mapping; num_docs may be bytes key
for k in (b"num_docs", "num_docs"):
if k in info:
return int(info[k])
return 0
except Exception:
return 0
def close(self) -> None:
"""Close the store."""
if self._client:
try:
self._client.close()
finally:
self._client = None
@@ -198,7 +198,13 @@ def _register_builtin_knowledge_stores(registry: StoreRegistry):
def _redis_vector(url=None, **kwargs):
from .knowledge.redis_vector import RedisVectorKnowledgeStore
return RedisVectorKnowledgeStore(url=url, **kwargs)
def _valkey_vector(url=None, **kwargs):
from .knowledge.valkey_vector import ValkeyVectorKnowledgeStore
# url not used; host/port come from kwargs
kwargs.pop("url", None)
return ValkeyVectorKnowledgeStore(**kwargs)
def _cassandra(url=None, **kwargs):
kwargs.pop("url", None) # Cassandra doesn't use url parameter
from .knowledge.cassandra import CassandraKnowledgeStore
@@ -258,9 +264,10 @@ def _register_builtin_knowledge_stores(registry: StoreRegistry):
registry.register("milvus", _milvus)
registry.register("pgvector", _pgvector)
registry.register("redis", _redis_vector)
registry.register("valkey", _valkey_vector)
registry.register("cassandra", _cassandra)
registry.register("clickhouse", _clickhouse)
registry.register("mongodb_vector", _mongodb_vector,
registry.register("mongodb_vector", _mongodb_vector,
aliases=("mongodb_atlas", "mongo_vector"))
# Register missing backends that were supported in old factory
@@ -286,7 +293,12 @@ def _register_builtin_state_stores(registry: StoreRegistry):
def _redis(url=None, **kwargs):
from .state.redis import RedisStateStore
return RedisStateStore(url=url, **kwargs)
def _valkey(url=None, **kwargs):
from .state.valkey import ValkeyStateStore
kwargs.pop("url", None)
return ValkeyStateStore(**kwargs)
def _dynamodb(url=None, **kwargs):
kwargs.pop("url", None) # DynamoDB doesn't use url parameter
from .state.dynamodb import DynamoDBStateStore
@@ -322,10 +334,11 @@ def _register_builtin_state_stores(registry: StoreRegistry):
return GCSStateStore(bucket_name=bucket, **kwargs)
registry.register("redis", _redis)
registry.register("valkey", _valkey)
registry.register("dynamodb", _dynamodb)
registry.register("firestore", _firestore)
registry.register("mongodb", _mongodb)
registry.register("async_mongodb", _async_mongodb,
registry.register("async_mongodb", _async_mongodb,
aliases=("motor", "mongodb_async"))
registry.register("upstash", _upstash)
registry.register("memory", _memory)
@@ -0,0 +1,230 @@
"""
Valkey implementation of StateStore.
Requires: valkey-glide-sync
Install: pip install 'praisonai[valkey]'
"""
import json
from typing import Any, Dict, List, Optional
from .base import StateStore
from .._valkey_client import create_valkey_client
try:
from glide_sync import ExpirySet, ExpiryType
except ImportError:
ExpirySet = None
ExpiryType = None
class ValkeyStateStore(StateStore):
"""
Valkey-based state store for fast key-value operations.
Example:
store = ValkeyStateStore(
host="localhost",
port=6379,
)
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
password: Optional[str] = None,
prefix: str = "praison:",
db: int = 0,
):
self.host = host
self.port = port
self.password = password
self.prefix = prefix
self.db = db
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
db=self.db,
)
return self._client
def _key(self, key: str) -> str:
"""Add prefix to key."""
return f"{self.prefix}{key}"
def get(self, key: str) -> Optional[Any]:
"""Get a value by key."""
client = self._get_client()
try:
value = client.get(self._key(key))
except Exception as e:
raise RuntimeError(f"Failed to get key from Valkey: {e}") from e
if value is None:
return None
if isinstance(value, bytes):
value = value.decode()
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
"""Set a value with optional TTL."""
client = self._get_client()
if not isinstance(value, str):
value = json.dumps(value)
try:
if ttl is not None:
client.set(self._key(key), value, expiry=ExpirySet(ExpiryType.SEC, ttl))
else:
client.set(self._key(key), value)
except Exception as e:
raise RuntimeError(f"Failed to set key in Valkey: {e}") from e
def delete(self, key: str) -> bool:
"""Delete a key."""
client = self._get_client()
try:
return client.delete([self._key(key)]) > 0
except Exception as e:
raise RuntimeError(f"Failed to delete key from Valkey: {e}") from e
def exists(self, key: str) -> bool:
"""Check if a key exists."""
client = self._get_client()
try:
return client.exists([self._key(key)]) > 0
except Exception as e:
raise RuntimeError(f"Failed to check key existence in Valkey: {e}") from e
def keys(self, pattern: str = "*") -> List[str]:
"""List keys matching pattern."""
client = self._get_client()
full_pattern = self._key(pattern)
try:
cursor: bytes = b"0"
all_keys = []
while True:
result = client.scan(cursor, match=full_pattern, count=100)
cursor = result[0]
all_keys.extend(result[1] or [])
if not cursor or cursor in (b"0", "0"):
break
prefix_len = len(self.prefix)
result_keys = []
for k in all_keys:
k_str = k.decode() if isinstance(k, bytes) else k
result_keys.append(k_str[prefix_len:] if k_str.startswith(self.prefix) else k_str)
return result_keys
except Exception as e:
raise RuntimeError(f"Failed to list keys from Valkey: {e}") from e
def ttl(self, key: str) -> Optional[int]:
"""Get remaining TTL in seconds."""
client = self._get_client()
try:
result = client.ttl(self._key(key))
except Exception as e:
raise RuntimeError(f"Failed to get TTL from Valkey: {e}") from e
if result is None or result < 0:
return None
return int(result)
def expire(self, key: str, ttl: int) -> bool:
"""Set TTL on existing key."""
client = self._get_client()
try:
return bool(client.expire(self._key(key), ttl))
except Exception as e:
raise RuntimeError(f"Failed to set expire in Valkey: {e}") from e
def hget(self, key: str, field: str) -> Optional[Any]:
"""Get a field from a hash."""
client = self._get_client()
try:
value = client.hget(self._key(key), field)
except Exception as e:
raise RuntimeError(f"Failed to hget from Valkey: {e}") from e
if value is None:
return None
if isinstance(value, bytes):
value = value.decode()
try:
return json.loads(value)
except json.JSONDecodeError:
return value
def hset(self, key: str, field: str, value: Any) -> None:
"""Set a field in a hash."""
client = self._get_client()
if not isinstance(value, str):
value = json.dumps(value)
try:
client.hset(self._key(key), {field: value})
except Exception as e:
raise RuntimeError(f"Failed to hset in Valkey: {e}") from e
def hgetall(self, key: str) -> Dict[str, Any]:
"""Get all fields from a hash."""
client = self._get_client()
try:
data = client.hgetall(self._key(key))
except Exception as e:
raise RuntimeError(f"Failed to hgetall from Valkey: {e}") from e
result = {}
if not data:
return result
for k, v in data.items():
k_str = k.decode() if isinstance(k, bytes) else k
v_str = v.decode() if isinstance(v, bytes) else v
try:
result[k_str] = json.loads(v_str)
except (json.JSONDecodeError, TypeError):
result[k_str] = v_str
return result
def hdel(self, key: str, *fields: str) -> int:
"""Delete fields from a hash."""
if not fields:
return 0
client = self._get_client()
try:
return client.hdel(self._key(key), list(fields))
except Exception as e:
raise RuntimeError(f"Failed to hdel from Valkey: {e}") from e
def incr(self, key: str, amount: int = 1) -> int:
"""Increment a counter."""
client = self._get_client()
try:
return client.incrby(self._key(key), amount)
except Exception as e:
raise RuntimeError(f"Failed to increment key in Valkey: {e}") from e
def decr(self, key: str, amount: int = 1) -> int:
"""Decrement a counter."""
client = self._get_client()
try:
return client.decrby(self._key(key), amount)
except Exception as e:
raise RuntimeError(f"Failed to decrement key in Valkey: {e}") from e
def close(self) -> None:
"""Close the store."""
if self._client:
try:
self._client.close()
finally:
self._client = None
@@ -0,0 +1,492 @@
"""
Tests for ValkeyVectorKnowledgeStore, ValkeyStateStore, and registry integration.
Tests cover:
- ValkeyVectorKnowledgeStore collection and document operations (mocked glide client)
- ValkeyStateStore key-value operations (mocked glide client)
- KNOWLEDGE_STORES and STATE_STORES registry entries for "valkey"
"""
import json
import struct
from unittest.mock import MagicMock, patch
from praisonai.persistence.knowledge.base import KnowledgeDocument
from praisonai.persistence.registry import KNOWLEDGE_STORES, STATE_STORES
def _make_knowledge_store():
"""Return a ValkeyVectorKnowledgeStore with a pre-injected MagicMock client."""
from praisonai.persistence.knowledge.valkey_vector import ValkeyVectorKnowledgeStore
store = ValkeyVectorKnowledgeStore.__new__(ValkeyVectorKnowledgeStore)
store.host = "localhost"
store.port = 6379
store.password = None
store.prefix = "praison:vec:"
mock_client = MagicMock()
store._client = mock_client
store._get_client = lambda: mock_client
return store, mock_client
def _make_state_store():
"""Return a ValkeyStateStore with a pre-injected MagicMock client."""
from praisonai.persistence.state.valkey import ValkeyStateStore
store = ValkeyStateStore.__new__(ValkeyStateStore)
store.host = "localhost"
store.port = 6379
store.password = None
store.prefix = "praison:"
store.db = 0
mock_client = MagicMock()
store._client = mock_client
store._get_client = lambda: mock_client
return store, mock_client
class TestValkeyVectorKnowledgeStore:
"""Tests for ValkeyVectorKnowledgeStore."""
_FT_MODULE = "praisonai.persistence.knowledge.valkey_vector.ft"
def _mock_ft(self, **method_returns):
"""Return a mock ft module with configured method returns."""
mock_ft = MagicMock()
for name, val in method_returns.items():
if isinstance(val, Exception):
getattr(mock_ft, name).side_effect = val
else:
getattr(mock_ft, name).return_value = val
return mock_ft
def test_create_collection(self):
"""Test create_collection calls ft.create with HNSW schema."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(create=b"OK")
with patch(self._FT_MODULE, mock_ft):
store.create_collection("docs", dimension=4)
mock_ft.create.assert_called_once()
assert mock_ft.create.call_args[0][1] == "praison:vec:docs:idx"
def test_create_collection_already_exists(self):
"""Test create_collection silently ignores 'already exists' error."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(create=Exception("already exists"))
with patch(self._FT_MODULE, mock_ft):
store.create_collection("docs", dimension=4) # should not raise
def test_delete_collection(self):
"""Test delete_collection calls ft.dropindex and returns True on success."""
store, mock_client = _make_knowledge_store()
mock_ft = self._mock_ft(dropindex=b"OK")
mock_client.scan.return_value = [b"0", []]
with patch(self._FT_MODULE, mock_ft):
result = store.delete_collection("docs")
assert result is True
mock_ft.dropindex.assert_called_once()
def test_collection_exists_true(self):
"""Test collection_exists returns True when ft.info succeeds."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(info={b"index_name": b"praison:vec:docs:idx"})
with patch(self._FT_MODULE, mock_ft):
result = store.collection_exists("docs")
assert result is True
def test_collection_exists_false(self):
"""Test collection_exists returns False when ft.info raises an exception."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(info=Exception("Unknown index name"))
with patch(self._FT_MODULE, mock_ft):
result = store.collection_exists("docs")
assert result is False
def test_list_collections(self):
"""Test list_collections returns only names matching the prefix, stripped."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(list=[b"praison:vec:docs:idx", b"other:idx"])
with patch(self._FT_MODULE, mock_ft):
result = store.list_collections()
assert "docs" in result
assert all("other" not in name for name in result)
def test_insert(self):
"""Test insert uses Batch pipeline and calls client.exec."""
store, mock_client = _make_knowledge_store()
mock_client.exec.return_value = [4]
embedding = [0.1, 0.2, 0.3, 0.4]
doc = KnowledgeDocument(id="doc1", content="hello", embedding=embedding)
ids = store.insert("docs", [doc])
mock_client.exec.assert_called_once()
assert ids == ["doc1"]
def test_upsert_delegates_to_insert(self):
"""Test upsert calls insert (same path)."""
store, mock_client = _make_knowledge_store()
mock_client.exec.return_value = [4]
doc = KnowledgeDocument(id="doc1", content="hello", embedding=[0.1, 0.2, 0.3, 0.4])
store.upsert("docs", [doc])
mock_client.exec.assert_called_once()
def test_search(self):
"""Test search calls ft.search and returns a list of KnowledgeDocument."""
store, _ = _make_knowledge_store()
ft_result = [1, {
b"praison:vec:docs:doc1": {
b"content": b"hello",
b"content_hash": b"abc",
b"created_at": b"1234.0",
b"score": b"0.1",
}
}]
mock_ft = self._mock_ft(search=ft_result)
with patch(self._FT_MODULE, mock_ft):
results = store.search("docs", query_embedding=[0.1, 0.2, 0.3, 0.4], limit=5)
assert isinstance(results, list)
assert len(results) == 1
assert isinstance(results[0], KnowledgeDocument)
assert results[0].id == "doc1"
mock_ft.search.assert_called_once()
def test_search_with_filters(self):
"""Test search with filters builds a pre-filter KNN query."""
store, _ = _make_knowledge_store()
ft_result = [1, {
b"praison:vec:docs:doc1": {
b"content": b"hello",
b"content_hash": b"abc",
b"created_at": b"1234.0",
b"score": b"0.1",
}
}]
mock_ft = self._mock_ft(search=ft_result)
with patch(self._FT_MODULE, mock_ft):
results = store.search(
"docs",
query_embedding=[0.1, 0.2, 0.3, 0.4],
limit=5,
filters={"content_hash": "abc"},
)
assert len(results) == 1
query_arg = mock_ft.search.call_args[0][2]
assert "KNN" in query_arg
assert query_arg.startswith("(")
def test_search_score_threshold_excludes_low_similarity(self):
"""Test search excludes documents whose similarity is below score_threshold."""
store, _ = _make_knowledge_store()
ft_result = [2, {
b"praison:vec:docs:doc1": {
b"content": b"close",
b"content_hash": b"",
b"created_at": b"1.0",
b"score": b"0.05", # similarity = 0.95 — above threshold
},
b"praison:vec:docs:doc2": {
b"content": b"far",
b"content_hash": b"",
b"created_at": b"2.0",
b"score": b"0.8", # similarity = 0.2 — below threshold
},
}]
mock_ft = self._mock_ft(search=ft_result)
with patch(self._FT_MODULE, mock_ft):
results = store.search(
"docs",
query_embedding=[0.1, 0.2, 0.3, 0.4],
limit=5,
score_threshold=0.5,
)
assert len(results) == 1
assert results[0].id == "doc1"
def test_search_empty_result(self):
"""Test search returns an empty list when ft.search returns no documents."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(search=[])
with patch(self._FT_MODULE, mock_ft):
results = store.search("docs", query_embedding=[0.1, 0.2, 0.3, 0.4])
assert results == []
def test_get(self):
"""Test get uses Batch pipeline and returns a KnowledgeDocument."""
store, mock_client = _make_knowledge_store()
embedding = [0.1, 0.2, 0.3, 0.4]
embedding_bytes = struct.pack(f"{len(embedding)}f", *embedding)
mock_client.exec.return_value = [{
b"content": b"hello",
b"content_hash": b"abc",
b"created_at": b"1234.0",
b"embedding": embedding_bytes,
}]
results = store.get("docs", ["doc1"])
assert len(results) == 1
assert isinstance(results[0], KnowledgeDocument)
assert results[0].id == "doc1"
mock_client.exec.assert_called_once()
def test_delete(self):
"""Test delete calls client.delete and returns count deleted."""
store, mock_client = _make_knowledge_store()
mock_client.delete.return_value = 2
result = store.delete("docs", ids=["doc1", "doc2"])
assert result == 2
def test_count(self):
"""Test count calls ft.info and returns integer document count."""
store, _ = _make_knowledge_store()
mock_ft = self._mock_ft(info={b"num_docs": 7, b"index_name": b"praison:vec:docs:idx"})
with patch(self._FT_MODULE, mock_ft):
result = store.count("docs")
assert isinstance(result, int)
assert result == 7
def test_close_closes_client(self):
"""Test close() calls client.close() and clears the reference."""
store, mock_client = _make_knowledge_store()
store.close()
mock_client.close.assert_called_once()
assert store._client is None
class TestValkeyStateStore:
"""Tests for ValkeyStateStore."""
def test_get_existing(self):
"""Test get returns a dict when client.get returns JSON bytes."""
store, mock_client = _make_state_store()
data = {"result": "ok"}
mock_client.get.return_value = json.dumps(data).encode("utf-8")
result = store.get("mykey")
mock_client.get.assert_called_once_with("praison:mykey")
assert result == data
def test_get_missing(self):
"""Test get returns None when client.get returns None."""
store, mock_client = _make_state_store()
mock_client.get.return_value = None
result = store.get("missing")
assert result is None
def test_set_no_ttl(self):
"""Test set calls client.set with prefixed key when no TTL is given."""
store, mock_client = _make_state_store()
mock_client.set.return_value = b"OK"
store.set("mykey", {"x": 1})
mock_client.set.assert_called_once()
call_args = mock_client.set.call_args[0]
assert call_args[0] == "praison:mykey"
def test_set_with_ttl(self):
"""Test set with TTL passes SetOptions to client.set (ImportError/RuntimeError tolerated)."""
store, mock_client = _make_state_store()
mock_client.set.return_value = b"OK"
# glide's ExpirySet/ExpiryType are lazy-imported inside set(); without a live
# glide install this raises ImportError — both paths are valid in CI.
try:
store.set("ttlkey", {"x": 1}, ttl=300)
except (ImportError, RuntimeError):
pass
def test_delete_existing(self):
"""Test delete returns True when client.delete returns 1."""
store, mock_client = _make_state_store()
mock_client.delete.return_value = 1
result = store.delete("mykey")
mock_client.delete.assert_called_once_with(["praison:mykey"])
assert result is True
def test_delete_missing(self):
"""Test delete returns False when client.delete returns 0."""
store, mock_client = _make_state_store()
mock_client.delete.return_value = 0
result = store.delete("absent")
assert result is False
def test_exists_true(self):
"""Test exists returns True when client.exists returns 1."""
store, mock_client = _make_state_store()
mock_client.exists.return_value = 1
assert store.exists("mykey") is True
mock_client.exists.assert_called_once_with(["praison:mykey"])
def test_exists_false(self):
"""Test exists returns False when client.exists returns 0."""
store, mock_client = _make_state_store()
mock_client.exists.return_value = 0
assert store.exists("absent") is False
def test_keys(self):
"""Test keys returns stripped key names via client.scan."""
store, mock_client = _make_state_store()
# scan() returns [cursor, [keys]]; cursor b"0" signals end of iteration
mock_client.scan.return_value = [b"0", [b"praison:key1", b"praison:key2"]]
result = store.keys()
mock_client.scan.assert_called_once()
assert result == ["key1", "key2"]
def test_ttl_with_value(self):
"""Test ttl returns seconds remaining when client.ttl returns a positive value."""
store, mock_client = _make_state_store()
mock_client.ttl.return_value = 30
result = store.ttl("mykey")
assert result == 30
def test_ttl_no_ttl(self):
"""Test ttl returns None when client.ttl returns -1 (no expiry)."""
store, mock_client = _make_state_store()
mock_client.ttl.return_value = -1
result = store.ttl("mykey")
assert result is None
def test_expire(self):
"""Test expire calls client.expire and returns its result."""
store, mock_client = _make_state_store()
mock_client.expire.return_value = True
result = store.expire("mykey", 600)
mock_client.expire.assert_called_once_with("praison:mykey", 600)
assert result is True
def test_hget(self):
"""Test hget returns a parsed dict when client.hget returns JSON bytes."""
store, mock_client = _make_state_store()
data = {"field_val": 42}
mock_client.hget.return_value = json.dumps(data).encode("utf-8")
result = store.hget("myhash", "field1")
mock_client.hget.assert_called_once_with("praison:myhash", "field1")
assert result == data
def test_hset(self):
"""Test hset calls client.hset with the prefixed key and a mapping dict."""
store, mock_client = _make_state_store()
mock_client.hset.return_value = 1
store.hset("myhash", "field1", "value1")
mock_client.hset.assert_called_once()
call_args = mock_client.hset.call_args[0]
assert call_args[0] == "praison:myhash"
assert isinstance(call_args[1], dict)
assert "field1" in call_args[1]
def test_hgetall(self):
"""Test hgetall returns a decoded dict when client.hgetall returns bytes."""
store, mock_client = _make_state_store()
mock_client.hgetall.return_value = {
b"field1": b'"value1"',
b"field2": b"42",
}
result = store.hgetall("myhash")
mock_client.hgetall.assert_called_once_with("praison:myhash")
assert "field1" in result
assert "field2" in result
def test_hdel(self):
"""Test hdel calls client.hdel and returns the count of deleted fields."""
store, mock_client = _make_state_store()
mock_client.hdel.return_value = 2
result = store.hdel("myhash", "field1", "field2")
assert result == 2
def test_incr(self):
"""Test incr calls client.incrby and returns the new value."""
store, mock_client = _make_state_store()
mock_client.incrby.return_value = 5
result = store.incr("counter")
assert result == 5
mock_client.incrby.assert_called_once_with("praison:counter", 1)
def test_decr(self):
"""Test decr calls client.decrby and returns the new value."""
store, mock_client = _make_state_store()
mock_client.decrby.return_value = 3
result = store.decr("counter")
assert result == 3
mock_client.decrby.assert_called_once_with("praison:counter", 1)
def test_close_closes_client(self):
"""Test close() calls client.close() and clears the reference."""
store, mock_client = _make_state_store()
store.close()
mock_client.close.assert_called_once()
assert store._client is None
class TestRegistryValkey:
"""Tests for Valkey backend registration in store registries."""
def test_knowledge_store_valkey_registered(self):
"""Test KNOWLEDGE_STORES registry contains 'valkey'."""
assert "valkey" in KNOWLEDGE_STORES.list_registered()
def test_state_store_valkey_registered(self):
"""Test STATE_STORES registry contains 'valkey'."""
assert "valkey" in STATE_STORES.list_registered()
+15 -1
View File
@@ -256,8 +256,11 @@ class DockerSandbox:
cmd_parts = shlex.split(command)
cmd_str = " ".join(shlex.quote(part) for part in cmd_parts)
# Generate container name for timeout cleanup
container_name = f"praisonai-{execution_id}"
docker_cmd = [
"docker", "run", "--rm",
"docker", "run", "--rm", "--name", container_name,
"--memory", f"{limits.memory_mb}m",
"--cpus", str(limits.cpu_percent / 100),
]
@@ -302,6 +305,17 @@ class DockerSandbox:
completed_at=completed_at,
)
except asyncio.TimeoutError:
# Kill the container, not just the client
try:
kill_proc = await asyncio.create_subprocess_exec(
"docker", "kill", container_name,
stdout=asyncio.subprocess.DEVNULL,
stderr=asyncio.subprocess.DEVNULL
)
await kill_proc.wait()
except Exception:
pass # Container may already be gone
proc.kill()
await proc.wait()
+17 -7
View File
@@ -165,10 +165,10 @@ class SSHSandbox:
execution_id = str(uuid.uuid4())
started_at = time.time()
# Create temporary file for code
remote_file = f"{self.working_dir}/exec_{execution_id}.{self._get_file_extension(language)}"
try:
# Create temporary file for code
remote_file = f"{self.working_dir}/exec_{execution_id}.{self._get_file_extension(language)}"
# Write code to remote file
await self.write_file(remote_file, code)
@@ -182,9 +182,6 @@ class SSHSandbox:
working_dir or self.working_dir
)
# Cleanup temp file
await self._connection.run(f"rm -f {shlex.quote(remote_file)}")
completed_at = time.time()
duration = completed_at - started_at
@@ -220,6 +217,12 @@ class SSHSandbox:
duration_seconds=time.time() - started_at,
metadata={"host": self.host, "language": language}
)
finally:
# Always cleanup temp file, never mask the real result/error with cleanup failure
try:
await self._connection.run(f"rm -f {shlex.quote(remote_file)}")
except Exception:
pass # Don't propagate cleanup errors
async def execute_file(
self,
@@ -525,13 +528,20 @@ class SSHSandbox:
"""Run command with resource limits."""
# Change to working directory and execute command
# Note: The command has already been processed for shell safety by the caller
full_command = f"cd {shlex.quote(working_dir)} && {command}"
# Set timeout
timeout = None
if limits and limits.timeout_seconds > 0:
timeout = limits.timeout_seconds
# Build command with remote-side timeout if needed
if timeout:
# Use timeout command to ensure remote process terminates
timeout_cmd = f"timeout {int(timeout)} sh -c {shlex.quote(command)}"
full_command = f"cd {shlex.quote(working_dir)} && {timeout_cmd}"
else:
full_command = f"cd {shlex.quote(working_dir)} && {command}"
# Execute with timeout
if timeout:
result = await asyncio.wait_for(
+13 -12
View File
@@ -30,20 +30,21 @@ def __getattr__(name):
from .agent_scheduler import create_agent_scheduler
return create_agent_scheduler
elif name == 'create_scheduler':
# Return a factory function that creates a mock scheduler for testing
def _create_scheduler(provider='gcp', **kwargs):
from .agent_scheduler import AgentScheduler
# Create a mock agent and task for testing
class MockAgent:
pass
return AgentScheduler(MockAgent(), "test task")
return _create_scheduler
# Return the real deployment scheduler factory
from .deployment import create_deployment_scheduler
return create_deployment_scheduler
elif name == 'DeploymentScheduler':
from .agent_scheduler import AgentScheduler
return AgentScheduler
from .deployment import DeploymentScheduler
return DeploymentScheduler
elif name == 'create_deployment_scheduler':
from .agent_scheduler import create_agent_scheduler
return create_agent_scheduler
from .deployment import create_deployment_scheduler
return create_deployment_scheduler
elif name == 'AsyncAgentScheduler':
from .async_agent_scheduler import AsyncAgentScheduler
return AsyncAgentScheduler
elif name == 'create_async_agent_scheduler':
from .async_agent_scheduler import create_async_agent_scheduler
return create_async_agent_scheduler
elif name in ('ScheduledAgentExecutor', 'JobResult'):
from .executor import ScheduledAgentExecutor, JobResult
return ScheduledAgentExecutor if name == 'ScheduledAgentExecutor' else JobResult
@@ -9,6 +9,7 @@ import threading
import logging
from datetime import datetime
from typing import Optional, Dict, Any, Callable
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
from .base import ScheduleParser, PraisonAgentExecutor
from .shared import backoff_delay
@@ -73,6 +74,7 @@ class AgentScheduler:
self._failure_count = 0
self._total_cost = 0.0
self._start_time = None
self._stats_lock = threading.Lock()
def start(
self,
@@ -160,17 +162,18 @@ class AgentScheduler:
Returns:
Dictionary with execution stats including cost
"""
runtime = (datetime.now() - self._start_time).total_seconds() if self._start_time else 0
return {
"is_running": self.is_running,
"total_executions": self._execution_count,
"successful_executions": self._success_count,
"failed_executions": self._failure_count,
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
"total_cost_usd": round(self._total_cost, 4),
"runtime_seconds": round(runtime, 1),
"cost_per_execution": round(self._total_cost / self._execution_count, 4) if self._execution_count > 0 else 0
}
with self._stats_lock:
runtime = (datetime.now() - self._start_time).total_seconds() if self._start_time else 0
return {
"is_running": self.is_running,
"total_executions": self._execution_count,
"successful_executions": self._success_count,
"failed_executions": self._failure_count,
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
"total_cost_usd": round(self._total_cost, 4),
"runtime_seconds": round(runtime, 1),
"cost_per_execution": round(self._total_cost / self._execution_count, 4) if self._execution_count > 0 else 0
}
def _update_state_if_daemon(self):
"""Update state file with execution stats if running as daemon."""
@@ -232,7 +235,8 @@ class AgentScheduler:
def _execute_with_retry(self, max_retries: int):
"""Execute agent with retry logic and timeout."""
self._execution_count += 1
with self._stats_lock:
self._execution_count += 1
success = False
result = None
@@ -242,21 +246,16 @@ class AgentScheduler:
# Execute with timeout if specified
if self.timeout:
import signal
def timeout_handler(signum, frame):
raise TimeoutError(f"Execution exceeded {self.timeout}s timeout")
# Set timeout alarm (Unix only)
executor = ThreadPoolExecutor(max_workers=1)
future = executor.submit(self._executor.execute, self.task)
try:
signal.signal(signal.SIGALRM, timeout_handler)
signal.alarm(self.timeout)
result = self._executor.execute(self.task)
signal.alarm(0) # Cancel alarm
except AttributeError:
# Windows doesn't support SIGALRM, use threading.Timer fallback
logger.warning("Timeout not supported on this platform, executing without timeout")
result = self._executor.execute(self.task)
result = future.result(timeout=self.timeout)
except FuturesTimeout as e:
future.cancel()
executor.shutdown(wait=False, cancel_futures=True)
raise TimeoutError(f"Execution exceeded {self.timeout}s timeout") from e
else:
executor.shutdown(wait=False, cancel_futures=True)
else:
result = self._executor.execute(self.task)
@@ -268,10 +267,10 @@ class AgentScheduler:
# Estimate cost (rough: ~$0.0001 per execution for gpt-4o-mini)
estimated_cost = 0.0001 # Base cost estimate
self._total_cost += estimated_cost
with self._stats_lock:
self._total_cost += estimated_cost
self._success_count += 1
logger.debug(f"Estimated cost this run: ${estimated_cost:.4f}, Total: ${self._total_cost:.4f}")
self._success_count += 1
success = True
if self.on_success:
@@ -299,7 +298,8 @@ class AgentScheduler:
return
if not success:
self._failure_count += 1
with self._stats_lock:
self._failure_count += 1
logger.error(f"Agent execution failed after {max_retries} attempts")
if self.on_failure:
@@ -0,0 +1,469 @@
"""Async Agent Scheduler - Canonical implementation moved from root module.
This is the canonical implementation of async agent scheduling for PraisonAI.
The old location (praisonai.async_agent_scheduler) is now a backward-compatible re-export.
"""
import asyncio
import logging
import threading
from datetime import datetime
from typing import Optional, Dict, Any, Callable, Union
from abc import ABC, abstractmethod
from .shared import ScheduleParser, backoff_delay, safe_call
logger = logging.getLogger(__name__)
class AsyncAgentExecutorInterface(ABC):
"""Abstract interface for async agent execution."""
@abstractmethod
async def execute(self, task: str) -> Any:
"""Execute the agent with given task asynchronously."""
pass
class AsyncPraisonAgentExecutor(AsyncAgentExecutorInterface):
"""Async executor for PraisonAI agents."""
def __init__(self, agent):
"""
Initialize executor with a PraisonAI agent.
Args:
agent: PraisonAI Agent instance
"""
self.agent = agent
async def execute(self, task: str) -> Any:
"""
Execute the agent with the given task.
Args:
task: Task description to execute
Returns:
Agent execution result
"""
try:
# Check if agent has async support
if hasattr(self.agent, 'astart'):
result = await self.agent.astart(task)
elif hasattr(self.agent, 'start'):
# Wrap sync call in executor
result = await asyncio.to_thread(self.agent.start, task)
else:
raise AttributeError("Agent must have either 'start' or 'astart' method")
return result
except Exception as e:
logger.error(f"Async agent execution failed: {e}")
raise
class AsyncAgentScheduler:
"""
Async-native scheduler for running PraisonAI agents periodically.
Features:
- Proper async/await execution
- Cancellation support
- No global state pollution
- Native async coordination
- Timeout support (TODO: needs porting from sync version)
- Budget tracking (TODO: needs porting from sync version)
- YAML/recipe constructors (TODO: needs porting from sync version)
Example:
scheduler = AsyncAgentScheduler(agent, task="Check news")
await scheduler.start(schedule_expr="hourly")
await asyncio.sleep(3600) # Let it run
await scheduler.stop()
"""
def __init__(
self,
agent,
task: str,
config: Optional[Dict[str, Any]] = None,
on_success: Optional[Callable] = None,
on_failure: Optional[Callable] = None,
# TODO: Add these missing features from sync version:
# timeout: Optional[int] = None,
# max_cost: Optional[float] = 1.00
):
"""
Initialize async agent scheduler.
Args:
agent: PraisonAI Agent instance
task: Task description to execute
config: Optional configuration dict
on_success: Callback function on successful execution
on_failure: Callback function on failed execution
# TODO: Add timeout and max_cost parameters
"""
self.agent = agent
self.task = task
self.config = config or {}
self.on_success = on_success
self.on_failure = on_failure
# TODO: Add these missing features from sync version:
# self.timeout = timeout
# self.max_cost = max_cost
# self._total_cost = 0.0
self.is_running = False
self._task: Optional[asyncio.Task] = None
self._executor = AsyncPraisonAgentExecutor(agent)
self._execution_count = 0
self._success_count = 0
self._failure_count = 0
# Sync lock for async primitives creation and bound loop tracking
self._primitives_lock = threading.Lock()
self._stop_event: Optional[asyncio.Event] = None
self._stats_lock: Optional[asyncio.Lock] = None
self._bound_loop: Optional[asyncio.AbstractEventLoop] = None
def _ensure_async_primitives(self) -> None:
"""Create async primitives if they don't exist yet.
Thread-safe and loop-aware: primitives are bound to the current running loop.
If called from a different loop, new primitives are created.
"""
loop = asyncio.get_running_loop() # must be called from a coroutine
with self._primitives_lock:
if self._bound_loop is not loop:
self._stop_event = asyncio.Event()
self._stats_lock = asyncio.Lock()
self._bound_loop = loop
async def start(
self,
schedule_expr: str,
max_retries: int = 3,
run_immediately: bool = False
) -> bool:
"""
Start scheduled agent execution.
Args:
schedule_expr: Schedule expression (e.g., "hourly", "*/6h", "3600")
max_retries: Maximum retry attempts on failure
run_immediately: If True, run agent immediately before starting schedule
Returns:
True if scheduler started successfully
"""
if self.is_running:
logger.warning("Async scheduler is already running")
return False
try:
interval = ScheduleParser.parse(schedule_expr)
self.is_running = True
self._ensure_async_primitives() # bind to the loop start() runs on
self._stop_event.clear()
logger.info(f"Starting async agent scheduler: {getattr(self.agent, 'name', 'Agent')}")
logger.info(f"Task: {self.task}")
logger.info(f"Schedule: {schedule_expr} ({interval}s interval)")
# TODO: Add timeout and budget logging from sync version
# Run immediately if requested
if run_immediately:
logger.info("Running agent immediately before starting schedule...")
await self._execute_with_retry(max_retries)
# Start background task
self._task = asyncio.create_task(
self._run_schedule(interval, max_retries)
)
logger.info("Async agent scheduler started successfully")
return True
except Exception as e:
logger.error(f"Failed to start async scheduler: {e}")
self.is_running = False
return False
async def stop(self) -> bool:
"""
Stop the scheduler gracefully.
IMPORTANT: This method must be called from the same event loop
that was used to start the scheduler.
Returns:
True if stopped successfully
Raises:
RuntimeError: If called from a different event loop than start()
"""
if not self.is_running:
logger.info("Async scheduler is not running")
return True
logger.info("Stopping async agent scheduler...")
# _stop_event is guaranteed non-None after a successful start()
if self._stop_event is None:
logger.warning("stop() called before start(); nothing to stop.")
self.is_running = False
return True
# Ensure we're on the same loop that was bound during start()
current_loop = asyncio.get_running_loop()
if self._bound_loop is not None and current_loop is not self._bound_loop:
raise RuntimeError(
"stop() must be called from the same event loop as start(). "
f"Expected: {self._bound_loop}, got: {current_loop}"
)
self._stop_event.set()
try:
if self._task:
try:
await asyncio.wait_for(self._task, timeout=10)
except asyncio.TimeoutError:
logger.warning("Scheduler task didn't stop gracefully, cancelling")
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Scheduler task raised on cancel: {e}")
except asyncio.CancelledError:
# Task already cancelled; treat as expected during shutdown
pass
except Exception as e:
logger.error(f"Scheduler task raised during stop: {e}")
finally:
self.is_running = False
logger.info("Async agent scheduler stopped")
# Log final stats with consistent snapshot
if self._stats_lock is not None:
async with self._stats_lock:
total = self._execution_count
ok = self._success_count
fail = self._failure_count
else:
total = self._execution_count
ok = self._success_count
fail = self._failure_count
logger.info(f"Execution stats - Total: {total}, Success: {ok}, Failed: {fail}")
return True
async def get_stats(self) -> Dict[str, Any]:
"""
Get current execution statistics (best-effort synchronous access).
Warning: This method provides a best-effort view of stats without
guaranteeing atomicity. For consistent snapshots in async context,
use get_stats_async() instead.
Returns:
Dictionary with execution stats
"""
# Best-effort read without lock for backward compatibility
return {
"is_running": self.is_running,
"total_executions": self._execution_count,
"successful_executions": self._success_count,
"failed_executions": self._failure_count,
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
# TODO: Add cost tracking from sync version:
# "total_cost": self._total_cost,
# "remaining_budget": (self.max_cost - self._total_cost) if self.max_cost else None,
}
async def get_stats_async(self) -> Dict[str, Any]:
"""
Get current execution statistics with atomic snapshot (async).
Returns:
Dictionary with execution stats
"""
if self._stats_lock is None:
# Not yet started: stats are all zero, no lock needed
execs, success, failed = 0, 0, 0
else:
# Take atomic snapshot of all counters
async with self._stats_lock:
execs = self._execution_count
success = self._success_count
failed = self._failure_count
return {
"is_running": self.is_running,
"total_executions": execs,
"successful_executions": success,
"failed_executions": failed,
"success_rate": (success / execs * 100) if execs > 0 else 0,
# TODO: Add cost tracking from sync version:
# "total_cost": self._total_cost,
# "remaining_budget": (self.max_cost - self._total_cost) if self.max_cost else None,
}
def get_stats_sync(self) -> Dict[str, Any]:
"""
Synchronous alias for get_stats() for clarity.
Returns:
Dictionary with execution stats (best-effort)
"""
# Always do best-effort synchronous read for simplicity
return {
"is_running": self.is_running,
"total_executions": self._execution_count,
"successful_executions": self._success_count,
"failed_executions": self._failure_count,
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
}
async def _run_schedule(self, interval: int, max_retries: int):
"""Internal method to run scheduled agent executions."""
try:
self._ensure_async_primitives()
while not self._stop_event.is_set():
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Starting async scheduled agent execution")
await self._execute_with_retry(max_retries)
# Wait for next scheduled time or stop event
logger.info(f"Next execution in {interval} seconds ({interval/3600:.1f} hours)")
try:
await asyncio.wait_for(self._stop_event.wait(), timeout=interval)
break # Stop event was set
except asyncio.TimeoutError:
continue # Timeout reached, continue with next execution
finally:
self.is_running = False
async def _execute_with_retry(self, max_retries: int):
"""Execute agent with retry logic.
TODO: Port missing features from sync version:
- Timeout support per execution
- Budget tracking and limits
- Daemon state updates (_update_state_if_daemon)
"""
self._ensure_async_primitives() # guarantees _stats_lock is bound to current loop
async with self._stats_lock:
self._execution_count += 1
# TODO: Add budget check from sync version:
# if self.max_cost and self._total_cost >= self.max_cost:
# logger.warning(f"Budget limit reached: ${self._total_cost:.4f} >= ${self.max_cost}")
# return
last_exc: Optional[Exception] = None
for attempt in range(max_retries):
try:
logger.info(f"Async attempt {attempt + 1}/{max_retries}")
# TODO: Add timeout support from sync version:
# if self.timeout:
# result = await asyncio.wait_for(
# self._executor.execute(self.task),
# timeout=self.timeout
# )
# else:
result = await self._executor.execute(self.task)
logger.info(f"Async agent execution successful on attempt {attempt + 1}")
logger.info(f"Result: {result}")
async with self._stats_lock:
self._success_count += 1
# TODO: Add cost tracking from sync version:
# estimated_cost = self._estimate_cost(result)
# self._total_cost += estimated_cost
safe_call(self.on_success, result)
# TODO: Add daemon state update from sync version:
# self._update_state_if_daemon()
return
except asyncio.TimeoutError as e:
last_exc = e
logger.error(f"Async execution timeout on attempt {attempt + 1}: {e}")
if attempt < max_retries - 1:
wait_time = backoff_delay(attempt)
logger.info(f"Waiting {wait_time}s before async retry after timeout...")
await asyncio.sleep(wait_time)
except Exception as e:
last_exc = e
logger.error(f"Async agent execution failed on attempt {attempt + 1}: {e}")
if attempt < max_retries - 1:
wait_time = backoff_delay(attempt)
logger.info(f"Waiting {wait_time}s before async retry...")
await asyncio.sleep(wait_time)
async with self._stats_lock:
self._failure_count += 1
logger.error(f"Async agent execution failed after {max_retries} attempts")
safe_call(
self.on_failure,
last_exc if last_exc is not None
else RuntimeError(f"Failed after {max_retries} attempts")
)
# TODO: Add daemon state update from sync version:
# self._update_state_if_daemon()
async def execute_once(self) -> Any:
"""
Execute agent immediately (one-time execution).
Returns:
Agent execution result
"""
logger.info("Executing agent once (async)")
try:
result = await self._executor.execute(self.task)
logger.info(f"One-time async execution successful: {result}")
return result
except Exception as e:
logger.error(f"One-time async execution failed: {e}")
raise
# TODO: Add these missing factory methods from sync version:
# @classmethod
# async def from_yaml(cls, yaml_path: str, ...) -> "AsyncAgentScheduler":
# """Create scheduler from YAML configuration."""
# pass
#
# @classmethod
# async def from_recipe(cls, recipe_name: str, ...) -> "AsyncAgentScheduler":
# """Create scheduler from recipe configuration."""
# pass
def create_async_agent_scheduler(
agent,
task: str,
config: Optional[Dict[str, Any]] = None
) -> AsyncAgentScheduler:
"""
Factory function to create async agent scheduler.
Args:
agent: PraisonAI Agent instance
task: Task description
config: Optional configuration
Returns:
Configured AsyncAgentScheduler instance
"""
return AsyncAgentScheduler(agent, task, config)
@@ -1,11 +1,10 @@
"""
DEPRECATED: This module is deprecated. Use praisonai.scheduler instead.
Real deployment scheduler that integrates with the actual deployment system.
This file is kept for backward compatibility only.
All new code should import from praisonai.scheduler.
This module provides the actual deployment scheduler implementation that was
previously shadowed by the mock in __init__.py.
"""
import warnings
import logging
import threading
import time
@@ -14,18 +13,7 @@ from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
# Issue deprecation warning
warnings.warn(
"praisonai.scheduler module (root level) is deprecated. "
"Use 'from praisonai.scheduler import ...' instead.",
DeprecationWarning,
stacklevel=2
)
# Import from new scheduler module for backward compatibility
from .scheduler.base import ScheduleParser
# Keep old classes for backward compatibility
class DeployerInterface(ABC):
"""Abstract interface for deployers to ensure provider compatibility."""
@@ -34,35 +22,56 @@ class DeployerInterface(ABC):
"""Execute deployment. Returns True on success, False on failure."""
pass
class CloudDeployerAdapter(DeployerInterface):
"""Adapter for existing CloudDeployer to match interface."""
class DeployHandlerAdapter(DeployerInterface):
"""Adapter for the real DeployHandler to match the scheduler interface."""
def __init__(self):
from .deploy import CloudDeployer
self._deployer = CloudDeployer()
def __init__(self, provider: str = "gcp", config: Optional[Dict[str, Any]] = None):
self.provider = provider
self.config = config or {}
def deploy(self) -> bool:
"""Execute deployment using CloudDeployer."""
"""Execute deployment using the real DeployHandler."""
try:
self._deployer.run_commands()
from praisonai.cli.features.deploy import DeployHandler
handler = DeployHandler()
# Create args object for handler
class DeployArgs:
def __init__(self):
self.file = "agents.yaml"
self.type = None
self.provider = self.provider if hasattr(self, 'provider') else 'gcp'
self.json = False
self.background = False
# Create args with the provider from the scheduler
deploy_args = DeployArgs()
deploy_args.provider = self.provider
handler.handle_deploy(deploy_args)
return True
except Exception as e:
logger.error(f"Deployment failed: {e}")
return False
class DeploymentScheduler:
"""
Minimal deployment scheduler with provider-agnostic design.
Real deployment scheduler with provider-agnostic design.
Features:
- Simple interval-based scheduling
- Thread-safe operation
- Extensible deployer factory pattern
- Minimal dependencies
- Integrates with actual DeployHandler
- Provider dispatch support
"""
def __init__(self, schedule_config: Optional[Dict[str, Any]] = None):
self.config = schedule_config or {}
def __init__(self, provider: str = "gcp", config: Optional[Dict[str, Any]] = None):
self.provider = provider
self.config = config or {}
self.is_running = False
self._stop_event = threading.Event()
self._thread = None
@@ -77,8 +86,8 @@ class DeploymentScheduler:
if self._deployer:
return self._deployer
# Default to CloudDeployer for backward compatibility
return CloudDeployerAdapter()
# Use real DeployHandler via adapter
return DeployHandlerAdapter(self.provider, self.config)
def start(self, schedule_expr: str, max_retries: int = 3) -> bool:
"""
@@ -96,6 +105,7 @@ class DeploymentScheduler:
return False
try:
from .base import ScheduleParser
interval = ScheduleParser.parse(schedule_expr)
self.is_running = True
self._stop_event.clear()
@@ -165,30 +175,16 @@ class DeploymentScheduler:
logger.error(f"One-time deployment failed: {e}")
return False
def create_scheduler(provider: str = "gcp", config: Optional[Dict[str, Any]] = None) -> DeploymentScheduler:
def create_deployment_scheduler(provider: str = "gcp", config: Optional[Dict[str, Any]] = None) -> DeploymentScheduler:
"""
Factory function to create scheduler for different providers.
Factory function to create a real deployment scheduler for different providers.
Args:
provider: Deployment provider ("gcp", "aws", "azure", etc.)
config: Optional configuration dict
Returns:
Configured DeploymentScheduler instance
Configured DeploymentScheduler instance that uses real deployment logic
"""
scheduler = DeploymentScheduler(config)
# Provider-specific deployer setup can be added here
if provider == "gcp":
# Default CloudDeployer for GCP
pass
elif provider == "aws":
# Future: AWS deployer implementation
logger.warning("AWS provider not yet implemented, using default")
elif provider == "azure":
# Future: Azure deployer implementation
logger.warning("Azure provider not yet implemented, using default")
else:
logger.warning(f"Unknown provider {provider}, using default")
return scheduler
return DeploymentScheduler(provider, config)
+41 -2
View File
@@ -6,6 +6,7 @@ Heavy implementations that follow StorageBackendProtocol for:
- MongoDB (praisonai[mongodb])
- PostgreSQL (praisonai[postgresql])
- DynamoDB (praisonai[dynamodb])
- Valkey (praisonai[valkey])
These implementations are kept in the wrapper to avoid bloating the core SDK.
"""
@@ -13,9 +14,12 @@ These implementations are kept in the wrapper to avoid bloating the core SDK.
# Lazy imports - only import when needed
__all__ = [
"RedisStorageAdapter",
"MongoDBStorageAdapter",
"MongoDBStorageAdapter",
"PostgreSQLStorageAdapter",
"DynamoDBStorageAdapter",
"ValkeyStorageAdapter",
"ValkeySearchBackend",
"ValkeyBackend",
]
def __getattr__(name: str):
@@ -32,5 +36,40 @@ def __getattr__(name: str):
elif name == "DynamoDBStorageAdapter":
from .dynamodb_adapter import DynamoDBStorageAdapter
return DynamoDBStorageAdapter
elif name == "ValkeyStorageAdapter":
from .valkey_adapter import ValkeyStorageAdapter
return ValkeyStorageAdapter
elif name == "ValkeySearchBackend":
from .valkey_adapter import ValkeySearchBackend
return ValkeySearchBackend
elif name == "ValkeyBackend":
cls = _make_valkey_backend_class()
globals()["ValkeyBackend"] = cls
return cls
else:
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
def _make_valkey_backend_class():
"""Return ValkeyBackend — ValkeyStorageAdapter pre-configured from env vars."""
import os
from .valkey_adapter import ValkeyStorageAdapter
class ValkeyBackend(ValkeyStorageAdapter):
def __init__(
self,
host: str = None,
port: int = None,
prefix: str = None,
ttl=None,
password: str = None,
):
super().__init__(
host=host if host is not None else os.environ.get("VALKEY_HOST", "localhost"),
port=int(port if port is not None else os.environ.get("VALKEY_PORT", 6379)),
prefix=prefix if prefix is not None else os.environ.get("VALKEY_PREFIX", "praisonai:"),
ttl=ttl,
password=password if password is not None else os.environ.get("VALKEY_PASSWORD"),
)
return ValkeyBackend
@@ -0,0 +1,333 @@
"""
Valkey Storage Adapter for PraisonAI.
Implements StorageBackendProtocol using Valkey for high-speed storage.
This is the wrapper implementation that contains the heavy valkey-glide-sync dependency.
"""
import json
import struct
from typing import Dict, Any, List, Optional
from praisonai.persistence._valkey_client import create_valkey_client
try:
from glide_sync import ExpirySet, ExpiryType
except ImportError:
ExpirySet = None
ExpiryType = None
try:
from glide_sync import ft
from glide_sync import (
VectorField, VectorFieldAttributesHnsw, VectorAlgorithm,
DistanceMetricType, VectorType, FtCreateOptions, DataType,
FtSearchOptions, ReturnField,
)
except ImportError:
ft = None
VectorField = VectorFieldAttributesHnsw = VectorAlgorithm = None
DistanceMetricType = VectorType = FtCreateOptions = DataType = None
FtSearchOptions = ReturnField = None
class ValkeyStorageAdapter:
"""
Valkey-based storage backend adapter.
Uses Valkey for high-speed caching and ephemeral data storage.
Implements StorageBackendProtocol from praisonaiagents.storage.protocols.
Example:
```python
from praisonai.storage import ValkeyStorageAdapter
adapter = ValkeyStorageAdapter(host="localhost", port=6379)
adapter.save("session_123", {"messages": []})
data = adapter.load("session_123")
```
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
prefix: str = "praisonai:",
ttl: Optional[int] = None,
password: Optional[str] = None,
):
"""
Initialize the Valkey storage adapter.
Args:
host: Valkey server host
port: Valkey server port
prefix: Key prefix for all stored data
ttl: Optional TTL in seconds for all keys
password: Optional Valkey password
"""
self.host = host
self.port = port
self.prefix = prefix
self.ttl = ttl
self.password = password
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
)
return self._client
def _make_key(self, key: str) -> str:
"""Create prefixed key."""
return f"{self.prefix}{key}"
def _scan_keys(self, pattern: str) -> List[bytes]:
"""Collect all keys matching pattern via SCAN."""
client = self._get_client()
cursor: bytes = b"0"
all_keys: List[bytes] = []
while True:
result = client.scan(cursor, match=pattern, count=100)
cursor = result[0]
all_keys.extend(result[1] or [])
if not cursor or cursor in (b"0", "0", 0):
break
return all_keys
def save(self, key: str, data: Dict[str, Any]) -> None:
"""Save data with the given key."""
client = self._get_client()
full_key = self._make_key(key)
json_data = json.dumps(data, default=str, ensure_ascii=False).encode('utf-8')
try:
if self.ttl is not None:
client.set(full_key, json_data, expiry=ExpirySet(ExpiryType.SEC, self.ttl))
else:
client.set(full_key, json_data)
except Exception as e:
raise RuntimeError(f"Failed to save data to Valkey: {e}") from e
def load(self, key: str) -> Optional[Dict[str, Any]]:
"""Load data by key."""
client = self._get_client()
full_key = self._make_key(key)
try:
value = client.get(full_key)
if value is not None:
try:
if isinstance(value, bytes):
value = value.decode('utf-8')
return json.loads(value)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON data for key '{key}': {e}") from e
return None
except ValueError:
raise
except Exception as e:
raise RuntimeError(f"Failed to load data from Valkey: {e}") from e
def delete(self, key: str) -> bool:
"""Delete data by key."""
client = self._get_client()
full_key = self._make_key(key)
try:
return client.delete([full_key]) > 0
except Exception as e:
raise RuntimeError(f"Failed to delete data from Valkey: {e}") from e
def list_keys(self, prefix: str = "") -> List[str]:
"""List all keys, optionally filtered by prefix."""
pattern = self._make_key(f"{prefix}*")
try:
raw_keys = self._scan_keys(pattern)
prefix_len = len(self.prefix)
return sorted(
(k.decode('utf-8') if isinstance(k, bytes) else k)[prefix_len:]
for k in raw_keys
)
except Exception as e:
raise RuntimeError(f"Failed to list keys from Valkey: {e}") from e
def exists(self, key: str) -> bool:
"""Check if a key exists."""
client = self._get_client()
full_key = self._make_key(key)
try:
return client.exists([full_key]) > 0
except Exception as e:
raise RuntimeError(f"Failed to check key existence in Valkey: {e}") from e
def clear(self) -> int:
"""Clear all data with our prefix. Returns number of items deleted."""
client = self._get_client()
pattern = self._make_key("*")
batch_size = 500
try:
all_keys = self._scan_keys(pattern)
if not all_keys:
return 0
total = 0
for i in range(0, len(all_keys), batch_size):
total += client.delete(all_keys[i:i + batch_size])
return total
except Exception as e:
raise RuntimeError(f"Failed to clear data from Valkey: {e}") from e
def set_ttl(self, key: str, ttl: int) -> bool:
"""Set TTL on a specific key."""
client = self._get_client()
full_key = self._make_key(key)
try:
return client.expire(full_key, ttl)
except Exception as e:
raise RuntimeError(f"Failed to set TTL in Valkey: {e}") from e
def ping(self) -> bool:
"""Test connection to Valkey."""
try:
client = self._get_client()
result = client.ping()
return result is not None
except Exception:
return False
def close(self) -> None:
"""Close the Valkey connection."""
if self._client:
try:
self._client.close()
finally:
self._client = None
class ValkeySearchBackend:
"""
Valkey vector search backend using RediSearch / ValkeySearch FT commands.
Example:
```python
from praisonai.storage import ValkeySearchBackend
backend = ValkeySearchBackend(host="localhost", port=6379, vector_dim=1536)
backend.create_index()
backend.add_document("doc1", "hello world", embedding)
results = backend.search(query_embedding, k=5)
```
"""
def __init__(
self,
host: str = "localhost",
port: int = 6379,
index_name: str = "praisonai_vectors",
password: Optional[str] = None,
vector_dim: int = 1536,
):
"""
Initialize the Valkey search backend.
Args:
host: Valkey server host
port: Valkey server port
index_name: Name of the FT index
password: Optional Valkey password
vector_dim: Dimension of embedding vectors
"""
self.host = host
self.port = port
self.index_name = index_name
self.password = password
self.vector_dim = vector_dim
self._client = None
def _get_client(self):
"""Lazy initialize Valkey client."""
if self._client is None:
self._client = create_valkey_client(
host=self.host,
port=self.port,
password=self.password,
)
return self._client
def create_index(self, vector_dim: int = None) -> None:
"""Create the vector search index (no-op if already exists)."""
client = self._get_client()
dim = vector_dim or self.vector_dim
try:
schema = [VectorField(
"embedding",
VectorAlgorithm.HNSW,
VectorFieldAttributesHnsw(dim, DistanceMetricType.COSINE, VectorType.FLOAT32),
)]
options = FtCreateOptions(DataType.HASH, prefixes=[f"{self.index_name}:"])
ft.create(client, self.index_name, schema, options)
except Exception as e:
err = str(e)
if "already exists" not in err.lower() and "index already exists" not in err.lower():
raise RuntimeError(f"Failed to create Valkey search index: {e}") from e
def add_document(self, doc_id: str, text: str, embedding: List[float]) -> None:
"""Add a document with its embedding to the index."""
client = self._get_client()
full_id = f"{self.index_name}:{doc_id}"
packed = struct.pack(f"{len(embedding)}f", *embedding)
try:
client.hset(full_id, {"text": text, "embedding": packed})
except Exception as e:
raise RuntimeError(f"Failed to add document to Valkey: {e}") from e
def search(self, query_embedding: List[float], k: int = 5) -> List[Dict[str, Any]]:
"""Search for nearest neighbors by embedding."""
client = self._get_client()
packed = struct.pack(f"{len(query_embedding)}f", *query_embedding)
try:
options = FtSearchOptions(
return_fields=[ReturnField("text"), ReturnField("score")],
params={"vec": packed},
)
raw = ft.search(client, self.index_name, f"*=>[KNN {k} @embedding $vec AS score]", options)
except Exception as e:
raise RuntimeError(f"Failed to search Valkey: {e}") from e
# ft.search returns [count, {doc_id: {field: value, ...}, ...}]
if not raw or len(raw) < 2:
return []
results = []
key_prefix = f"{self.index_name}:"
for doc_id, fields in raw[1].items():
doc: Dict[str, Any] = {}
raw_id = doc_id.decode('utf-8') if isinstance(doc_id, bytes) else doc_id
doc["id"] = raw_id.removeprefix(key_prefix)
for fname, fval in fields.items():
fname = fname.decode('utf-8') if isinstance(fname, bytes) else fname
fval = fval.decode('utf-8') if isinstance(fval, bytes) else fval
doc[fname] = fval
results.append(doc)
return results
def close(self) -> None:
"""Close the Valkey connection."""
if self._client:
try:
self._client.close()
finally:
self._client = None
+192 -72
View File
@@ -34,6 +34,9 @@ from ._safe_loader import load_user_module
logger = logging.getLogger(__name__)
# Sentinel for cache - needed because None is a valid cached result (tool not found)
_SENTINEL = object()
class ToolResolver:
"""Resolves tool names to callables from multiple sources.
@@ -45,19 +48,34 @@ class ToolResolver:
Attributes:
_local_tools_cache: Cached tools from local tools.py
_local_tools_loaded: Whether local tools have been loaded
_registry: Optional ToolRegistry for wrapper-level tool registration
_resolve_cache: Cached results from resolve() calls
_resolve_cache_lock: Thread lock for resolve cache
"""
def __init__(self, tools_py_path: Optional[str] = None):
def __init__(
self,
tools_py_path: Optional[str] = None,
registry: Optional["ToolRegistry"] = None,
):
"""Initialize the resolver.
Args:
tools_py_path: Optional path to tools.py. If None, uses ./tools.py
registry: Optional ToolRegistry to include in resolution chain
"""
self._tools_py_path = tools_py_path or "tools.py"
from pathlib import Path
# Resolve path eagerly in constructor to make binding explicit and inspectable
self._tools_py_path = str(Path(tools_py_path or "tools.py").resolve())
self._local_tools_cache: Mapping[str, Callable] = MappingProxyType({})
self._local_tools_loaded: bool = False
self._praisonai_tools_available: Optional[bool] = None
self._local_tools_lock = threading.Lock()
self._registry = registry
# Cache for resolved tools to avoid repeated resolution
self._resolve_cache: Dict[str, Optional[Callable]] = {}
self._resolve_cache_lock = threading.Lock()
def _load_local_tools(self) -> Mapping[str, Callable]:
"""Load tools from local tools.py file.
@@ -187,6 +205,25 @@ class ToolResolver:
return None
def _resolve_from_wrapper_registry(self, name: str) -> Optional[Callable]:
"""Resolve tool from the wrapper ToolRegistry.
Args:
name: Tool name to resolve
Returns:
Callable if found, None otherwise
"""
if self._registry is None:
return None
tool = self._registry.get_function(name)
if tool is not None:
logger.debug(f"Resolved '{name}' from wrapper ToolRegistry")
return tool
return None
def _resolve_from_registry(self, name: str) -> Optional[Callable]:
"""Resolve tool from the global tool registry.
@@ -214,9 +251,10 @@ class ToolResolver:
Resolution order:
1. Local tools.py (backward compat, custom tools)
2. praisonaiagents.tools.TOOL_MAPPINGS (built-in)
3. praisonai-tools package (external, optional)
4. Tool registry (plugins)
2. Wrapper ToolRegistry (register_function API)
3. praisonaiagents.tools.TOOL_MAPPINGS (built-in)
4. praisonai-tools package (external, optional)
5. Core SDK tool registry (plugins)
Args:
name: Tool name to resolve
@@ -230,30 +268,56 @@ class ToolResolver:
name = name.strip()
if not name:
return None
# 1. Check local tools.py first (highest priority)
# Fast path: cached result
cached = self._resolve_cache.get(name, _SENTINEL)
if cached is not _SENTINEL:
return cached
# Load local tools outside the cache lock to prevent lock-order inversion
local_tools = self._load_local_tools()
if name in local_tools:
logger.debug(f"Resolved '{name}' from local tools.py")
return local_tools[name]
# 2. Check praisonaiagents.tools
tool = self._resolve_from_praisonaiagents(name)
if tool is not None:
return tool
# 3. Check praisonai-tools package
tool = self._resolve_from_praisonai_tools(name)
if tool is not None:
return tool
# 4. Check tool registry
tool = self._resolve_from_registry(name)
if tool is not None:
return tool
logger.warning(f"Tool '{name}' not found in any source")
return None
with self._resolve_cache_lock:
# Double-check inside lock
cached = self._resolve_cache.get(name, _SENTINEL)
if cached is not _SENTINEL:
return cached
# 1. Check local tools.py first (highest priority)
if name in local_tools:
logger.debug(f"Resolved '{name}' from local tools.py")
tool = local_tools[name]
self._resolve_cache[name] = tool
return tool
# 2. Check wrapper ToolRegistry (NEW - ahead of SDK paths)
tool = self._resolve_from_wrapper_registry(name)
if tool is not None:
self._resolve_cache[name] = tool
return tool
# 3. Check praisonaiagents.tools
tool = self._resolve_from_praisonaiagents(name)
if tool is not None:
self._resolve_cache[name] = tool
return tool
# 4. Check praisonai-tools package
tool = self._resolve_from_praisonai_tools(name)
if tool is not None:
self._resolve_cache[name] = tool
return tool
# 5. Check core SDK tool registry
tool = self._resolve_from_registry(name)
if tool is not None:
self._resolve_cache[name] = tool
return tool
# Cache the None result to avoid repeated failed lookups
logger.warning(f"Tool '{name}' not found in any source")
self._resolve_cache[name] = None
return None
def resolve_many(self, names: List[str]) -> List[Callable]:
"""Resolve multiple tool names to callables.
@@ -362,13 +426,15 @@ class ToolResolver:
return list(set(missing)) # Remove duplicates
def clear_cache(self) -> None:
"""Clear the local tools cache.
"""Clear both the local tools cache and resolve cache.
Useful when tools.py has been modified and needs to be reloaded.
"""
with self._local_tools_lock:
self._local_tools_cache = MappingProxyType({})
self._local_tools_loaded = False
with self._resolve_cache_lock:
self._resolve_cache.clear()
def get_local_callables(self) -> List[Callable]:
"""Get functions exposed by tools.py (path A semantics).
@@ -390,59 +456,99 @@ class ToolResolver:
module = load_user_module(self._tools_py_path, name="tools_module")
if module is None:
return {}
# Import the necessary classes (matching agents_generator.py logic)
BaseTool = None
PRAISONAI_TOOLS_AVAILABLE = False
try:
from praisonai_tools import BaseTool
PRAISONAI_TOOLS_AVAILABLE = True
except ImportError:
try:
from praisonai.tools import BaseTool
PRAISONAI_TOOLS_AVAILABLE = True
except ImportError:
pass
result = {}
for name, obj in inspect.getmembers(module,
lambda x: inspect.isclass(x) and (
x.__module__.startswith('langchain_community.tools') or
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
) and x is not BaseTool):
try:
result[name] = obj()
logger.debug(f"Loaded local tool class: {name}")
except Exception as e:
logger.warning(f"Error instantiating tool class {name}: {e}")
continue
return result
return self._extract_tool_classes(module)
except Exception as e:
logger.warning(f"Error loading tool classes from {self._tools_py_path}: {e}")
return {}
def get_local_tool_classes_from_dir(self, tools_dir: "os.PathLike|str") -> Dict[str, Any]:
"""Load BaseTool/langchain classes from every *.py in a tools/ directory.
Args:
tools_dir: Path to the tools directory
Returns:
Dictionary mapping class names to instantiated tool objects
"""
from pathlib import Path
from ._safe_loader import load_user_module
classes: Dict[str, Any] = {}
for py_file in Path(tools_dir).glob("*.py"):
if py_file.name.startswith("__"):
continue
try:
module = load_user_module(py_file, name=f"tools_{py_file.stem}")
if module is not None:
classes.update(self._extract_tool_classes(module))
except Exception as e:
logger.warning(f"Error loading tool classes from file {py_file}: {e}")
return classes
# Process-level lazy singleton for performance (matches profiler.py pattern)
_default_resolver: Optional[ToolResolver] = None
_default_resolver_lock = threading.Lock()
def _extract_tool_classes(self, module):
"""Extract tool classes from a loaded module that inherit from BaseTool
or are part of langchain_community.tools package.
"""
# Import the necessary classes (matching agents_generator.py logic)
BaseTool = None
PRAISONAI_TOOLS_AVAILABLE = False
try:
from praisonai_tools import BaseTool
PRAISONAI_TOOLS_AVAILABLE = True
except ImportError:
try:
from praisonai.tools import BaseTool
PRAISONAI_TOOLS_AVAILABLE = True
except ImportError:
pass
result = {}
for name, obj in inspect.getmembers(module,
lambda x: inspect.isclass(x) and (
x.__module__.startswith('langchain_community.tools') or
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
) and x is not BaseTool):
try:
result[name] = obj()
logger.debug(f"Loaded tool class: {name}")
except Exception as e:
logger.warning(f"Error instantiating tool class {name}: {e}")
continue
return result
# Context-local resolver for multi-project safety
import contextvars
_resolver_var: contextvars.ContextVar[Optional[ToolResolver]] = contextvars.ContextVar(
"tool_resolver", default=None,
)
def _get_default_resolver() -> ToolResolver:
"""Process-default ToolResolver (double-checked lazy init).
"""Per-context resolver. Falls back to a fresh ToolResolver per context,
so changing CWD between agents / requests is honoured.
Returns cached ToolResolver that is anchored to the working directory
at first call. Local tools.py resolution is CWD-dependent and cached
for the lifetime of the process.
Each context (agent/task/request) gets its own resolver anchored to the
working directory at the time of first use in that context. This prevents
the singleton bug where the first caller locks in their CWD for the entire process.
For test isolation or multi-project CLIs, create explicit resolver
instances instead of using this cached default.
"""
global _default_resolver
if _default_resolver is None:
with _default_resolver_lock:
if _default_resolver is None:
_default_resolver = ToolResolver()
return _default_resolver
resolver = _resolver_var.get()
if resolver is None:
resolver = ToolResolver()
_resolver_var.set(resolver)
return resolver
def reset_default_resolver() -> None:
"""Explicit invalidation hook for daemons / IDE plugins switching projects.
Call this when changing working directories or switching between projects
to ensure the next tool resolution uses the new CWD.
"""
_resolver_var.set(None)
# Convenience functions that use cached default resolver for performance
@@ -457,8 +563,8 @@ def resolve_tool(name: str, resolver: Optional[ToolResolver] = None) -> Optional
Callable if found, None otherwise
Note:
When resolver=None, uses a process-level cached resolver that is anchored
to the working directory at first call. For test isolation or multi-project
When resolver=None, uses a context-local cached resolver anchored to the
working directory for that context. For test isolation or multi-project
CLIs, pass an explicit resolver instance.
"""
return (resolver or _get_default_resolver()).resolve(name)
@@ -513,3 +619,17 @@ def validate_yaml_tools(yaml_config: Dict[str, Any], resolver: Optional[ToolReso
List of missing tool names
"""
return (resolver or _get_default_resolver()).validate_yaml_tools(yaml_config)
def reset_default_resolver() -> None:
"""Clear the process-default resolver.
Call this between tenants, on CWD change, or in test setup to ensure
that local tools.py resolution is not affected by previous calls.
This follows the same pattern as _framework_availability.invalidate()
for resetting cached state.
"""
global _default_resolver
with _default_resolver_lock:
_default_resolver = None
+6 -5
View File
@@ -549,12 +549,13 @@ class TrainModel:
"""
def create_and_push_ollama_model(self):
from ._ollama import create_and_push_ollama_model
modelfile_content = self.prepare_modelfile_content()
with open("Modelfile", "w") as file:
file.write(modelfile_content)
subprocess.run(["ollama", "serve"])
subprocess.run(["ollama", "create", f"{self.config['ollama_model']}:{self.config['model_parameters']}", "-f", "Modelfile"])
subprocess.run(["ollama", "push", f"{self.config['ollama_model']}:{self.config['model_parameters']}"])
create_and_push_ollama_model(
self.config['ollama_model'],
self.config['model_parameters'],
modelfile_content
)
def run(self):
self.print_system_info()
+97
View File
@@ -0,0 +1,97 @@
"""Shared Ollama daemon management utilities.
This module provides utilities to start and check Ollama daemon status,
fixing the blocking subprocess.run(["ollama", "serve"]) issue present
in multiple files.
"""
import contextlib
import shutil
import socket
import subprocess
import time
from typing import Optional
def _ollama_ready(host: str = "127.0.0.1", port: int = 11434, timeout: float = 0.2) -> bool:
"""Check if Ollama daemon is ready to accept connections.
Args:
host: Ollama host (default 127.0.0.1)
port: Ollama port (default 11434)
timeout: Connection timeout in seconds
Returns:
True if Ollama is ready, False otherwise
"""
with contextlib.suppress(OSError):
with socket.create_connection((host, port), timeout):
return True
return False
def ensure_ollama_running(max_wait_seconds: float = 5.0) -> Optional[subprocess.Popen]:
"""Ensure Ollama daemon is running, start it if necessary.
Args:
max_wait_seconds: Maximum time to wait for daemon to become ready
Returns:
Process object if we started the daemon, None if it was already running
Raises:
RuntimeError: If ollama CLI not found or daemon doesn't become ready
"""
# Check if already running
if _ollama_ready():
return None
# Check if ollama CLI is available
if shutil.which("ollama") is None:
raise RuntimeError("`ollama` CLI not found; install from https://ollama.com")
# Start daemon in detached mode
proc = subprocess.Popen(
["ollama", "serve"],
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
start_new_session=True, # Detach from parent
)
# Poll until ready or timeout
wait_interval = 0.1
max_polls = int(max_wait_seconds / wait_interval)
for _ in range(max_polls):
if _ollama_ready():
return proc
time.sleep(wait_interval)
# If we get here, daemon didn't become ready in time
proc.terminate()
raise RuntimeError(f"ollama serve did not become ready in {max_wait_seconds} seconds")
def create_and_push_ollama_model(ollama_model: str, model_parameters: str, modelfile_content: str) -> None:
"""Create and push an Ollama model with proper daemon management.
Args:
ollama_model: Name of the Ollama model
model_parameters: Model parameters/tag
modelfile_content: Content for the Modelfile
Raises:
RuntimeError: If ollama operations fail
subprocess.CalledProcessError: If create/push commands fail
"""
# Write Modelfile
with open("Modelfile", "w") as f:
f.write(modelfile_content)
# Ensure daemon is running
ensure_ollama_running()
# Create and push model
tag = f"{ollama_model}:{model_parameters}"
subprocess.run(["ollama", "create", tag, "-f", "Modelfile"], check=True)
subprocess.run(["ollama", "push", tag], check=True)
+6 -5
View File
@@ -522,12 +522,13 @@ class TrainModel:
"""
def create_and_push_ollama_model(self):
from .._ollama import create_and_push_ollama_model
modelfile_content = self.prepare_modelfile_content()
with open("Modelfile", "w") as file:
file.write(modelfile_content)
subprocess.run(["ollama", "serve"])
subprocess.run(["ollama", "create", f"{self.config['ollama_model']}:{self.config['model_parameters']}", "-f", "Modelfile"])
subprocess.run(["ollama", "push", f"{self.config['ollama_model']}:{self.config['model_parameters']}"])
create_and_push_ollama_model(
self.config['ollama_model'],
self.config['model_parameters'],
modelfile_content
)
def run(self):
self.print_system_info()
+6 -5
View File
@@ -285,12 +285,13 @@ PARAMETER top_p 0.9
"""
def create_and_push_ollama_model(self):
from .train._ollama import create_and_push_ollama_model
modelfile_content = self.prepare_modelfile_content()
with open("Modelfile", "w") as file:
file.write(modelfile_content)
subprocess.run(["ollama", "serve"])
subprocess.run(["ollama", "create", f"{self.config['ollama_model']}:{self.config['model_parameters']}", "-f", "Modelfile"])
subprocess.run(["ollama", "push", f"{self.config['ollama_model']}:{self.config['model_parameters']}"])
create_and_push_ollama_model(
self.config['ollama_model'],
self.config['model_parameters'],
modelfile_content
)
def run(self):
self.print_system_info()
+7 -18
View File
@@ -82,26 +82,15 @@ class UploadVisionModel:
def create_and_push_ollama_model(self):
"""Create and push model to Ollama."""
from .train._ollama import create_and_push_ollama_model
print(f"DEBUG: Creating Ollama model: {self.config['ollama_model']}:{self.config['model_parameters']}")
modelfile_content = self.prepare_modelfile_content()
with open("Modelfile", "w") as file:
file.write(modelfile_content)
print("DEBUG: Starting Ollama server...")
subprocess.run(["ollama", "serve"])
print("DEBUG: Creating Ollama model...")
subprocess.run([
"ollama", "create",
f"{self.config['ollama_model']}:{self.config['model_parameters']}",
"-f", "Modelfile"
])
print("DEBUG: Pushing model to Ollama...")
subprocess.run([
"ollama", "push",
f"{self.config['ollama_model']}:{self.config['model_parameters']}"
])
print("DEBUG: Starting Ollama server and creating model...")
create_and_push_ollama_model(
self.config['ollama_model'],
self.config['model_parameters'],
modelfile_content
)
print("DEBUG: Model pushed to Ollama successfully.")
def upload(self, target="all"):
+1 -1
View File
@@ -1 +1 @@
__version__ = "4.6.45"
__version__ = "4.6.48"
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff. Show more