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
praison 445003042e Release v4.6.45 2026-05-21 11:42:37 +01:00
copilot-swe-agent[bot]andMervinPraison 00b40e8cc4 fix: address review findings for capability gates
Agent-Logs-Url: https://github.com/MervinPraison/PraisonAI/sessions/b4c856b3-93a3-4a2b-ad6c-bb90ef413121

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Skills can now declare requirements in SKILL.md frontmatter.

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

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

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

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

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

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

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

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

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

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

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

Fixes #1694

Co-authored-by: MervinPraison <[email protected]>
2026-05-20 08:17:51 +00:00
170 changed files with 10187 additions and 2465 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__
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.43" \
"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.43" \
"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.43" \
"praisonai>=4.6.48" \
"praisonai[ui]" \
"praisonai[crewai]"
+27
View File
@@ -0,0 +1,27 @@
#!/usr/bin/env python3
"""Minimal A2UI example — agent + send_a2ui_messages tool.
Install:
pip install praisonaiagents[a2ui]
Run:
python send_surface.py
For live rendering, use PraisonAIUI with AG-UI (/agui) — see integrate-a2ui-frontend docs.
"""
from praisonaiagents import Agent
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
agent = Agent(
name="ui-assistant",
instructions=(
"When asked for UI, call send_a2ui_messages with A2UI v0.9 JSON "
"(createSurface on surface id 'main')."
),
tools=[send_a2ui_messages],
)
if __name__ == "__main__":
result = agent.start("Show a simple welcome card on surface main")
print(result)
+222
View File
@@ -0,0 +1,222 @@
# Multi-Channel Bot Deployment Guide
This guide explains how to set up multiple bots on the same platform (e.g., multiple Telegram bots) for different roles using PraisonAI, similar to Hermes-style AI workforces.
## Overview
PraisonAI supports running multiple bots on the same platform, each routing to different agents with specialized knowledge and capabilities. This is useful for:
- **Role-based bots**: `@company_cfo_bot`, `@company_ops_bot`, `@company_content_bot`
- **Department-specific agents**: Each with different knowledge brains and tool access
- **Workflow separation**: Different bots handle different types of requests
## Quick Start with Onboard Wizard
The easiest way to set up multiple channels is using the onboard wizard:
```bash
praisonai onboard
```
The wizard now supports:
1. **Multi-channel setup**: Add multiple bots for the same platform
2. **Role-based naming**: Automatically generates `TELEGRAM_<ROLE>_BOT_TOKEN` environment variables
3. **Agent routing**: Each channel routes to a different agent
### Onboard Wizard Flow
1. **Choose platform**: Start with your first platform (e.g., telegram)
2. **Add more channels**: Wizard asks "Add another bot channel?"
3. **Specify role**: Enter role name (e.g., "cfo", "ops", "content")
4. **Configure tokens**: Each channel gets its own environment variable
5. **Test connections**: Wizard tests each bot separately
## Environment Variable Convention
For multiple bots on the same platform, use this naming convention:
| Channel | Environment Variable | Bot Purpose |
|---------|---------------------|-------------|
| `telegram_cfo` | `TELEGRAM_CFO_BOT_TOKEN` | CFO-related tasks |
| `telegram_ops` | `TELEGRAM_OPS_BOT_TOKEN` | Operations tasks |
| `telegram_content` | `TELEGRAM_CONTENT_BOT_TOKEN` | Content creation |
| `telegram_support` | `TELEGRAM_SUPPORT_BOT_TOKEN` | Customer support |
### Setting Up Tokens
1. **Create bots in @BotFather**:
```text
/newbot
Bot name: Company CFO Bot
Username: @company_cfo_bot
-> Copy token
/newbot
Bot name: Company Ops Bot
Username: @company_ops_bot
-> Copy token
```
2. **Set environment variables**:
```bash
export TELEGRAM_CFO_BOT_TOKEN="123456789:ABC..."
export TELEGRAM_OPS_BOT_TOKEN="987654321:DEF..."
export TELEGRAM_CONTENT_BOT_TOKEN="456789123:GHI..."
```
3. **Or use `~/.praisonai/.env`**:
```env
TELEGRAM_CFO_BOT_TOKEN=123456789:ABC...
TELEGRAM_OPS_BOT_TOKEN=987654321:DEF...
TELEGRAM_CONTENT_BOT_TOKEN=456789123:GHI...
TELEGRAM_ALLOWED_USERS=123456789,987654321
```
## Example Configuration
See `multi-telegram-hermes-workforce.yaml` for a complete example.
### Key Features
1. **Multiple Channels**:
```yaml
channels:
telegram_cfo:
platform: telegram
token: ${TELEGRAM_CFO_BOT_TOKEN}
routes:
default: cfo
telegram_ops:
platform: telegram
token: ${TELEGRAM_OPS_BOT_TOKEN}
routes:
default: ops
```
2. **Specialized Agents**:
```yaml
agents:
cfo:
instructions: "You are a CFO agent. Help with financial analysis..."
model: gpt-4o-mini
ops:
instructions: "You are an ops agent. Help with system monitoring..."
model: gpt-4o-mini
```
## Validation with Doctor
Use the doctor command to validate your multi-channel setup:
```bash
praisonai doctor
```
The doctor checks for:
- ✅ **Unique tokens**: Each channel uses a different bot token
- ✅ **Naming convention**: Follows `PLATFORM_ROLE_BOT_TOKEN` pattern
- ✅ **Missing tokens**: All referenced environment variables are set
- ❌ **Duplicate tokens**: Same token used by multiple channels (fails)
## Deployment
### Start Gateway
```bash
# Start the multi-bot gateway
praisonai gateway start --config multi-telegram-hermes-workforce.yaml
# Or install as daemon
praisonai onboard # Wizard installs daemon automatically
praisonai gateway status
```
### Verify Setup
1. **Check health**: `curl http://127.0.0.1:8765/health`
2. **View dashboard**: `praisonai claw` → http://127.0.0.1:8082
3. **Test bots**: Send messages to each bot to verify routing
## Common Patterns
### Basic Multi-Role Setup
```yaml
# Single platform, multiple roles
channels:
telegram_support:
token: ${TELEGRAM_SUPPORT_BOT_TOKEN}
routes:
default: support_agent
telegram_sales:
token: ${TELEGRAM_SALES_BOT_TOKEN}
routes:
default: sales_agent
```
### Cross-Platform Workforce
```yaml
# Multiple platforms, multiple roles
channels:
telegram_cfo:
platform: telegram
token: ${TELEGRAM_CFO_BOT_TOKEN}
routes:
default: cfo
discord_ops:
platform: discord
token: ${DISCORD_OPS_BOT_TOKEN}
routes:
default: ops
slack_content:
platform: slack
token: ${SLACK_CONTENT_BOT_TOKEN}
routes:
default: content
```
## Troubleshooting
### Common Issues
1. **"Token is used by multiple channels"**
- Each bot needs its own unique token from @BotFather
- Check environment variables for duplicates
2. **"Channel skipped - no token"**
- Set the environment variable: `export TELEGRAM_CFO_BOT_TOKEN="..."`
- Or update `~/.praisonai/.env`
3. **"Bot not responding"**
- Verify token is correct: `praisonai doctor`
- Check bot is not already running elsewhere
- Ensure allowlist includes your user ID
### Debugging Commands
```bash
# Check configuration
praisonai doctor
# View logs
praisonai gateway logs
# Test connection
curl -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
http://127.0.0.1:8765/info
```
## Migration from Single-Bot Setup
If you currently have a single bot setup, you can add more channels:
1. **Run onboard wizard**: `praisonai onboard`
2. **Add new channels**: Wizard will preserve existing setup
3. **Update configuration**: New channels added to existing `bot.yaml`
4. **Restart gateway**: `praisonai gateway restart`
The wizard preserves backward compatibility with single-bot configurations.
+19
View File
@@ -0,0 +1,19 @@
# A2UI canvas agent — YAML example
#
# Usage:
# pip install praisonaiagents[a2ui]
# praisonai agent run --file a2ui-canvas.yaml --prompt "Show a welcome card"
#
# For live A2UI rendering, use PraisonAIUI (AG-UI / surfaces):
# pip install praisonaiui
# See examples/python/a2ui/send_surface.py and PraisonAIUI example 29.
agent:
name: "ui-assistant"
instructions: |
You are a helpful assistant. When the user asks for UI elements
(buttons, cards, forms), call send_a2ui_messages with valid A2UI v0.9 JSON.
Use surface id "main" unless told otherwise.
llm: gpt-4o-mini
tools:
- send_a2ui_messages
@@ -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
@@ -343,6 +343,7 @@ _LAZY_IMPORTS = {
# UI (optional)
'AGUI': ('praisonaiagents.ui.agui', 'AGUI'),
'A2A': ('praisonaiagents.ui.a2a', 'A2A'),
'A2UI': ('praisonaiagents.ui.a2ui', 'A2UI'),
# Feature configs
'MemoryConfig': ('praisonaiagents.config.feature_configs', 'MemoryConfig'),
@@ -814,6 +815,6 @@ def __dir__():
# - Workflow, Task, Route, Parallel, Loop, Repeat, Pipeline, etc.
# - MCP, FlowDisplay, track_workflow, FastContext, etc.
# - Plan, PlanStep, TodoList, PlanningAgent, ApprovalCallback, etc.
# - RAG, RAGConfig, RAGResult, AGUI, A2A, etc.
# - RAG, RAGConfig, RAGResult, AGUI, A2A, A2UI, etc.
# - All telemetry, display, and utility functions
# ============================================================================
@@ -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."""
@@ -4616,60 +4739,57 @@ Answer:"""
error=f"Agent guardrail validation error: {str(e)}"
)
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
"""Apply guardrail validation with retry logic.
Args:
response_text: The response to validate
prompt: Original prompt for regeneration if needed
temperature: Temperature for regeneration
tools: Tools for regeneration
Returns:
str: The validated response text or None if validation fails after retries
"""
def _validate_with_guardrail(self, response_text):
"""Validate response with guardrail. Returns (success, result, error)."""
if not self._guardrail_fn:
return response_text
return True, response_text, None
from ..main import TaskOutput
task_output = TaskOutput(
description="Agent response output",
raw=response_text,
agent=self.name
)
guardrail_result = self._process_guardrail(task_output)
if guardrail_result.success:
# Return the potentially modified result
if guardrail_result.result and hasattr(guardrail_result.result, 'raw'):
return True, guardrail_result.result.raw, None
elif guardrail_result.result:
return True, str(guardrail_result.result), None
else:
return True, response_text, None
else:
return False, None, guardrail_result.error
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
"""Apply guardrail validation with retry logic (sync version)."""
retry_count = 0
current_response = response_text
while retry_count <= self.max_guardrail_retries:
# Create TaskOutput object
task_output = TaskOutput(
description="Agent response output",
raw=current_response,
agent=self.name
)
success, result, error = self._validate_with_guardrail(current_response)
# Process guardrail
guardrail_result = self._process_guardrail(task_output)
if guardrail_result.success:
if success:
logging.info(f"Agent {self.name}: Guardrail validation passed")
# Return the potentially modified result
if guardrail_result.result and hasattr(guardrail_result.result, 'raw'):
return guardrail_result.result.raw
elif guardrail_result.result:
return str(guardrail_result.result)
else:
return current_response
return result
# Guardrail failed
if retry_count >= self.max_guardrail_retries:
raise Exception(
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
f"Last error: {guardrail_result.error}"
f"Last error: {error}"
)
retry_count += 1
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {guardrail_result.error}")
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
# Regenerate response for retry
try:
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {guardrail_result.error}. Please provide an improved response."
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
response = self._chat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
if response and response.choices:
content = response.choices[0].message.content
@@ -4678,10 +4798,46 @@ Answer:"""
raise Exception("Failed to generate retry response")
except Exception as e:
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
# If we can't regenerate, fail the guardrail
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
return current_response
async def _aapply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
"""Apply guardrail validation with retry logic (async version)."""
retry_count = 0
current_response = response_text
while retry_count <= self.max_guardrail_retries:
success, result, error = self._validate_with_guardrail(current_response)
if success:
logging.info(f"Agent {self.name}: Guardrail validation passed")
return result
# Guardrail failed
if retry_count >= self.max_guardrail_retries:
raise Exception(
f"Agent {self.name} guardrail retry failed: {e}"
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
f"Last error: {error}"
)
retry_count += 1
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
# Regenerate response for retry (async version)
try:
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
response = await self._execute_unified_achat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
if response and hasattr(response, 'choices') and response.choices:
content = response.choices[0].message.content
current_response = content.strip() if content else ""
elif isinstance(response, str):
current_response = response.strip()
else:
raise Exception("Failed to generate retry response")
except Exception as e:
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
return current_response
@@ -2079,7 +2079,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Apply guardrail validation for custom LLM response
try:
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
@@ -2187,22 +2187,216 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
task_id=task_id,
response_format=response_format
)
return await self._finalize_unified_achat_response(
response=response,
original_prompt=original_prompt,
temperature=temperature,
tools=tools,
output_json=output_json,
output_pydantic=output_pydantic,
reasoning_steps=reasoning_steps,
stream=stream,
messages=messages,
chat_history_length=chat_history_length,
start_time=start_time,
task_name=task_name,
task_description=task_description,
task_id=task_id,
)
# Process response - mirror sync _chat_impl behavior (lines ~1544-1602)
if not response:
# Rollback chat history on response failure
self._truncate_chat_history(chat_history_length)
return None
# Extract response content using the same method as sync
response_text = self._extract_llm_response_content(response)
if isinstance(response_text, str):
response_text = response_text.strip()
# Handle output_json or output_pydantic if specified
if output_json or output_pydantic:
# Add to chat history and return raw response
# User message already added before LLM call via _build_messages
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB
self._persist_message("assistant", response_text)
# Apply guardrail validation even for JSON output
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
# For regular responses (no self-reflection)
if not self.self_reflect:
# User message already added before LLM call via _build_messages
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB (non-reflect path)
self._persist_message("assistant", response_text)
if self.verbose:
logging.debug(f"Agent {self.name} final response: {response_text}")
# Return only reasoning content if reasoning_steps is True
if reasoning_steps and hasattr(response, 'choices') and response.choices and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
# Apply guardrail to reasoning content
try:
validated_reasoning = await self._aapply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
else:
# Apply guardrail to regular response content
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
# If self-reflection is enabled, implement reflection logic
if self.self_reflect:
# Implement async self-reflection similar to sync path
reflection_prompt = f"""
Reflect on your previous response: '{response_text}'.
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
Provide a "satisfactory" status ('yes' or 'no').
Output MUST be JSON with 'reflection' and 'satisfactory'.
"""
# Add reflection prompt to messages
reflection_messages = messages + [
{"role": "assistant", "content": response_text},
{"role": "user", "content": reflection_prompt}
]
reflection_count = 0
while True:
try:
# Check if OpenAI client is available for self-reflection
if self._openai_client is None:
# For custom LLMs, self-reflection with structured output is not supported
if self.verbose:
_get_display_functions()['display_self_reflection'](f"Agent {self.name}: Self-reflection with structured output is not supported for custom LLM providers. Skipping reflection.", console=self.console)
# Return the original response without reflection
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB
self._persist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
model=self.reflect_llm if self.reflect_llm else self.llm,
messages=reflection_messages,
temperature=temperature,
response_format=_get_display_functions()['ReflectionOutput']
)
reflection_output = reflection_response.choices[0].message.parsed
if self.verbose:
_get_display_functions()['display_self_reflection'](f"Agent {self.name} self reflection (using {self.reflect_llm if self.reflect_llm else self.llm}): reflection='{reflection_output.reflection}' satisfactory='{reflection_output.satisfactory}'", console=self.console)
# Only consider satisfactory after minimum reflections
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
if self.verbose:
_get_display_functions()['display_self_reflection']("Agent marked the response as satisfactory after meeting minimum reflections", console=self.console)
# Add to chat history and return
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB
self._persist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
# Check if we've hit max reflections
if reflection_count >= self.max_reflect - 1:
if self.verbose:
_get_display_functions()['display_self_reflection']("Maximum reflection count reached, returning current response", console=self.console)
# Add to chat history and return
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB
self._persist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as e:
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
# Regenerate response based on reflection
regenerate_messages = reflection_messages + [
{"role": "assistant", "content": f"Self Reflection: {reflection_output.reflection} Satisfactory?: {reflection_output.satisfactory}"},
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
]
new_response = await self._execute_unified_achat_completion(
messages=regenerate_messages,
temperature=temperature,
tools=formatted_tools,
stream=stream,
reasoning_steps=reasoning_steps,
task_name=task_name,
task_description=task_description,
task_id=task_id,
response_format=response_format
)
if new_response:
new_response_text = self._extract_llm_response_content(new_response)
if isinstance(new_response_text, str):
response_text = new_response_text.strip()
# Update reflection_messages to include the new response for next iteration
reflection_messages = regenerate_messages + [
{"role": "assistant", "content": response_text}
]
reflection_count += 1
except Exception as e:
if self.verbose:
_get_display_functions()['display_error'](f"Error in parsing self-reflection json {e}. Retrying", console=self.console)
logging.error("Reflection parsing failed.", exc_info=True)
reflection_count += 1
if reflection_count >= self.max_reflect:
# Return original response after max reflection attempts
self._append_to_chat_history({"role": "assistant", "content": response_text})
# Persist assistant message to DB
self._persist_message("assistant", response_text)
try:
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
except Exception as guard_e:
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
# Rollback chat history on guardrail failure
self._truncate_chat_history(chat_history_length)
return None
continue
# This should never be reached due to the returns above
# But adding as safety fallback
logging.warning(f"Agent {self.name}: Unexpected code path reached in unified dispatch")
return None
else:
# LEGACY: Check if OpenAI client is available
if self._openai_client is None:
@@ -2339,7 +2533,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Apply guardrail validation for OpenAI client response
try:
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
# Execute callback after validation
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
@@ -403,9 +403,33 @@ class MemoryMixin:
self._session_store.add_user_message(self._session_id, content)
elif role == "assistant":
self._session_store.add_assistant_message(self._session_id, content)
self._persist_session_stats()
except Exception as e:
logging.warning(f"Failed to persist message to session store: {e}")
def _persist_session_stats(self):
"""Flush agent cost/token stats into session JSON metadata."""
store = getattr(self, "_session_store", None)
session_id = getattr(self, "_session_id", None)
if store is None or session_id is None:
return
if not hasattr(store, "update_session_metadata"):
return
try:
summary = getattr(self, "cost_summary", {}) or {}
llm = getattr(self, "llm", None)
model = str(llm) if llm is not None else None
store.update_session_metadata(
session_id,
model=model,
total_tokens=summary.get("tokens_in", 0) + summary.get("tokens_out", 0),
cost=summary.get("cost", 0),
source=getattr(self, "_session_source", "chat"),
agent_id=getattr(self, "agent_id", None) or getattr(self, "_agent_id", None),
)
except Exception as e:
logging.debug(f"Session stats persist skipped: {e}")
def session_id(self) -> Optional[str]:
"""Get the current session ID."""
return self._session_id
@@ -26,6 +26,21 @@ if TYPE_CHECKING:
class ToolExecutionMixin:
"""Mixin providing toolexecution methods for the Agent class."""
def _get_existing_stream_emitter(self):
"""Return an already-initialized stream emitter without creating one."""
emitter = getattr(self, "_stream_emitter", None)
if emitter is not None:
return emitter
# Support name-mangled private attributes across class renames/inheritance.
for cls in type(self).mro():
mangled = f"_{cls.__name__}__stream_emitter"
if hasattr(self, mangled):
emitter = getattr(self, mangled, None)
if emitter is not None:
return emitter
return None
def _resolve_tool_names(self, tool_names):
"""Resolve tool names to actual tool instances from registry.
@@ -160,8 +175,9 @@ class ToolExecutionMixin:
# Emit TOOL_CALL_START to stream_emitter (for AIUI/AG-UI consumers)
# Zero overhead when no callbacks registered
if hasattr(self, '_Agent__stream_emitter') and getattr(self, "_Agent__stream_emitter", None) is not None and getattr(self, "_Agent__stream_emitter", None).has_callbacks:
getattr(self, "_Agent__stream_emitter", None).emit(StreamEvent(
_stream_emitter = self._get_existing_stream_emitter()
if _stream_emitter is not None and _stream_emitter.has_callbacks:
_stream_emitter.emit(StreamEvent(
type=StreamEventType.TOOL_CALL_START,
timestamp=_tool_start_perf,
tool_call={
@@ -275,10 +291,10 @@ class ToolExecutionMixin:
# Emit TOOL_CALL_RESULT to stream_emitter (for AIUI/AG-UI consumers)
# Zero overhead when no callbacks registered
if hasattr(self, '_Agent__stream_emitter') and getattr(self, "_Agent__stream_emitter", None) is not None and getattr(self, "_Agent__stream_emitter", None).has_callbacks:
if _stream_emitter is not None and _stream_emitter.has_callbacks:
# Truncate result for stream event (keep it reasonable for UI display)
result_summary = str(result)[:500] if result else None
getattr(self, "_Agent__stream_emitter", None).emit(StreamEvent(
_stream_emitter.emit(StreamEvent(
type=StreamEventType.TOOL_CALL_RESULT,
timestamp=_time.perf_counter(),
tool_call={
@@ -290,6 +306,18 @@ class ToolExecutionMixin:
agent_id=self.name,
metadata={"duration_ms": _duration_ms},
))
_stream_emitter.emit(StreamEvent(
type=StreamEventType.TOOL_CALL_END,
timestamp=_time.perf_counter(),
tool_call={
"name": function_name,
"arguments": arguments,
"result": result_summary,
"id": tool_call_id,
},
agent_id=self.name,
metadata={"duration_ms": _duration_ms},
))
# Trigger AFTER_TOOL hook
from ..hooks import HookEvent, AfterToolInput
@@ -926,4 +954,3 @@ class ToolExecutionMixin:
def pending_approval_count(self) -> int:
"""Number of approval requests still waiting."""
return len(self._pending_approvals)
@@ -15,9 +15,9 @@ from enum import Enum
# Import token tracking
try:
from ..telemetry.token_collector import _token_collector
from ..telemetry.token_collector import get_token_collector
except ImportError:
_token_collector = None
get_token_collector = None
# Import async utility for hot-path usage
try:
@@ -1609,25 +1609,25 @@ class AgentTeam:
original_on_task_start = self.on_task_start
original_on_task_complete = self.on_task_complete
def composed_on_task_start(task):
def composed_on_task_start(task, task_id):
try:
verbose_task_start_callback(task)
verbose_task_start_callback(task, task_id)
except Exception as e:
logging.debug(f"Error in verbose task start callback: {e}")
if original_on_task_start:
try:
original_on_task_start(task)
original_on_task_start(task, task_id)
except Exception as e:
logging.debug(f"Error in original task start callback: {e}")
def composed_on_task_complete(task):
def composed_on_task_complete(task, task_output):
try:
verbose_task_complete_callback(task)
verbose_task_complete_callback(task, task_output)
except Exception as e:
logging.debug(f"Error in verbose task complete callback: {e}")
if original_on_task_complete:
try:
original_on_task_complete(task)
original_on_task_complete(task, task_output)
except Exception as e:
logging.debug(f"Error in original task complete callback: {e}")
@@ -1826,18 +1826,19 @@ class AgentTeam:
def get_token_usage_summary(self) -> Dict[str, Any]:
"""Get a summary of token usage across all agents and tasks."""
if not _token_collector:
if not get_token_collector:
return {"error": "Token tracking not available"}
return _token_collector.get_session_summary()
return get_token_collector().get_session_summary()
def get_detailed_token_report(self) -> Dict[str, Any]:
"""Get a detailed token usage report."""
if not _token_collector:
if not get_token_collector:
return {"error": "Token tracking not available"}
summary = _token_collector.get_session_summary()
recent = _token_collector.get_recent_interactions(limit=20)
collector = get_token_collector()
summary = collector.get_session_summary()
recent = collector.get_recent_interactions(limit=20)
# Calculate cost estimates (example rates)
cost_per_1k_input = 0.0005 # $0.0005 per 1K input tokens
@@ -1861,11 +1862,11 @@ class AgentTeam:
def display_token_usage(self):
"""Display token usage in a formatted table."""
if not _token_collector:
if not get_token_collector:
print("Token tracking not available")
return
summary = _token_collector.get_session_summary()
summary = get_token_collector().get_session_summary()
print("\n" + "="*50)
print("TOKEN USAGE SUMMARY")
@@ -0,0 +1,13 @@
"""Hooks query protocol for UI/API consumers."""
from __future__ import annotations
from typing import Dict, List, Protocol, runtime_checkable
@runtime_checkable
class HooksQueryProtocol(Protocol):
"""Read path for registered hooks."""
def list_hooks_for_api(self) -> List[Dict]:
...
@@ -439,3 +439,13 @@ def has_hook(event: Union[str, HookEvent]) -> bool:
)
return get_default_registry().has_hooks(event)
def list_hooks_for_api() -> List[Dict]:
"""Flat hook list suitable for REST/UI consumers."""
registry = get_default_registry()
flat: List[Dict] = []
for event, hooks in registry.list_hooks().items():
for h in hooks:
flat.append({"event": event, "source": "sdk", **h})
return flat
@@ -68,6 +68,14 @@ class HookEvent(str, Enum):
SCHEDULE_REMOVE = "schedule_remove"
SCHEDULE_TRIGGER = "schedule_trigger"
# Kanban task lifecycle (wrapper dispatcher + tools emit these)
KANBAN_TASK_CREATED = "kanban_task_created"
KANBAN_TASK_CLAIMED = "kanban_task_claimed"
KANBAN_TASK_MOVED = "kanban_task_moved"
KANBAN_TASK_DONE = "kanban_task_done"
KANBAN_TASK_BLOCKED = "kanban_task_blocked"
KANBAN_TASK_FAILED = "kanban_task_failed"
# Claude Code parity events
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
NOTIFICATION = "notification" # When notification is sent
@@ -101,6 +109,34 @@ class HookInput:
}
@dataclass
class KanbanHookInput(HookInput):
"""Hook input for kanban task lifecycle events.
Used by wrapper dispatcher and tools when emitting kanban events.
Observability adapters can subscribe to these via the hook registry.
"""
task_id: str = ""
board: str = "default"
status: str = ""
assignee: Optional[str] = field(default=None)
from_status: Optional[str] = field(default=None)
to_status: Optional[str] = field(default=None)
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
result = super().to_dict()
result.update({
"task_id": self.task_id,
"board": self.board,
"status": self.status,
"assignee": self.assignee,
"from_status": self.from_status,
"to_status": self.to_status,
})
return result
@dataclass
class HookOutput:
"""Base hook output - common fields for all events."""
@@ -0,0 +1,23 @@
"""
Kanban protocols and types for PraisonAI Agents.
This module provides the protocol contracts for kanban functionality,
allowing the wrapper (praisonai) and PraisonAIUI to share a stable
interface without coupling the core to SQLite or other heavy implementations.
"""
from praisonaiagents.kanban.protocols import (
KanbanStoreProtocol,
KanbanTaskProtocol,
KanbanCommentingProtocol,
KanbanLinkingProtocol,
VALID_KANBAN_STATUSES,
)
__all__ = [
"KanbanStoreProtocol",
"KanbanTaskProtocol",
"KanbanCommentingProtocol",
"KanbanLinkingProtocol",
"VALID_KANBAN_STATUSES",
]
@@ -0,0 +1,221 @@
"""
Kanban protocols for PraisonAI Agents.
Defines the protocol contracts that the wrapper (praisonai) and PraisonAIUI
can use to implement kanban functionality with a stable interface.
This follows AGENTS.md §3.2 Protocol-First Design:
- Protocols define WHAT (interface contract)
- Implementations define HOW (concrete behavior)
- Core SDK has protocols only, wrapper has heavy implementations
"""
from typing import Protocol, TypedDict, runtime_checkable
# Valid kanban statuses matching PraisonAIUI columns + archived
VALID_KANBAN_STATUSES: frozenset[str] = frozenset([
"triage",
"todo",
"ready",
"running",
"blocked",
"review",
"done",
"archived"
])
class KanbanTaskProtocol(TypedDict, total=False):
"""Typed dict shape for kanban task fields.
Defines the expected structure of task objects returned by
KanbanStoreProtocol implementations.
"""
id: str
title: str
body: str
status: str
assignee: str | None
priority: str | None
tenant: str | None
board: str
created_at: float
updated_at: float
@runtime_checkable
class KanbanStoreProtocol(Protocol):
"""Protocol contract for kanban store implementations.
This protocol defines the core interface that PraisonAIUI expects
for injected kanban stores. Wrapper implementations must
implement all methods to be compatible.
Duck typing contract matches InjectedKanbanStore in PraisonAIUI.
"""
def get_board(
self,
*,
board: str = "default",
tenant: str | None = None,
include_archived: bool = False,
) -> dict:
"""Get board data with tasks grouped by status.
Args:
board: Board name (default: "default")
tenant: Optional tenant filter
include_archived: Whether to include archived tasks
Returns:
Board data with tasks organized by status columns
"""
...
def get_task(self, task_id: str) -> dict | None:
"""Get a single task by ID.
Args:
task_id: Task identifier
Returns:
Task data or None if not found
"""
...
def create_task(self, data: dict) -> dict:
"""Create a new task.
Args:
data: Task creation data (title, body, status, etc.)
Returns:
Created task data with assigned ID
"""
...
def update_task(self, task_id: str, data: dict) -> dict | None:
"""Update an existing task.
Args:
task_id: Task identifier
data: Fields to update
Returns:
Updated task data or None if not found
"""
...
def move_task(self, task_id: str, status: str) -> dict | None:
"""Move a task to a different status.
Args:
task_id: Task identifier
status: New status (must be in VALID_KANBAN_STATUSES)
Returns:
Updated task data or None if not found
"""
...
def bulk_update(self, task_ids: list[str], status: str) -> dict:
"""Update multiple tasks to the same status.
Args:
task_ids: List of task identifiers
status: New status for all tasks
Returns:
Results summary with success/failure counts
"""
...
def delete_task(self, task_id: str) -> bool:
"""Delete a task.
Args:
task_id: Task identifier
Returns:
True if deleted, False if not found
"""
...
def list_events(self, since: float = 0.0, board: str = "default") -> list[dict]:
"""List kanban events since a timestamp.
Args:
since: Unix timestamp to filter events from
board: Board name to filter events
Returns:
List of event data
"""
...
def health(self) -> dict:
"""Check store health status.
Returns:
Health status information
"""
...
@runtime_checkable
class KanbanCommentingProtocol(Protocol):
"""Extension protocol for kanban task commenting functionality.
This protocol is implemented separately from KanbanStoreProtocol to allow
stores to optionally support commenting without breaking isinstance checks
on the core protocol.
"""
def add_comment(self, task_id: str, text: str, author: str | None = None) -> dict | None:
"""Add a comment to a task.
Args:
task_id: Task identifier
text: Comment text
author: Optional comment author
Returns:
Comment data or None if task not found
"""
...
@runtime_checkable
class KanbanLinkingProtocol(Protocol):
"""Extension protocol for kanban task linking functionality.
This protocol is implemented separately from KanbanStoreProtocol to allow
stores to optionally support task relationships without breaking isinstance
checks on the core protocol.
"""
def link_tasks(self, parent_id: str, child_id: str) -> bool:
"""Link two tasks in parent-child relationship.
Args:
parent_id: Parent task identifier
child_id: Child task identifier
Returns:
True if linked successfully, False otherwise
"""
...
def unlink_tasks(self, parent_id: str, child_id: str) -> bool:
"""Unlink parent-child task relationship.
Args:
parent_id: Parent task identifier
child_id: Child task identifier
Returns:
True if unlinked successfully, False otherwise
"""
...
@@ -174,7 +174,14 @@ class ChromaKnowledgeAdapter:
# Add where filter if any conditions specified
if where_filter:
query_kwargs["where"] = where_filter
# ChromaDB validation expects exactly one condition in the where clause
# When multiple conditions exist, use $and operator to combine them
if len(where_filter) == 1:
query_kwargs["where"] = where_filter
else:
# Convert multiple conditions to ChromaDB's $and format
and_conditions = [{k: v} for k, v in where_filter.items()]
query_kwargs["where"] = {"$and": and_conditions}
response = self.collection.query(**query_kwargs)
@@ -197,6 +204,10 @@ class ChromaKnowledgeAdapter:
return SearchResult(results=items)
except Exception as e:
# Log ChromaDB query errors for better debugging
import logging
logger = logging.getLogger(__name__)
logger.warning(f"ChromaDB search failed: {e}")
return SearchResult(results=[])
def add(self, content: Any, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
@@ -73,10 +73,10 @@ def _get_live():
# Import token tracking
try:
from ..telemetry.token_collector import TokenMetrics, _token_collector
from ..telemetry.token_collector import TokenMetrics, get_token_collector
except ImportError:
TokenMetrics = None
_token_collector = None
get_token_collector = None
# Logging is already configured in _logging.py via __init__.py
@@ -4519,7 +4519,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
def _track_token_usage(self, response: Dict[str, Any], model: str) -> Optional[TokenMetrics]:
"""Extract and track token usage from LLM response."""
if not TokenMetrics or not _token_collector:
if not TokenMetrics or not get_token_collector:
return None
# Note: metrics check moved to call sites for performance
@@ -4551,7 +4551,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
# Track in global collector
# Extract provider from model string (e.g., 'openai' from 'openai/gpt-4o-mini')
provider = model.split('/')[0] if '/' in model else 'litellm'
_token_collector.track_tokens(
get_token_collector().track_tokens(
model=model,
agent=self.current_agent_name,
metrics=metrics,
@@ -6,7 +6,13 @@ No heavy imports - only stdlib.
"""
import hashlib
from typing import Any, Dict, List, Optional, Set, Tuple
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
if TYPE_CHECKING:
from ..knowledge.models import SearchResultItem
ResultItem = Union[Dict[str, Any], "SearchResultItem"]
def _estimate_tokens(text: str) -> int:
@@ -19,6 +25,39 @@ def _estimate_tokens(text: str) -> int:
return len(text) // 4 + 1
def _extract_value(item: ResultItem, key: str, default: Any = None) -> Any:
"""
Extract value from either dict or object (e.g., SearchResultItem).
Args:
item: Dictionary or object with attributes
key: Key/attribute name to extract
default: Default value if key not found
Returns:
Extracted value or default
"""
if isinstance(item, dict):
return item.get(key, default)
else:
return getattr(item, key, default)
def _extract_metadata_value(
item: ResultItem,
metadata: Dict[str, Any],
key: str,
default: Any = "",
) -> Any:
"""
Extract metadata value with fallback to top-level item attribute.
"""
value = metadata.get(key)
if value is None:
return _extract_value(item, key, default)
return value
def _chunk_hash(text: str, source: Optional[str] = None) -> str:
"""
Generate stable hash for chunk deduplication.
@@ -35,31 +74,38 @@ def _chunk_hash(text: str, source: Optional[str] = None) -> str:
def deduplicate_chunks(
results: List[Dict[str, Any]],
results: List[ResultItem],
similarity_threshold: float = 0.9,
) -> List[Dict[str, Any]]:
) -> List[ResultItem]:
"""
Deduplicate chunks by content hash.
Handles both dict format and SearchResultItem objects.
Args:
results: List of search results with 'text' or 'memory' key
results: List of search results (dicts or SearchResultItem objects)
similarity_threshold: Not used currently (hash-based dedup)
Returns:
Deduplicated list of results
"""
seen_hashes: Set[str] = set()
unique_results: List[Dict[str, Any]] = []
unique_results: List[ResultItem] = []
for result in results:
# Skip None results
if result is None:
continue
# Handle different result formats
text = result.get("text") or result.get("memory", "")
# Handle different result formats (dict or SearchResultItem)
text = _extract_value(result, "text") or _extract_value(result, "memory", "")
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
metadata = result.get("metadata") or {}
source = metadata.get("source", "")
metadata = _extract_value(result, "metadata") or {}
if not isinstance(metadata, dict):
metadata = {}
source = _extract_metadata_value(result, metadata, "source", "")
chunk_id = _chunk_hash(text, source)
@@ -106,17 +152,19 @@ def truncate_context(
def build_context(
results: List[Dict[str, Any]],
results: List[ResultItem],
max_tokens: int = 4000,
deduplicate: bool = True,
separator: str = "\n\n---\n\n",
include_source: bool = True,
) -> Tuple[str, List[Dict[str, Any]]]:
) -> Tuple[str, List[ResultItem]]:
"""
Build context string from retrieval results.
Handles both dict format and SearchResultItem objects.
Args:
results: List of search results
results: List of search results (dicts or SearchResultItem objects)
max_tokens: Maximum tokens for context
deduplicate: Whether to deduplicate chunks
separator: Separator between chunks
@@ -133,7 +181,7 @@ def build_context(
results = deduplicate_chunks(results)
context_parts: List[str] = []
used_results: List[Dict[str, Any]] = []
used_results: List[ResultItem] = []
current_tokens = 0
separator_tokens = _estimate_tokens(separator)
@@ -141,17 +189,21 @@ def build_context(
# Skip None results
if result is None:
continue
# Handle different result formats
text = result.get("text") or result.get("memory", "")
# Handle different result formats (dict or SearchResultItem)
text = _extract_value(result, "text") or _extract_value(result, "memory", "")
if not text:
continue
# Build chunk text with optional source
if include_source:
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
metadata = result.get("metadata") or {}
source = metadata.get("source", "")
filename = metadata.get("filename", "")
metadata = _extract_value(result, "metadata") or {}
if not isinstance(metadata, dict):
metadata = {}
source = _extract_metadata_value(result, metadata, "source", "")
filename = _extract_metadata_value(result, metadata, "filename", "")
source_label = filename or source or f"Source {i + 1}"
chunk_text = f"[{source_label}]\n{text}"
else:
@@ -194,11 +246,14 @@ class DefaultContextBuilder:
def build(
self,
results: List[Dict[str, Any]],
results: List[ResultItem],
max_tokens: int = 4000,
deduplicate: bool = True,
) -> str:
"""Build context string from results."""
"""Build context string from results.
Handles both dict format and SearchResultItem objects.
"""
context, _ = build_context(
results=results,
max_tokens=max_tokens,
@@ -0,0 +1,29 @@
# Scheduler — runner vs loop
## ScheduleRunner (stateless)
`ScheduleRunner` is **stateless**. Call `get_due_jobs()` whenever you want to check for jobs that should fire. It does not run background threads.
Use when:
- CLI one-shot tick (`praisonai schedule tick`)
- Host integration that polls on its own timer
- Tests
## ScheduleLoop (optional daemon)
`ScheduleLoop` is an **optional background daemon**. You must call `.start()` explicitly; it polls the runner and invokes `on_trigger` callbacks.
Use when:
- Long-running host process (Pattern B/C via `setup_bridges()`)
- Standalone scheduler service
```python
from praisonaiagents.scheduler import FileScheduleStore, ScheduleRunner, ScheduleLoop
store = FileScheduleStore()
runner = ScheduleRunner(store)
loop = ScheduleLoop(runner)
loop.start() # optional daemon
```
The wrapper host calls `ensure_schedule_runner()` which starts `ScheduleLoop` when the scheduler optional dependency is available.
@@ -32,7 +32,22 @@ When you provide a `session_id` to an Agent:
### Default Storage Location
Sessions are stored in: `~/.praison/sessions/{session_id}.json`
Sessions are stored in: `~/.praisonai/sessions/{session_id}.json`
### Session metadata contract (Issue #48)
List endpoints and UI dashboards may expose these optional root-level fields:
| Field | Type | Description |
|-------|------|-------------|
| `model` | string | LLM model used in the session |
| `total_tokens` | int | Cumulative input+output tokens |
| `cost` | float | Estimated USD cost |
| `agent_id` | string | Gateway or registry agent id |
| `source` | string | Origin: `chat`, `gateway`, `cli`, `api` |
| `agent_name` | string | Human-readable agent name |
Fields are persisted in session JSON `metadata` and mirrored at root on save. Populated automatically after each assistant turn when using the default session store.
### Session File Format
@@ -45,7 +60,13 @@ Sessions are stored in: `~/.praison/sessions/{session_id}.json`
],
"created_at": "2026-01-02T04:00:00+00:00",
"updated_at": "2026-01-02T04:01:00+00:00",
"agent_name": "Assistant"
"agent_name": "Assistant",
"agent_id": "agent-abc",
"source": "chat",
"model": "gpt-4o-mini",
"total_tokens": 128,
"cost": 0.0004,
"metadata": {}
}
```
@@ -144,6 +144,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."""
@@ -129,3 +129,16 @@ class SessionStoreProtocol(Protocol):
True if the session exists.
"""
...
@runtime_checkable
class CheckpointQueryProtocol(Protocol):
"""Read path for session checkpoints / rollback snapshots."""
def list_checkpoints(self, session_id: str) -> List[Dict[str, Any]]:
"""Return checkpoint metadata for a session."""
...
def get_checkpoint(self, session_id: str, checkpoint_id: str) -> Optional[Dict[str, Any]]:
"""Return a single checkpoint payload."""
...
@@ -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
@@ -81,7 +81,7 @@ class SessionData:
def to_dict(self) -> Dict[str, Any]:
"""Convert to dictionary for JSON serialization."""
return {
data = {
"session_id": self.session_id,
"messages": [m.to_dict() for m in self.messages],
"created_at": self.created_at,
@@ -92,6 +92,10 @@ class SessionData:
"gateway_session_id": self.gateway_session_id,
"agent_id": self.agent_id,
}
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source"):
if key in self.metadata:
data[key] = self.metadata[key]
return data
@classmethod
def from_dict(cls, data: Dict[str, Any]) -> "SessionData":
@@ -137,7 +141,8 @@ class FileLock:
self._lock_path = filepath + ".lock"
def __enter__(self):
self.acquire()
if not self.acquire():
raise IOError(f"Failed to acquire file lock for {self.filepath} after {self.timeout}s")
return self
def __exit__(self, exc_type, exc_val, exc_tb):
@@ -286,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."""
@@ -435,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,
@@ -450,27 +521,42 @@ 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."""
if not fields:
return True
def _apply(session: SessionData) -> None:
for key, value in fields.items():
if value is None:
continue
session.metadata[key] = value
if key in ("agent_id", "agent_name", "user_id"):
setattr(session, key, value)
return self._modify_session_locked(
session_id, _apply, error_label="update session metadata"
)
def delete_session(self, session_id: str) -> bool:
"""Delete a session completely."""
filepath = self._get_session_path(session_id)
@@ -500,10 +586,16 @@ class DefaultSessionStore:
data = json.load(f)
sessions.append({
"session_id": data.get("session_id", filename[:-5]),
"id": data.get("session_id", filename[:-5]),
"agent_name": data.get("agent_name"),
"agent_id": data.get("agent_id") or (data.get("metadata") or {}).get("agent_id"),
"source": data.get("source") or (data.get("metadata") or {}).get("source"),
"created_at": data.get("created_at"),
"updated_at": data.get("updated_at"),
"message_count": len(data.get("messages", [])),
"model": data.get("model") or data.get("llm") or (data.get("metadata") or {}).get("model"),
"total_tokens": data.get("total_tokens") or data.get("token_count") or (data.get("metadata") or {}).get("total_tokens"),
"cost": data.get("cost") or (data.get("metadata") or {}).get("cost"),
})
except (json.JSONDecodeError, IOError):
continue
@@ -563,7 +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,
@@ -617,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),
)
@@ -7,7 +7,7 @@ plugin bundle, enterprise policy) without touching the core.
from __future__ import annotations
from typing import Protocol, Iterable, Optional, runtime_checkable
from typing import Protocol, Iterable, Optional, runtime_checkable, List, Dict, Any
from .models import SkillProperties
@@ -45,7 +45,8 @@ class SkillMutatorProtocol(Protocol):
"""Agent-managed skill CRUD operations.
Allows agents to create, edit, and manage their own skills at runtime.
Implementations should provide safe-by-default behavior (e.g., propose mode).
Implementations should provide safe-by-default behaviour: use ``propose=True``
(default) to stage mutations for human approval before writing to disk.
"""
def create(self, name: str, content: str, category: Optional[str] = None,
@@ -163,3 +164,30 @@ class SkillMutatorProtocol(Protocol):
Status message describing the action taken
"""
...
@runtime_checkable
class SkillsCatalogProtocol(Protocol):
"""Metadata listing for skills — UI catalog pages."""
def list_skills(self) -> List[Dict[str, Any]]:
"""Return skill metadata dicts (name, description, location)."""
...
def list_skills_for_api() -> List[Dict[str, Any]]:
"""Default catalog adapter using SkillManager when available."""
try:
from .manager import SkillManager
manager = SkillManager()
return [
{
"name": s.name,
"description": s.description,
"location": getattr(s, "location", ""),
}
for s in manager.get_available_skills()
]
except Exception:
return []
@@ -191,9 +191,11 @@ def validate(skill_dir: Path, strict: bool = False) -> List[str]:
return ["Missing required file: SKILL.md"]
try:
content = skill_md.read_text()
content = skill_md.read_text(encoding="utf-8")
metadata, _ = parse_frontmatter(content)
except ParseError as e:
return [str(e)]
except UnicodeDecodeError as e:
return [f"SKILL.md is not valid UTF-8: {e}"]
return validate_metadata(metadata, skill_dir, strict=strict)
@@ -100,3 +100,107 @@ class InMemoryTokenUsageSink:
def clear(self) -> None:
"""Clear all records."""
self.records.clear()
@runtime_checkable
class UsageQueryProtocol(Protocol):
"""Read path for token usage — UI dashboards and analytics."""
def get_summary(self) -> Dict[str, Any]:
"""Aggregate totals and breakdowns by model/agent."""
...
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
"""Recent usage records, newest last."""
...
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
...
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
...
class InMemoryUsageQuery:
"""Query adapter backed by InMemoryTokenUsageSink records."""
def __init__(self, sink: InMemoryTokenUsageSink):
self._sink = sink
def get_summary(self) -> Dict[str, Any]:
records = self._sink.records
total_in = 0
total_out = 0
total_all = 0
by_model: Dict[str, Dict[str, int]] = {}
by_agent: Dict[str, Dict[str, int]] = {}
# Single-pass optimization: calculate everything in one loop
for r in records:
r_in = r.get("input_tokens", 0)
r_out = r.get("output_tokens", 0)
r_total = r.get("total_tokens", r_in + r_out)
total_in += r_in
total_out += r_out
total_all += r_total
model = r.get("model", "unknown")
agent = r.get("agent_name", "unknown")
# Update by_model and by_agent efficiently
for bucket, key in ((by_model, model), (by_agent, agent)):
stats = bucket.setdefault(key, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
stats["input_tokens"] += r_in
stats["output_tokens"] += r_out
stats["total_tokens"] += r_total
return {
"total_requests": len(records),
"total_input_tokens": total_in,
"total_output_tokens": total_out,
"total_tokens": total_all, # Use consistent calculation
"by_model": by_model,
"by_agent": by_agent,
}
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
return self._sink.records[-limit:]
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
return self._sink.get_by_task(task_id)
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
return self._sink.get_by_agent(agent_name)
class TokenCollectorUsageQuery:
"""Query adapter reading from the global TokenCollector + optional sink."""
def __init__(self, collector: Any):
self._collector = collector
def get_summary(self) -> Dict[str, Any]:
summary = self._collector.get_session_summary()
return {
"total_requests": summary.get("total_interactions", 0),
"total_input_tokens": summary.get("total_metrics", {}).get("input_tokens", 0),
"total_output_tokens": summary.get("total_metrics", {}).get("output_tokens", 0),
"total_tokens": summary.get("total_tokens", 0),
"by_model": summary.get("by_model", {}),
"by_agent": summary.get("by_agent", {}),
}
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
return self._collector.get_recent_interactions(limit)
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
sink = getattr(self._collector, "_sink", None)
if sink and hasattr(sink, "get_by_task"):
return sink.get_by_task(task_id)
return []
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
sink = getattr(self._collector, "_sink", None)
if sink and hasattr(sink, "get_by_agent"):
return sink.get_by_agent(agent_name)
return []
@@ -191,4 +191,9 @@ class TokenCollector:
# Global token collector instance
_token_collector = TokenCollector()
_token_collector = TokenCollector()
def get_token_collector() -> TokenCollector:
"""Return the global TokenCollector singleton."""
return _token_collector
@@ -7,7 +7,7 @@ import logging
from praisonaiagents._logging import get_logger
# Import dependencies
from .token_collector import _token_collector, TokenMetrics
from .token_collector import get_token_collector, TokenMetrics
from .telemetry import get_telemetry
logger = get_logger(__name__)
@@ -56,11 +56,12 @@ class TokenTelemetryBridge:
def export_token_metrics(self) -> Dict[str, Any]:
"""Export token metrics for telemetry reporting."""
if not _token_collector:
collector = get_token_collector()
if not collector:
return {}
try:
metrics = _token_collector.export_metrics()
metrics = collector.export_metrics()
# Prepare telemetry-friendly format
return {
@@ -77,8 +78,7 @@ class TokenTelemetryBridge:
def reset_token_metrics(self):
"""Reset token metrics collection."""
if _token_collector:
_token_collector.reset()
get_token_collector().reset()
# Global telemetry bridge instance
_token_telemetry_bridge = TokenTelemetryBridge()
@@ -14,10 +14,31 @@ import json
from typing import Any, Dict, List, Union
from praisonaiagents.tools.decorator import tool
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
def _unwrap_messages(messages: Union[List[Dict[str, Any]], Dict[str, Any], str]) -> List[Dict[str, Any]]:
"""Minimal unwrap for common LLM arg shapes (not full UI normalisation)."""
if isinstance(messages, str):
try:
messages = json.loads(messages)
except json.JSONDecodeError as exc:
raise ValueError(f"messages JSON string is not valid JSON: {exc}") from exc
if isinstance(messages, dict):
if "messages" in messages:
messages = messages["messages"]
else:
messages = [messages]
if not isinstance(messages, list):
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
return messages
@tool
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str, Any]:
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], Dict[str, Any], str]) -> Dict[str, Any]:
"""
Send validated A2UI JSON messages to a connected A2UI renderer client.
@@ -32,21 +53,14 @@ def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str,
"""
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part
if isinstance(messages, str):
try:
messages = json.loads(messages)
except json.JSONDecodeError as exc:
raise ValueError(f"messages JSON string is not valid JSON: {exc}") from exc
if not isinstance(messages, list):
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
messages = _unwrap_messages(messages)
part = create_a2ui_part({"messages": messages})
data = getattr(getattr(part, "root", part), "data", messages)
metadata = getattr(getattr(part, "root", part), "metadata", {}) or {}
return {
"mime_type": metadata.get("mimeType", "application/json+a2ui"),
"mime_type": metadata.get("mimeType", A2UI_MIME_TYPE),
"messages": messages,
"a2ui_part": data,
}
@@ -15,7 +15,7 @@ Usage:
"""
from abc import ABC, abstractmethod
from typing import Any, Dict, Optional, Type, get_type_hints
from typing import Any, Dict, List, Optional, Type, get_type_hints
import inspect
import logging
@@ -234,18 +234,81 @@ class BaseTool(ABC):
if getattr(self.run, '__isabstractmethod__', False):
errors.append("Tool must implement the 'run()' method")
# Check parameters schema is valid
# Check parameters schema is valid and OpenAI-compatible
if self.parameters:
if not isinstance(self.parameters, dict):
errors.append("'parameters' must be a dictionary")
elif "type" not in self.parameters:
errors.append("'parameters' must have a 'type' field")
elif "properties" not in self.parameters:
errors.append("'parameters' must have a 'properties' field for OpenAI compatibility")
# Validate schema structure for OpenAI compatibility
try:
schema = self.get_schema()
if not isinstance(schema, dict):
errors.append("get_schema() must return a dictionary")
elif schema.get("type") != "function":
errors.append("get_schema() must return schema with type='function'")
elif "function" not in schema:
errors.append("get_schema() must have 'function' key")
else:
func = schema["function"]
if not isinstance(func.get("parameters"), dict):
errors.append("Schema function.parameters must be a dictionary")
elif "properties" not in func["parameters"]:
errors.append("Schema function.parameters must have 'properties' for OpenAI compatibility")
except Exception as e:
errors.append(f"Schema generation failed: {e}")
if errors:
raise ToolValidationError(f"Tool '{self.name}' validation failed: {'; '.join(errors)}")
return True
def validate_schema_roundtrip(self) -> bool:
"""Validate that schema can round-trip through OpenAI-style serialization.
This ensures the tool schema is compatible with LLM providers that expect
OpenAI function calling format and that parameters can be properly parsed.
Raises:
ToolValidationError: If round-trip fails
Returns:
True if round-trip validation passes
"""
try:
import json
# Get the schema
schema = self.get_schema()
# Serialize to JSON (what gets sent to LLM)
serialized = json.dumps(schema)
# Deserialize back
deserialized = json.loads(serialized)
# Validate structure matches expectations
if deserialized != schema:
raise ToolValidationError(f"Schema serialization round-trip failed for tool '{self.name}'")
# Validate OpenAI format requirements
func_schema = deserialized.get("function", {})
parameters = func_schema.get("parameters", {})
if not isinstance(parameters.get("properties"), dict):
raise ToolValidationError(f"Tool '{self.name}' parameters.properties must be a dict for OpenAI compatibility")
if "required" in parameters and not isinstance(parameters["required"], list):
raise ToolValidationError(f"Tool '{self.name}' parameters.required must be a list")
return True
except Exception as e:
raise ToolValidationError(f"Schema round-trip validation failed for tool '{self.name}': {e}")
@classmethod
def validate_class(cls) -> bool:
"""Validate a tool class before instantiation.
@@ -282,7 +345,9 @@ def validate_tool(tool: Any) -> bool:
ToolValidationError: If validation fails
"""
if isinstance(tool, BaseTool):
return tool.validate()
tool.validate()
tool.validate_schema_roundtrip()
return True
if callable(tool):
# For plain functions, check they have a name
@@ -294,5 +359,67 @@ def validate_tool(tool: Any) -> bool:
raise ToolValidationError(f"Invalid tool type: {type(tool)}")
def validate_tool_schema_consistency(tools: List[Any]) -> bool:
"""Validate a list of tools for schema consistency with OpenAI format.
This function ensures all tools in a list can be properly serialized
and have consistent schema structures for use with LLM providers.
Args:
tools: List of tool objects to validate
Returns:
True if all tools are valid and consistent
Raises:
ToolValidationError: If validation fails
"""
if not tools:
return True
import json
from .decorator import get_tool_schema
schemas = []
for i, tool in enumerate(tools):
try:
# Validate individual tool
validate_tool(tool)
# Get schema from different tool types
if isinstance(tool, BaseTool):
schema = tool.get_schema()
elif hasattr(tool, 'get_schema') and callable(getattr(tool, 'get_schema')):
schema = tool.get_schema()
elif callable(tool):
schema = get_tool_schema(tool)
else:
raise ToolValidationError(f"Cannot extract schema from tool at index {i}: {type(tool)}")
if not schema:
raise ToolValidationError(f"Tool at index {i} returned empty schema")
# Validate JSON serialization
try:
json.dumps(schema)
except (TypeError, ValueError) as e:
raise ToolValidationError(f"Tool schema at index {i} is not JSON serializable: {e}")
schemas.append(schema)
except Exception as e:
raise ToolValidationError(f"Tool validation failed at index {i}: {e}")
# Check for duplicate tool names
names = set()
for schema in schemas:
name = schema.get("function", {}).get("name")
if name in names:
raise ToolValidationError(f"Duplicate tool name '{name}' found in tool list")
names.add(name)
return True
# For backward compatibility - tools can also just be functions
# The @tool decorator (in decorator.py) wraps functions into BaseTool instances
@@ -214,6 +214,13 @@ def tool(
availability=availability
)
# Validate the tool at creation time for early error detection
try:
tool_instance.validate()
tool_instance.validate_schema_roundtrip()
except Exception as e:
logging.warning(f"Tool validation warning for {tool_instance.name}: {e}")
# Register with global registry if available
try:
from .registry import get_registry
@@ -253,7 +253,7 @@ class A2A:
from praisonaiagents.ui.a2a.conversion import (
extract_user_input,
praisonai_to_a2a_message,
create_artifact,
create_response_artifact,
)
# Validate params
@@ -286,7 +286,8 @@ class A2A:
else:
raise RuntimeError("No agent or agents configured")
response_msg = praisonai_to_a2a_message(str(response), task_id=task_id)
artifact = create_artifact(str(response))
history = getattr(self.agent, "chat_history", None) if self.agent else None
artifact = create_response_artifact(str(response), chat_history=history)
self.task_store.add_to_history(task_id, response_msg)
self.task_store.add_artifact(task_id, artifact)
self.task_store.update_status(task_id, TaskState.COMPLETED)
@@ -322,7 +323,8 @@ class A2A:
response_msg = praisonai_to_a2a_message(
str(response), task_id=task.id
)
artifact = create_artifact(str(response))
history = getattr(self.agent, "chat_history", None) if self.agent else None
artifact = create_response_artifact(str(response), chat_history=history)
# Update task store
self.task_store.add_to_history(task.id, response_msg)
@@ -16,6 +16,7 @@ from praisonaiagents.ui.a2a.types import (
DataPart,
Artifact,
)
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
def a2a_to_praisonai_messages(messages: List[Message]) -> List[Dict[str, Any]]:
@@ -107,6 +108,75 @@ def praisonai_to_a2a_message(
)
def find_a2ui_tool_result(
chat_history: Optional[List[Dict[str, Any]]],
) -> Optional[Dict[str, Any]]:
"""Return the last A2UI tool result dict from agent chat history, if any."""
if not chat_history:
return None
for msg in reversed(chat_history):
if msg.get("role") != "tool":
continue
content = msg.get("content")
if content is None:
continue
data: Any = content if isinstance(content, dict) else None
if data is None:
try:
data = json.loads(content)
except (json.JSONDecodeError, TypeError):
continue
if isinstance(data, dict) and data.get("mime_type") == A2UI_MIME_TYPE:
return data
return None
def create_a2ui_artifact(
result: Dict[str, Any],
artifact_id: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
) -> Artifact:
"""Create an A2A Artifact with a DataPart for an A2UI tool result."""
art_id = artifact_id or f"art-{uuid.uuid4().hex[:12]}"
messages = result.get("messages") or []
a2ui_data = result.get("a2ui_part")
if not isinstance(a2ui_data, dict):
a2ui_data = {"messages": messages}
elif "messages" not in a2ui_data:
a2ui_data = {**a2ui_data, "messages": messages}
return Artifact(
artifact_id=art_id,
name=name,
description=description,
parts=[
DataPart(
data=a2ui_data,
metadata={"mimeType": result.get("mime_type", A2UI_MIME_TYPE)},
)
],
)
def create_response_artifact(
content: str,
chat_history: Optional[List[Dict[str, Any]]] = None,
artifact_id: Optional[str] = None,
name: Optional[str] = None,
description: Optional[str] = None,
) -> Artifact:
"""Build artifact from response text, preferring A2UI DataPart when present in history."""
a2ui = find_a2ui_tool_result(chat_history)
if a2ui:
return create_a2ui_artifact(
a2ui, artifact_id=artifact_id, name=name, description=description
)
return create_artifact(
content, artifact_id=artifact_id, name=name, description=description
)
def create_artifact(
content: str,
artifact_id: Optional[str] = None,
@@ -12,9 +12,12 @@ from praisonaiagents.ui.a2ui.adapter import (
is_a2ui_part,
parse_a2ui_response,
)
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE, A2UIToolResultProtocol
__all__ = [
"A2UI",
"A2UI_MIME_TYPE",
"A2UIToolResultProtocol",
"create_a2ui_part",
"generate_a2ui_system_prompt",
"get_schema_manager",
@@ -12,6 +12,7 @@ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Set
from praisonaiagents.ui.agui.types import (
BaseEvent,
CustomEvent,
TextMessageStartEvent,
TextMessageContentEvent,
TextMessageEndEvent,
@@ -26,6 +27,7 @@ from praisonaiagents.ui.agui.types import (
StepFinishedEvent,
StateSnapshotEvent,
)
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
@dataclass
@@ -292,13 +294,41 @@ def stream_event_to_agui_events(
if event.type == StreamEventType.TOOL_CALL_RESULT and event.tool_call:
tool_call_id = event.tool_call.get("id") or str(uuid.uuid4())
content = event.content if event.content is not None else str(event.tool_call.get("result", ""))
raw_result = event.tool_call.get("result")
content = event.content if event.content is not None else str(raw_result or "")
buffer.end_tool_call(tool_call_id)
return [create_tool_result_event(tool_call_id, str(content), message_id)]
events: List[BaseEvent] = [
create_tool_result_event(tool_call_id, str(content), message_id)
]
if isinstance(raw_result, dict) and raw_result.get("mime_type") == A2UI_MIME_TYPE:
events.append(
CustomEvent(
name="a2ui",
value={
"mime_type": A2UI_MIME_TYPE,
"messages": raw_result.get("messages", []),
"surface_id": _infer_a2ui_surface_id(raw_result),
},
)
)
return events
return []
def _infer_a2ui_surface_id(result: Dict[str, Any]) -> str:
"""Infer surface id from send_a2ui_messages result (inline, no UI-layer deps)."""
for msg in result.get("messages") or []:
if isinstance(msg, dict):
create = msg.get("createSurface")
if isinstance(create, dict):
sid = create.get("surfaceId") or create.get("id")
if sid:
return str(sid)
sid = result.get("surface_id") or result.get("surfaceId")
return str(sid) if sid else "main"
async def async_stream_agent_response(
agent,
user_input: str,
@@ -133,6 +133,13 @@ class StateSnapshotEvent(BaseEvent):
snapshot: Dict[str, Any]
class CustomEvent(BaseEvent):
"""Custom extension event (e.g. structured A2UI payloads)."""
type: Literal["CUSTOM"] = "CUSTOM"
name: str
value: Any
# Message Types
class FunctionCall(BaseModel):
"""Function call in a tool call."""
@@ -175,6 +182,7 @@ Event = Union[
ToolCallEndEvent,
ToolCallResultEvent,
StateSnapshotEvent,
CustomEvent,
RunStartedEvent,
RunFinishedEvent,
RunErrorEvent,
@@ -0,0 +1,26 @@
"""
UI protocol types — lightweight contracts for agent-to-UI integration.
Zero runtime cost: TypedDict and constants only. No optional dependencies.
"""
from __future__ import annotations
from typing import Any, Dict, List, TypedDict, Union
# Standard MIME type for A2UI payloads (Google A2UI / A2A transport).
A2UI_MIME_TYPE = "application/json+a2ui"
class A2UIToolResultProtocol(TypedDict, total=False):
"""
Output shape from ``send_a2ui_messages`` — the integrator contract for any UI.
Example:
if result.get("mime_type") == A2UI_MIME_TYPE:
render(result["messages"])
"""
mime_type: str
messages: List[Dict[str, Any]]
a2ui_part: Union[Dict[str, Any], List[Any]]
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "praisonaiagents"
version = "1.6.43"
version = "1.6.48"
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
readme = "README.md"
requires-python = ">=3.10"
@@ -233,6 +233,47 @@ class TestA2AJsonRpc:
assert result["status"]["state"] == "completed"
assert result["artifacts"] is not None
assert len(result["artifacts"]) > 0
def test_post_a2a_message_send_a2ui_artifact(self):
"""message/send with A2UI tool result in chat_history → DataPart artifact."""
import json
from fastapi.testclient import TestClient
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
app, a2a = _make_app()
a2ui_payload = {
"mime_type": A2UI_MIME_TYPE,
"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}],
"a2ui_part": {"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}]},
}
a2a.agent.chat = MagicMock(return_value="Surface ready.")
a2a.agent.chat_history = [
{"role": "tool", "content": json.dumps(a2ui_payload)},
]
client = TestClient(app)
response = client.post("/a2a", json={
"jsonrpc": "2.0",
"method": "message/send",
"id": "a2ui-1",
"params": {
"message": {
"messageId": "msg-a2ui",
"role": "user",
"parts": [{"text": "Show UI"}],
}
},
})
assert response.status_code == 200
result = response.json()["result"]
artifact = result["artifacts"][0]
parts = artifact["parts"]
data_parts = [p for p in parts if "data" in p]
assert data_parts, "Expected DataPart in artifact"
assert data_parts[0]["metadata"]["mimeType"] == A2UI_MIME_TYPE
def test_post_a2a_message_send_creates_task_in_store(self):
"""message/send should create a task in TaskStore."""
@@ -0,0 +1,66 @@
"""Tests for A2UI artifact conversion in A2A transport."""
import json
import pytest
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
SAMPLE_A2UI_RESULT = {
"mime_type": A2UI_MIME_TYPE,
"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}],
"a2ui_part": {"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}]},
}
class TestFindA2uiToolResult:
def test_finds_last_a2ui_tool_message(self):
from praisonaiagents.ui.a2a.conversion import find_a2ui_tool_result
history = [
{"role": "user", "content": "show ui"},
{"role": "tool", "content": json.dumps({"mime_type": "text/plain", "data": "x"})},
{"role": "tool", "content": json.dumps(SAMPLE_A2UI_RESULT)},
]
result = find_a2ui_tool_result(history)
assert result is not None
assert result["mime_type"] == A2UI_MIME_TYPE
def test_returns_none_without_a2ui(self):
from praisonaiagents.ui.a2a.conversion import find_a2ui_tool_result
assert find_a2ui_tool_result([]) is None
assert find_a2ui_tool_result([{"role": "tool", "content": "plain text"}]) is None
class TestCreateA2uiArtifact:
def test_creates_datapart_with_mime(self):
from praisonaiagents.ui.a2a.conversion import create_a2ui_artifact
from praisonaiagents.ui.a2a.types import DataPart
artifact = create_a2ui_artifact(SAMPLE_A2UI_RESULT, name="ui-surface")
assert artifact.name == "ui-surface"
assert len(artifact.parts) == 1
part = artifact.parts[0]
assert isinstance(part, DataPart)
assert part.metadata.get("mimeType") == A2UI_MIME_TYPE
assert "messages" in part.data
class TestCreateResponseArtifact:
def test_prefers_a2ui_from_history(self):
from praisonaiagents.ui.a2a.conversion import create_response_artifact
from praisonaiagents.ui.a2a.types import DataPart
history = [{"role": "tool", "content": json.dumps(SAMPLE_A2UI_RESULT)}]
artifact = create_response_artifact("Done.", chat_history=history)
assert isinstance(artifact.parts[0], DataPart)
def test_falls_back_to_text(self):
from praisonaiagents.ui.a2a.conversion import create_response_artifact
from praisonaiagents.ui.a2a.types import TextPart
artifact = create_response_artifact("Hello", chat_history=[])
assert isinstance(artifact.parts[0], TextPart)
assert artifact.parts[0].text == "Hello"
@@ -0,0 +1,51 @@
"""Tests for send_a2ui_messages minimal arg unwrap."""
import json
import pytest
from praisonaiagents.tools.a2ui_tools import _unwrap_messages
try:
import a2ui # noqa: F401
HAS_A2UI_SDK = True
except ImportError:
HAS_A2UI_SDK = False
SAMPLE_MSG = {"createSurface": {"surfaceId": "main", "catalogId": "basic"}}
class TestUnwrapMessages:
def test_list_input(self):
assert _unwrap_messages([SAMPLE_MSG]) == [SAMPLE_MSG]
def test_json_string_list(self):
assert _unwrap_messages(json.dumps([SAMPLE_MSG])) == [SAMPLE_MSG]
def test_wrapped_messages_dict(self):
assert _unwrap_messages({"messages": [SAMPLE_MSG]}) == [SAMPLE_MSG]
def test_json_string_wrapped_object(self):
assert _unwrap_messages(json.dumps({"messages": [SAMPLE_MSG]})) == [SAMPLE_MSG]
def test_single_message_dict(self):
assert _unwrap_messages(SAMPLE_MSG) == [SAMPLE_MSG]
def test_invalid_json_raises(self):
with pytest.raises(ValueError, match="not valid JSON"):
_unwrap_messages("{not json")
def test_invalid_shape_raises(self):
with pytest.raises(ValueError, match="must be"):
_unwrap_messages(123)
@pytest.mark.skipif(not HAS_A2UI_SDK, reason="a2ui-agent-sdk not installed")
class TestSendA2uiMessagesIntegration:
def test_full_tool_call(self):
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
result = send_a2ui_messages(messages=[SAMPLE_MSG])
assert result["mime_type"] == "application/json+a2ui"
assert len(result["messages"]) == 1
@@ -34,3 +34,27 @@ class TestA2UITool:
)
assert result["mime_type"] == "application/json+a2ui"
assert len(result["messages"]) == 1
class TestA2UIAdapterSmoke:
def test_is_a2ui_part_with_sdk(self):
pytest.importorskip("a2ui", reason="a2ui-agent-sdk not installed")
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part, is_a2ui_part
part = create_a2ui_part({"messages": [{"createSurface": {"surfaceId": "m"}}]})
assert is_a2ui_part(part) is True
def test_missing_sdk_import_error_message(self):
from unittest.mock import patch
from praisonaiagents.ui.a2ui import adapter
def _raise(_name):
raise ImportError(
"A2UI support requires the optional dependency. "
"Install with: pip install praisonaiagents[a2ui]"
)
with patch.object(adapter, "_import", side_effect=_raise):
with pytest.raises(ImportError, match="praisonaiagents\\[a2ui\\]"):
adapter.create_a2ui_part({"messages": []})
@@ -0,0 +1,27 @@
"""Tests for UI protocol types (zero-dep contracts)."""
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE, A2UIToolResultProtocol
class TestA2UIProtocols:
def test_mime_type_constant(self):
assert A2UI_MIME_TYPE == "application/json+a2ui"
def test_tool_result_protocol_shape(self):
result: A2UIToolResultProtocol = {
"mime_type": A2UI_MIME_TYPE,
"messages": [{"createSurface": {"surfaceId": "main"}}],
"a2ui_part": {"messages": []},
}
assert result["mime_type"] == A2UI_MIME_TYPE
def test_exported_from_a2ui_package(self):
from praisonaiagents.ui.a2ui import A2UI_MIME_TYPE, A2UIToolResultProtocol
assert A2UI_MIME_TYPE == "application/json+a2ui"
assert A2UIToolResultProtocol is not None
def test_top_level_lazy_a2ui_export(self):
from praisonaiagents import A2UI
assert A2UI is not None
@@ -129,6 +129,29 @@ class TestStreamEventBridge:
assert agui_events[0].type == "TOOL_CALL_START"
assert agui_events[0].tool_call_name == "search"
def test_tool_call_result_a2ui_emits_custom_event(self):
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
from praisonaiagents.ui.agui.streaming import EventBuffer, stream_event_to_agui_events
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
buffer = EventBuffer()
a2ui_result = {
"mime_type": A2UI_MIME_TYPE,
"messages": [{"createSurface": {"surfaceId": "panel-1"}}],
}
event = StreamEvent(
type=StreamEventType.TOOL_CALL_RESULT,
tool_call={"id": "tc-a2ui", "name": "send_a2ui_messages", "result": a2ui_result},
)
agui_events = stream_event_to_agui_events(event, "msg-1", buffer)
assert len(agui_events) == 2
assert agui_events[0].type == "TOOL_CALL_RESULT"
assert agui_events[1].type == "CUSTOM"
assert agui_events[1].name == "a2ui"
assert agui_events[1].value["surface_id"] == "panel-1"
assert agui_events[1].value["messages"] == a2ui_result["messages"]
class TestRunLifecycleEvents:
"""Test run lifecycle events."""
@@ -349,6 +349,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
@@ -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)
@@ -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)
@@ -225,3 +225,19 @@ class TestToolCallIdPropagation:
)
assert event.tool_call["id"] == "call_abc123xyz"
class TestToolExecutionMixinStreamEmitterLookup:
def test_mixin_resolves_name_mangled_stream_emitter_without_agent_class_name(self):
from praisonaiagents.agent.tool_execution import ToolExecutionMixin
from praisonaiagents.streaming.events import StreamEventEmitter
class CustomAgent(ToolExecutionMixin):
def __init__(self):
self.__stream_emitter = StreamEventEmitter()
agent = CustomAgent()
emitter = agent._get_existing_stream_emitter()
assert emitter is not None
assert isinstance(emitter, StreamEventEmitter)
@@ -134,3 +134,37 @@ class TestTokenCollectorSinkIntegration:
)
assert sink.records[0]["task_id"] == "task-abc"
assert sink.records[0]["agent_name"] == "researcher"
class TestInMemoryUsageQuery:
"""Test query adapter backed by in-memory sink records."""
def test_get_summary_uses_total_tokens_when_present(self):
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink, InMemoryUsageQuery
sink = InMemoryTokenUsageSink()
sink.records.extend([
{
"task_id": "t1",
"agent_name": "a1",
"model": "m1",
"input_tokens": 10,
"output_tokens": 5,
"total_tokens": 50,
},
{
"task_id": "t2",
"agent_name": "a1",
"model": "m2",
"input_tokens": 2,
"output_tokens": 3,
},
])
summary = InMemoryUsageQuery(sink).get_summary()
assert summary["total_input_tokens"] == 12
assert summary["total_output_tokens"] == 8
assert summary["total_tokens"] == 55
assert summary["by_model"]["m1"]["total_tokens"] == 50
assert summary["by_model"]["m2"]["total_tokens"] == 5
@@ -0,0 +1,293 @@
"""
Unit tests for kanban protocols and hook events.
Tests the protocol contracts and hook events without any heavy
implementations like SQLite or LLM calls.
"""
import pytest
import json
from typing import runtime_checkable
from praisonaiagents.kanban.protocols import (
KanbanStoreProtocol,
KanbanTaskProtocol,
KanbanCommentingProtocol,
KanbanLinkingProtocol,
VALID_KANBAN_STATUSES,
)
from praisonaiagents.hooks.types import (
HookEvent,
KanbanHookInput,
)
class TestKanbanProtocol:
"""Test KanbanStoreProtocol runtime checking and constants."""
def test_kanban_store_protocol_is_runtime_checkable(self):
"""Test that KanbanStoreProtocol is @runtime_checkable."""
assert runtime_checkable(KanbanStoreProtocol)
def test_valid_kanban_statuses_includes_ui_columns(self):
"""Test that VALID_KANBAN_STATUSES includes all PraisonAIUI columns."""
expected_statuses = {
"triage",
"todo",
"ready",
"running",
"blocked",
"review",
"done",
"archived"
}
assert VALID_KANBAN_STATUSES == expected_statuses
def test_valid_kanban_statuses_is_frozen(self):
"""Test that VALID_KANBAN_STATUSES is immutable."""
assert isinstance(VALID_KANBAN_STATUSES, frozenset)
def test_mock_store_passes_isinstance_check(self):
"""Test that a mock store implementing the core protocol passes isinstance check."""
class MockKanbanStore:
"""Mock implementation for testing - only core methods required."""
def get_board(self, *, board="default", tenant=None, include_archived=False):
return {"board": board, "tasks": []}
def get_task(self, task_id: str):
return {"id": task_id, "title": "Test Task"}
def create_task(self, data: dict):
return {"id": "task_123", **data}
def update_task(self, task_id: str, data: dict):
return {"id": task_id, **data}
def move_task(self, task_id: str, status: str):
return {"id": task_id, "status": status}
def bulk_update(self, task_ids: list[str], status: str):
return {"updated": len(task_ids), "status": status}
def delete_task(self, task_id: str):
return True
def list_events(self, since: float = 0.0, board: str = "default"):
return []
def health(self):
return {"status": "healthy"}
mock_store = MockKanbanStore()
assert isinstance(mock_store, KanbanStoreProtocol)
def test_extension_protocol_checks(self):
"""Test that extension protocols work independently."""
class CommentingStore:
"""Store that implements commenting functionality."""
def add_comment(self, task_id: str, text: str, author: str | None = None):
return {"id": "comment_1", "text": text, "author": author}
class LinkingStore:
"""Store that implements linking functionality."""
def link_tasks(self, parent_id: str, child_id: str):
return True
def unlink_tasks(self, parent_id: str, child_id: str):
return True
class FullFeaturedStore:
"""Store that implements all protocols."""
# Core protocol
def get_board(self, *, board="default", tenant=None, include_archived=False):
return {"board": board, "tasks": []}
def get_task(self, task_id: str):
return {"id": task_id}
def create_task(self, data: dict):
return {"id": "task_123", **data}
def update_task(self, task_id: str, data: dict):
return {"id": task_id, **data}
def move_task(self, task_id: str, status: str):
return {"id": task_id, "status": status}
def bulk_update(self, task_ids: list[str], status: str):
return {"updated": len(task_ids)}
def delete_task(self, task_id: str):
return True
def list_events(self, since: float = 0.0, board: str = "default"):
return []
def health(self):
return {"status": "healthy"}
# Extension protocols
def add_comment(self, task_id: str, text: str, author: str | None = None):
return {"id": "comment_1", "text": text}
def link_tasks(self, parent_id: str, child_id: str):
return True
def unlink_tasks(self, parent_id: str, child_id: str):
return True
commenting_store = CommentingStore()
linking_store = LinkingStore()
full_store = FullFeaturedStore()
# Test individual extension protocols
assert isinstance(commenting_store, KanbanCommentingProtocol)
assert isinstance(linking_store, KanbanLinkingProtocol)
# Test that extension stores don't implement core protocol
assert not isinstance(commenting_store, KanbanStoreProtocol)
assert not isinstance(linking_store, KanbanStoreProtocol)
# Test full featured store implements all protocols
assert isinstance(full_store, KanbanStoreProtocol)
assert isinstance(full_store, KanbanCommentingProtocol)
assert isinstance(full_store, KanbanLinkingProtocol)
def test_kanban_task_protocol_shape(self):
"""Test KanbanTaskProtocol typed dict structure."""
# This tests that the TypedDict can be used for type hints
# The shape is validated at runtime by mypy/type checkers
task: KanbanTaskProtocol = {
"id": "task_123",
"title": "Test Task",
"body": "Task description",
"status": "todo",
"assignee": "[email protected]",
"priority": "high",
"tenant": "test_tenant",
"board": "default",
"created_at": 1640995200.0,
"updated_at": 1640995200.0,
}
# Basic validation that we can access the fields
assert task["id"] == "task_123"
assert task["title"] == "Test Task"
assert task["status"] == "todo"
class TestKanbanHookEvents:
"""Test kanban hook events and payload types."""
def test_kanban_hook_events_in_enum(self):
"""Test that all kanban hook events are registered in HookEvent."""
expected_events = [
HookEvent.KANBAN_TASK_CREATED,
HookEvent.KANBAN_TASK_CLAIMED,
HookEvent.KANBAN_TASK_MOVED,
HookEvent.KANBAN_TASK_DONE,
HookEvent.KANBAN_TASK_BLOCKED,
HookEvent.KANBAN_TASK_FAILED,
]
for event in expected_events:
assert event in HookEvent
def test_kanban_hook_events_serializable(self):
"""Test that kanban hook events can be serialized."""
events = [
HookEvent.KANBAN_TASK_CREATED,
HookEvent.KANBAN_TASK_CLAIMED,
HookEvent.KANBAN_TASK_MOVED,
HookEvent.KANBAN_TASK_DONE,
HookEvent.KANBAN_TASK_BLOCKED,
HookEvent.KANBAN_TASK_FAILED,
]
for event in events:
# Should be able to serialize to JSON and reconstruct enum value
serialized = json.dumps({"event": event.value})
restored = HookEvent(json.loads(serialized)["event"])
assert restored == event
def test_kanban_hook_input_creation(self):
"""Test KanbanHookInput dataclass creation and fields."""
hook_input = KanbanHookInput(
session_id="session_123",
cwd="/home/user",
event_name="kanban_task_created",
timestamp="2023-01-01T00:00:00Z",
task_id="task_456",
board="project_board",
status="todo",
assignee="[email protected]",
from_status=None,
to_status="running"
)
assert hook_input.session_id == "session_123"
assert hook_input.task_id == "task_456"
assert hook_input.board == "project_board"
assert hook_input.status == "todo"
assert hook_input.assignee == "[email protected]"
assert hook_input.from_status is None
assert hook_input.to_status == "running"
def test_kanban_hook_input_defaults(self):
"""Test KanbanHookInput with default values."""
hook_input = KanbanHookInput(
session_id="session_123",
cwd="/home/user",
event_name="kanban_task_created",
timestamp="2023-01-01T00:00:00Z"
)
# Check defaults
assert hook_input.task_id == ""
assert hook_input.board == "default"
assert hook_input.status == ""
assert hook_input.assignee is None
assert hook_input.from_status is None
assert hook_input.to_status is None
def test_kanban_hook_input_to_dict(self):
"""Test KanbanHookInput serialization to dict."""
hook_input = KanbanHookInput(
session_id="session_123",
cwd="/home/user",
event_name="kanban_task_moved",
timestamp="2023-01-01T00:00:00Z",
task_id="task_456",
board="project_board",
from_status="todo",
to_status="running"
)
result = hook_input.to_dict()
assert result["session_id"] == "session_123"
assert result["event_name"] == "kanban_task_moved"
assert result["task_id"] == "task_456"
assert result["board"] == "project_board"
assert result["from_status"] == "todo"
assert result["to_status"] == "running"
class TestProtocolIntegration:
"""Integration tests for protocol usage patterns."""
def test_protocol_import_structure(self):
"""Test that protocols can be imported from expected paths."""
# Test direct imports
from praisonaiagents.kanban.protocols import KanbanStoreProtocol
from praisonaiagents.hooks.types import KanbanHookInput
assert KanbanStoreProtocol is not None
assert KanbanHookInput is not None
def test_lazy_import_from_package_root(self):
"""Test that kanban protocols can be imported from package root."""
from praisonaiagents.kanban import (
KanbanStoreProtocol,
KanbanTaskProtocol,
KanbanCommentingProtocol,
KanbanLinkingProtocol,
VALID_KANBAN_STATUSES,
)
assert KanbanStoreProtocol is not None
assert KanbanTaskProtocol is not None
assert KanbanCommentingProtocol is not None
assert KanbanLinkingProtocol is not None
assert VALID_KANBAN_STATUSES is not None
+1 -1
View File
@@ -3125,7 +3125,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.43"
version = "1.6.48"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+15 -7
View File
@@ -1,18 +1,19 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-20",
"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-20",
"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-20
> **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)
+31
View File
@@ -0,0 +1,31 @@
# Channels gateway integration
Channel bots (Telegram, Discord, Slack, WhatsApp) require the **`praisonai[bot]`** optional extra:
```bash
pip install "praisonai[bot]"
```
## Architecture
- **Pattern B/C host**: `configure_host()` + `AIUIGateway` serve chat UI and REST APIs.
- **Channels feature** (`praisonaiui.features.channels`): starts platform bots via `praisonai.bots`.
- **Gateway WebSocket**: `/ws` on the same process when using Pattern C.
## Environment variables
| Platform | Variables |
|----------|-------------|
| Telegram | `TELEGRAM_BOT_TOKEN` |
| Discord | `DISCORD_BOT_TOKEN` |
| Slack | `SLACK_BOT_TOKEN`, `SLACK_APP_TOKEN` |
| WhatsApp | `WHATSAPP_ACCESS_TOKEN`, `WHATSAPP_PHONE_NUMBER_ID` |
## CLI
```bash
praisonai dashboard --aiui # Pattern B in-process host
PRAISONAI_HOST_LEGACY=1 praisonai ui # Legacy @aiui.reply callbacks only
```
See `praisonai.bots.BotOS` for multi-platform orchestration.
+26
View File
@@ -0,0 +1,26 @@
# Pattern D — Platform API integration (defer)
Pattern D connects PraisonAIUI dashboard pages to **PraisonAI Cloud** via `PlatformClient` `/api/v1`.
## Scope (P3)
| Item | Status |
|------|--------|
| aiui pages → PlatformClient | Documented, optional |
| Platform JWT auth | Optional `auth.platform_jwt` config |
| Issues / kanban pages | Optional `@aiui.page` via platform |
| agent_id linking | See below |
## agent_id linking
Three registries may coexist:
1. **Platform roster** — cloud tenant agents (`/api/v1/agents`)
2. **Gateway registry** — WebSocket `/ws` agent ids
3. **aiui SDKAgentRegistry** — YAML CRUD + local `Agent` instances
Use explicit `agent_id` in session metadata (`source=gateway|platform|ui`) to correlate rows in the workflow-runs and sessions tables.
## Optional platform JWT
When `PRAISONAI_PLATFORM_TOKEN` is set, future aiui auth middleware may validate JWT from the platform issuer. Not required for Patterns B/C self-hosted installs.
Binary file not shown.
+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.43.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.43.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]"
+20 -4
View File
@@ -6,6 +6,8 @@ from .version import __version__
# Define __all__ for lazy loading
__all__ = [
'PraisonAI',
'run',
'arun',
'__version__',
'Deploy',
'DeployConfig',
@@ -30,6 +32,8 @@ __all__ = [
'HostedAgentConfig',
'LocalAgent',
'LocalAgentConfig',
# Integration functions
'run_integrated_gateway',
]
# Telemetry initialization state - thread-safe (threading imported above)
@@ -92,7 +96,13 @@ def __getattr__(name):
# Note: Telemetry initialization moved out of lazy hook to avoid side effects
# It should be called explicitly from cli.PraisonAI.__init__ instead
if name == 'PraisonAI':
if name == 'run':
from ._entrypoint import run
return run
elif name == 'arun':
from ._entrypoint import arun
return arun
elif name == 'PraisonAI':
from .cli import PraisonAI
return PraisonAI
elif name == 'Agent':
@@ -130,9 +140,15 @@ def __getattr__(name):
elif name == 'EmbeddingResult':
from praisonaiagents.embedding import EmbeddingResult
return EmbeddingResult
elif name == 'AgentOS':
from .app import AgentOS
return AgentOS
elif name == 'build_host_app':
from .integration.host_app import build_host_app
return build_host_app
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()
+1 -1
View File
@@ -119,7 +119,7 @@ class ApiMdGenerator:
'Observability': ['obs', 'FlowDisplay', 'track_workflow'],
'Context': ['FastContext', 'FastContextResult', 'FileMatch', 'LineRange',
'ContextManager', 'ManagerConfig'],
'UI': ['AGUI', 'A2A'],
'UI': ['AGUI', 'A2A', 'A2UI'],
'Config': ['MemoryConfig', 'KnowledgeConfig', 'PlanningConfig', 'ReflectionConfig',
'GuardrailConfig', 'WebConfig', 'OutputConfig', 'ExecutionConfig',
'TemplateConfig', 'CachingConfig', 'HooksConfig', 'SkillsConfig',
@@ -82,7 +82,7 @@ class PythonFeatureExtractor:
'telemetry': ['telemetry'],
'context': ['context.fast', 'context.manager', 'context.models'],
'session': ['session'],
'ui': ['ui.agui', 'ui.a2a'],
'ui': ['ui.agui', 'ui.a2a', 'ui.a2ui'],
'rag': ['rag'],
'planning': ['planning'],
'config': ['config.feature_configs', 'config.param_resolver',
+73
View File
@@ -0,0 +1,73 @@
"""Top-level convenience entry points for the Python API.
Keep this file minimal - it is part of the Minimal API surface and must defer
all heavy imports to call time.
"""
from __future__ import annotations
from typing import Any
def _build_config_list() -> list[dict[str, Any]]:
"""Reuse the same env/keyfile resolution the CLI already performs."""
# Import LLM endpoint resolution from the same path the CLI uses
from praisonai.llm.env import resolve_llm_endpoint
ep = resolve_llm_endpoint()
return [{
'model': ep.model,
'base_url': ep.base_url,
'api_key': ep.api_key,
'api_type': 'openai' # AutoGen expects this field
}]
def run(agent_file: str,
framework: str | None = None,
*,
tools: list | None = None,
agent_yaml: str | None = None,
cli_config: dict | None = None) -> str:
"""One-line Python entry point. Equivalent to `praisonai <agent_file>`."""
from .agents_generator import AgentsGenerator
from ._framework_availability import is_available
if framework is None:
# Mirror the CLI default-resolution order
for candidate in ("crewai", "praisonaiagents", "autogen", "ag2"):
if is_available(candidate):
framework = "praisonai" if candidate == "praisonaiagents" else candidate
break
else:
raise RuntimeError(
"No supported framework installed. "
"Install one of: crewai, praisonaiagents, autogen, ag2."
)
else:
# Validate explicit framework like CLI does
from .framework_adapters.validators import assert_framework_available
assert_framework_available(framework)
gen = AgentsGenerator(
agent_file=agent_file,
framework=framework,
config_list=_build_config_list(),
tools=tools,
agent_yaml=agent_yaml,
cli_config=cli_config,
)
return gen.generate_crew_and_kickoff()
async def arun(agent_file: str,
framework: str | None = None,
*,
tools: list | None = None,
agent_yaml: str | None = None,
cli_config: dict | None = None) -> str:
"""Async equivalent of `run()` for FastAPI / Jupyter async callers."""
import asyncio
return await asyncio.to_thread(
run, agent_file, framework,
tools=tools, agent_yaml=agent_yaml, cli_config=cli_config,
)
@@ -30,6 +30,11 @@ _PROBES: dict[str, Callable[[], bool]] = {
"agentops": lambda: importlib.util.find_spec("agentops") is not None,
"litellm": lambda: importlib.util.find_spec("litellm") is not None,
"openai": lambda: importlib.util.find_spec("openai") is not None,
# Additional probes for Gap 2 consolidation
"acp": lambda: importlib.util.find_spec("acp") is not None,
"rich": lambda: importlib.util.find_spec("rich") is not None,
"gradio": lambda: importlib.util.find_spec("gradio") is not None,
"unsloth": lambda: importlib.util.find_spec("unsloth") is not None,
}
def is_available(name: str) -> bool:
+2 -1
View File
@@ -42,7 +42,8 @@ def __getattr__(name: str):
def _check_acp_available() -> bool:
"""Check if agent-client-protocol package is installed."""
return importlib.util.find_spec("acp") is not None
from .._framework_availability import is_available
return is_available("acp")
def _get_install_instructions() -> str:
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."""
+34 -22
View File
@@ -8,29 +8,9 @@ Custom page: Feature Explorer
"""
import os
import praisonaiui as aiui
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
# ── Set up datastore bridge ─────────────────────────────────
aiui.set_datastore(PraisonAISessionDataStore())
# ── Dashboard style ─────────────────────────────────────────
aiui.set_style("dashboard")
aiui.set_pages([
"chat", # Core AI chat
"channels", # Messaging platforms
"agents", # Agent management
"skills", # Available tools
"memory", # Agent memory
"knowledge", # Knowledge base
"cron", # Scheduled jobs
"guardrails", # Safety rules
"sessions", # Conversation history
"usage", # Token usage & costs
"config", # Runtime configuration
"logs", # Server logs
"debug", # System debug info
])
from praisonai.integration.host_app import configure_host, create_host_app
# ── Agent definitions ───────────────────────────────────────
AGENTS = [
@@ -71,6 +51,35 @@ AGENTS = [
]
def _build_praison_agents():
from praisonaiagents import Agent
return [
Agent(name=a["name"], instructions=a["instructions"], llm=a["model"])
for a in AGENTS
]
configure_host(
pages=[
"chat",
"channels",
"agents",
"skills",
"memory",
"knowledge",
"cron",
"guardrails",
"sessions",
"usage",
"config",
"logs",
"debug",
],
agents=_build_praison_agents(),
modules=["jobs"],
)
# ── Custom page: Feature Explorer ───────────────────────────
@@ -110,7 +119,10 @@ def _register_agents_in_dashboard():
})
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
try:
_register_agents_in_dashboard()
except Exception as e:
print(f" ⚠️ Agent registration failed (non-fatal): {e}")
app = create_host_app()
@@ -199,93 +199,24 @@ def _auto_start_services(console, host: str) -> bool:
def _run_aiui_dashboard(port: int, host: str, console) -> bool:
"""Run the aiui dashboard interface using proper CLI entrypoint."""
console.print("[bold green]🦞 Starting aiui Dashboard...[/bold green]")
"""Run the aiui dashboard in-process via ``build_host_app()`` (Pattern B)."""
console.print("[bold green]🦞 Starting aiui Dashboard (Pattern B host)...[/bold green]")
try:
import tempfile
import os
# Check if aiui is available first
result = subprocess.run([
sys.executable, "-c", "import praisonaiui"
], capture_output=True, text=True)
if result.returncode != 0:
console.print("[red]Error: aiui package not installed.[/red]")
console.print("[yellow]Install with: pip install aiui[/yellow]")
return False
# Create a temporary script for aiui dashboard based on claw pattern
aiui_script = '''import os
import praisonaiui as aiui
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
# Set up datastore bridge
aiui.set_datastore(PraisonAISessionDataStore())
# Configure dashboard style
aiui.set_style("dashboard")
aiui.set_branding(title="PraisonAI Unified Dashboard", logo="🌟")
# Set up pages for unified dashboard
aiui.set_pages([
"chat", "agents", "memory", "knowledge",
"skills", "sessions", "usage", "config", "logs"
])
# Register a simple welcome message
@aiui.welcome
async def on_welcome():
await aiui.say("🌟 Welcome to PraisonAI Unified Dashboard!")
await aiui.say("Access all your AI services from one interface.")
# Register basic reply handler
@aiui.reply
async def on_reply(message: str, settings: dict | None = None):
await aiui.think("Processing...")
await aiui.say(f"Unified Dashboard received: {message}")
await aiui.say("Use the sidebar to access Flow, Agents, Memory, and more!")
'''
# Create temp file safely
with tempfile.NamedTemporaryFile(mode='w', suffix='.py', delete=False) as f:
f.write(aiui_script)
temp_script = f.name
try:
console.print(f"[green]✓ Starting aiui dashboard on {host}:{port}[/green]")
# Use aiui CLI to run the dashboard (NOT create_app)
# Try aiui command first, fallback to python -m
try:
result = subprocess.run([
"aiui", "run", temp_script, "--port", str(port), "--host", host
], check=True)
except (subprocess.CalledProcessError, FileNotFoundError):
# Fallback to python -m praisonaiui
result = subprocess.run([
sys.executable, "-m", "praisonaiui.cli", "run",
temp_script, "--port", str(port), "--host", host
], check=True)
return True
except subprocess.CalledProcessError as e:
console.print(f"[red]aiui dashboard exited with code {e.returncode}[/red]")
return False
finally:
# Clean up temp file
try:
os.unlink(temp_script)
except (OSError, FileNotFoundError):
pass
except subprocess.CalledProcessError as e:
console.print(f"[red]aiui dashboard failed with code {e.returncode}[/red]")
import uvicorn
from praisonai.integration.host_app import build_host_app
except ImportError:
console.print("[red]Error: aiui integration dependencies missing.[/red]")
console.print('[yellow]Install with: pip install "praisonai[ui]"[/yellow]')
return False
try:
app = build_host_app(pages=["chat", "sessions", "workflows", "hooks", "usage"])
console.print(f"[green]✓ Starting in-process host on {host}:{port}[/green]")
uvicorn.run(app, host=host, port=port, log_level="info")
return True
except Exception as e:
console.print(f"[red]Error running aiui dashboard: {e}[/red]")
console.print(f"[red]Error running in-process host: {e}[/red]")
return False
@@ -612,6 +543,10 @@ def unified(
"""
Launch the PraisonAI Unified Dashboard.
Pattern B (default with --aiui): in-process ``build_host_app()`` host.
Pattern C: ``praisonai serve gateway`` or ``run_integrated_gateway()``.
Legacy: set ``PRAISONAI_HOST_LEGACY=1`` for callback-only ``@aiui.reply`` mode.
Provides a single interface at localhost:3000 to access:
- Flow Visual Builder (Langflow) - port 7860
- Claw Dashboard (Full UI) - port 8082
@@ -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"),
+6 -1
View File
@@ -13,7 +13,12 @@ from typing import Optional
import typer
app = typer.Typer(help="🤖 PraisonAI Clean Chat UI")
app = typer.Typer(
help=(
"🤖 PraisonAI Clean Chat UI (Pattern B host via bundled default_app). "
"Pattern C: AIUIGateway. Legacy: PRAISONAI_HOST_LEGACY=1."
)
)
UI_DIR = Path.home() / ".praisonai" / "ui"
DEFAULT_APP = UI_DIR / "app.py"
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more