Compare commits

...
45 Commits
Author SHA1 Message Date
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
114 changed files with 4186 additions and 658 deletions

No files matched your search

+13 -1
View File
@@ -171,6 +171,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 +478,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 +490,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 +606,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 +767,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
```
# CLI
@@ -861,6 +872,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>
+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.46" \
"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.46" \
"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.46" \
"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)
+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
BIN
View File
Binary file not shown.
@@ -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
# ============================================================================
@@ -4616,60 +4616,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 +4675,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:
@@ -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
@@ -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,
@@ -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": {}
}
```
@@ -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."""
...
@@ -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):
@@ -470,7 +475,62 @@ class DefaultSessionStore:
self._cache[session_id] = session
return self._save_session(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
filepath = self._get_session_path(session_id)
with FileLock(filepath, self.lock_timeout):
if os.path.exists(filepath):
try:
with open(filepath, "r", encoding="utf-8") as f:
data = json.load(f)
session = SessionData.from_dict(data)
except (json.JSONDecodeError, IOError):
session = SessionData(session_id=session_id)
else:
session = SessionData(session_id=session_id)
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)
session.updated_at = datetime.now(timezone.utc).isoformat()
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 update session metadata {session_id}: {e}")
try:
if "temp_path" in locals():
os.remove(temp_path)
except (IOError, OSError):
pass
return False
def delete_session(self, session_id: str) -> bool:
"""Delete a session completely."""
filepath = self._get_session_path(session_id)
@@ -500,10 +560,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
@@ -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.46"
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."""
@@ -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"])
@@ -282,6 +282,29 @@ 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_concurrent_writes(self, temp_store):
"""Test concurrent writes to same session."""
@@ -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
+1 -1
View File
@@ -3125,7 +3125,7 @@ wheels = [
[[package]]
name = "praisonaiagents"
version = "1.6.43"
version = "1.6.46"
source = { editable = "." }
dependencies = [
{ name = "aiohttp" },
+14 -4
View File
@@ -1,18 +1,19 @@
{
"version": "1.5.87",
"lastUpdated": "2026-05-20",
"lastUpdated": "2026-05-21",
"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",
@@ -2821,6 +2822,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-21",
"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-21
> **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>
+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.46.tar.gz"
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.46.tar.gz | shasum -a 256`.split.first
license "MIT"
depends_on "[email protected]"
+15 -4
View File
@@ -6,6 +6,8 @@ from .version import __version__
# Define __all__ for lazy loading
__all__ = [
'PraisonAI',
'run',
'arun',
'__version__',
'Deploy',
'DeployConfig',
@@ -92,7 +94,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 +138,12 @@ 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 == 'AgentApp':
# Silent alias for AgentOS (backward compatibility)
from .app import AgentOS
+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:
+4 -4
View File
@@ -221,14 +221,14 @@ class AgentsGenerator:
elif os.environ.get('LOGLEVEL'):
self.logger.setLevel(getattr(logging, os.environ.get('LOGLEVEL', 'INFO').upper(), logging.INFO))
# Initialize tool resolver (single source of truth for tool resolution)
from .tool_resolver import ToolResolver
self.tool_resolver = ToolResolver()
# Keep tool registry for backward compatibility with autogen adapters
self.tool_registry = ToolRegistry()
self.tool_registry.register_builtin_autogen_adapters()
# Initialize tool resolver with the registry wired in (single source of truth for tool resolution)
from .tool_resolver import ToolResolver
self.tool_resolver = ToolResolver(registry=self.tool_registry)
# DI-friendly: tests/multi-tenant runtimes pass their own registry;
# CLI users get the process default.
self._adapter_registry = adapter_registry or get_default_registry()
+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():
"""
+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"
@@ -110,21 +110,8 @@ def create_agent_centric_tools(
if orchestrator is None:
orchestrator = ActionOrchestrator(runtime)
# Helper to run async functions synchronously
def run_async(coro):
"""Run async coroutine synchronously."""
try:
loop = asyncio.get_event_loop()
if loop.is_running():
# Create a new loop in a thread
import concurrent.futures
with concurrent.futures.ThreadPoolExecutor() as executor:
future = executor.submit(asyncio.run, coro)
return future.result(timeout=60)
else:
return loop.run_until_complete(coro)
except RuntimeError:
return asyncio.run(coro)
# Helper to run async functions synchronously using the shared bridge
from praisonai._async_bridge import run_sync
# =========================================================================
# ACP-Powered File Tools
@@ -204,7 +191,7 @@ def create_agent_centric_tools(
"verified": result.success
})
return run_async(_create())
return run_sync(_create())
def acp_edit_file(filepath: str, new_content: str) -> str:
"""
@@ -261,7 +248,7 @@ def create_agent_centric_tools(
"error": result.error
})
return run_async(_edit())
return run_sync(_edit())
def acp_delete_file(filepath: str) -> str:
"""
@@ -313,7 +300,7 @@ def create_agent_centric_tools(
"verified": result.success
})
return run_async(_delete())
return run_sync(_delete())
def acp_execute_command(command: str, cwd: str = None) -> str:
"""
@@ -372,7 +359,7 @@ def create_agent_centric_tools(
"error": result.error
})
return run_async(_execute())
return run_sync(_execute())
# =========================================================================
# LSP-Powered Code Intelligence Tools
@@ -404,7 +391,7 @@ def create_agent_centric_tools(
except asyncio.TimeoutError:
return json.dumps({"error": "LSP list_symbols timed out (10s)", "success": False})
return run_async(_list())
return run_sync(_list())
def lsp_find_definition(symbol: str, file_path: str = None) -> str:
"""
@@ -432,7 +419,7 @@ def create_agent_centric_tools(
except asyncio.TimeoutError:
return json.dumps({"error": "LSP find_definition timed out (10s)", "success": False})
return run_async(_find())
return run_sync(_find())
def lsp_find_references(symbol: str, file_path: str = None) -> str:
"""
@@ -460,7 +447,7 @@ def create_agent_centric_tools(
except asyncio.TimeoutError:
return json.dumps({"error": "LSP find_references timed out (10s)", "success": False})
return run_async(_find())
return run_sync(_find())
def lsp_get_diagnostics(file_path: str = None) -> str:
"""
@@ -487,7 +474,7 @@ def create_agent_centric_tools(
except asyncio.TimeoutError:
return json.dumps({"error": "LSP diagnostics timed out (10s)", "success": False})
return run_async(_get())
return run_sync(_get())
# =========================================================================
# Basic File Tools (for read-only operations)
@@ -229,3 +229,99 @@ def check_skills_praisonai_integration(config: DoctorConfig) -> CheckResult:
message="Skills module not available",
details=str(e),
)
@register_check(
id="skills_capabilities",
title="Skills Capability Requirements",
description="Check skill capability requirements and enforcement",
category=CheckCategory.SKILLS,
severity=CheckSeverity.MEDIUM,
dependencies=["skills_praisonai_integration"],
)
def check_skills_capabilities(config: DoctorConfig) -> CheckResult:
"""Check skill capability requirements."""
try:
from praisonaiagents.skills import SkillManager
from praisonaiagents.skills.capability_validator import EnforcementLevel
# Initialize skill manager
manager = SkillManager()
# Discover skills
discovered = manager.discover(include_defaults=True)
if discovered == 0:
return CheckResult(
id="skills_capabilities",
title="Skills Capability Requirements",
category=CheckCategory.SKILLS,
status=CheckStatus.SKIP,
message="No skills found to check",
)
# Get diagnostics for all skills
diagnostics = manager.get_skills_diagnostics()
active_count = 0
degraded_count = 0
unavailable_count = 0
issues = []
for skill_name, result in diagnostics.items():
if result.state.value == "active":
active_count += 1
elif result.state.value == "degraded":
degraded_count += 1
issues.extend([f"{skill_name}: {w}" for w in result.warnings[:2]])
elif result.state.value == "unavailable":
unavailable_count += 1
issues.extend([f"{skill_name}: {e}" for e in result.errors[:2]])
total = active_count + degraded_count + unavailable_count
# Determine status
enforcement_level = manager._validator.enforcement_level
if unavailable_count == 0 and degraded_count == 0:
status = CheckStatus.PASS
message = f"All {total} skill(s) have requirements satisfied"
elif unavailable_count > 0 and enforcement_level == EnforcementLevel.STRICT:
status = CheckStatus.FAIL
message = f"{unavailable_count}/{total} skills unavailable (strict enforcement)"
elif unavailable_count > 0 or degraded_count > 0:
status = CheckStatus.WARN
message = f"{active_count}/{total} skills fully available, {degraded_count} degraded, {unavailable_count} unavailable"
else:
status = CheckStatus.PASS
message = f"Skills capability check completed"
details = "; ".join(issues[:5]) + ("..." if len(issues) > 5 else "") if issues else None
metadata = {
"total_skills": total,
"active": active_count,
"degraded": degraded_count,
"unavailable": unavailable_count,
"enforcement_level": enforcement_level.value,
"sample_issues": issues[:10]
}
return CheckResult(
id="skills_capabilities",
title="Skills Capability Requirements",
category=CheckCategory.SKILLS,
status=status,
message=message,
details=details,
metadata=metadata,
)
except ImportError as e:
return CheckResult(
id="skills_capabilities",
title="Skills Capability Requirements",
category=CheckCategory.SKILLS,
status=CheckStatus.SKIP,
message="Skills capability validation not available",
details=str(e),
)
@@ -127,6 +127,11 @@ Launch PraisonAI servers with unified discovery support.
[bold]Discovery:[/bold]
All servers expose /__praisonai__/discovery for unified endpoint discovery.
[bold]A2UI / AG-UI rendering:[/bold]
For live A2UI surfaces in a web UI, use PraisonAIUI (POST /agui SSE + /api/surfaces):
pip install praisonaiui praisonaiagents[a2ui]
See docs: Integrate A2UI with Your Frontend.
Use `praisonai endpoints` to interact with any server.
"""
self._print_rich(help_text)
+9 -7
View File
@@ -166,17 +166,19 @@ AUTOGEN_AVAILABLE = False
PRAISONAI_AVAILABLE = False
TRAIN_AVAILABLE = False
# Use find_spec for fast availability checks (no actual import)
import importlib.util
GRADIO_AVAILABLE = importlib.util.find_spec("gradio") is not None
# Use centralized availability detection
from .._framework_availability import is_available
GRADIO_AVAILABLE = is_available("gradio")
try:
import importlib.util
CALL_MODULE_AVAILABLE = importlib.util.find_spec("praisonai.api.call") is not None
except (ModuleNotFoundError, AttributeError):
CALL_MODULE_AVAILABLE = False
CREWAI_AVAILABLE = importlib.util.find_spec("crewai") is not None
AUTOGEN_AVAILABLE = importlib.util.find_spec("autogen") is not None
PRAISONAI_AVAILABLE = importlib.util.find_spec("praisonaiagents") is not None
TRAIN_AVAILABLE = importlib.util.find_spec("unsloth") is not None
CREWAI_AVAILABLE = is_available("crewai")
AUTOGEN_AVAILABLE = is_available("autogen")
PRAISONAI_AVAILABLE = is_available("praisonaiagents")
TRAIN_AVAILABLE = is_available("unsloth")
# Lazy import helpers for optional dependencies (defined after availability flags)
def _get_call_module():
@@ -28,8 +28,8 @@ def _get_rich_available() -> bool:
global _rich_available
if _rich_available is None:
try:
import importlib.util
_rich_available = importlib.util.find_spec("rich") is not None
from ..._framework_availability import is_available
_rich_available = is_available("rich")
except ImportError:
_rich_available = False
return _rich_available
+1 -1
View File
@@ -57,7 +57,7 @@ class CloudDeployer:
file.write("FROM python:3.11-slim\n")
file.write("WORKDIR /app\n")
file.write("COPY . .\n")
file.write("RUN pip install flask praisonai==4.6.43 gunicorn markdown\n")
file.write("RUN pip install flask praisonai==4.6.46 gunicorn markdown\n")
file.write("EXPOSE 8080\n")
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
@@ -71,15 +71,14 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
acp_enabled = global_config.get('acp', False)
lsp_enabled = global_config.get('lsp', False)
interactive_runtime = None
interactive_loop = None
if acp_enabled or lsp_enabled:
try:
import asyncio
from praisonai._async_bridge import run_sync
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
from praisonai.cli.features.agent_tools import create_agent_centric_tools
# Use scoped event loop instead of process-global mutations
# Use scoped configuration instead of process-global mutations
runtime_config = RuntimeConfig(
workspace=os.getcwd(),
acp_enabled=acp_enabled,
@@ -89,11 +88,9 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
interactive_runtime = InteractiveRuntime(runtime_config)
logger.info(f"Starting InteractiveRuntime (ACP: {acp_enabled}, LSP: {lsp_enabled})")
# Create a scoped event loop instead of modifying process globals
interactive_loop = asyncio.new_event_loop()
# Start the runtime but keep it alive for agent execution
interactive_loop.run_until_complete(interactive_runtime.start())
# Start the runtime on the shared background loop where it stays alive
# and its asyncio primitives remain valid for the duration of this call
run_sync(interactive_runtime.start())
centric_tools = create_agent_centric_tools(interactive_runtime)
logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
@@ -204,12 +201,11 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
return result
finally:
# Cleanup InteractiveRuntime if it was started
if interactive_runtime and interactive_loop:
if interactive_runtime is not None:
try:
logger.info("Stopping InteractiveRuntime")
interactive_loop.run_until_complete(interactive_runtime.stop())
from praisonai._async_bridge import run_sync
run_sync(interactive_runtime.stop())
except Exception as e:
logger.error(f"Error stopping InteractiveRuntime: {e}")
finally:
interactive_loop.close()
@@ -1,11 +1,10 @@
"""Lazy access to autogen_tools, with `find_spec` startup probes for crewai/autogen."""
import importlib.util
from praisonai.auto import _load_optional
from .._framework_availability import is_available
CREWAI_AVAILABLE = is_available("crewai")
AUTOGEN_AVAILABLE = is_available("autogen")
PRAISONAI_TOOLS_PACKAGE_AVAILABLE = importlib.util.find_spec("praisonai_tools") is not None
PRAISONAI_TOOLS_PACKAGE_AVAILABLE = is_available("praisonai_tools")
def _load_autogen_tools():
@@ -0,0 +1,19 @@
"""PraisonAI ↔ PraisonAIUI integration layer (Pattern B/C host bootstrap)."""
from .host_app import (
build_host_app,
configure_host,
create_host_app,
is_legacy_host,
setup_bridges,
)
from .gateway_host import run_integrated_gateway
__all__ = [
"build_host_app",
"configure_host",
"create_host_app",
"is_legacy_host",
"setup_bridges",
"run_integrated_gateway",
]
@@ -0,0 +1,18 @@
"""Shared legacy @aiui.reply handlers for bundled default apps."""
from __future__ import annotations
def register_legacy_reply(aiui, *, agent_name: str = "PraisonAI"):
"""Register callback-only reply handler when ``PRAISONAI_HOST_LEGACY=1``."""
@aiui.reply
async def legacy_reply(message: str, session_id: str = "default"):
from praisonaiagents import Agent
agent = Agent(
name=agent_name,
instructions="You are a helpful assistant.",
llm="gpt-4o-mini",
)
return agent.run(message)
@@ -0,0 +1 @@
"""L1→L2 bridge helpers for PraisonAIUI feature routes."""
@@ -0,0 +1,31 @@
"""ApprovalRegistry CRUD adapter for aiui backends."""
from __future__ import annotations
from typing import Any, Dict, List, Optional
def list_pending_approvals() -> List[Dict[str, Any]]:
try:
from praisonaiagents.approval import get_approval_registry
registry = get_approval_registry()
backend = getattr(registry, "_backend", None)
if backend and hasattr(backend, "list_pending"):
return backend.list_pending()
except ImportError:
pass
return []
def get_approval_policies() -> Dict[str, Any]:
try:
from praisonaiagents.approval import get_approval_registry
registry = get_approval_registry()
return {
"auto_approve_env": registry.is_env_auto_approve(),
"requirements": getattr(registry, "_requirements", {}),
}
except ImportError:
return {}
@@ -0,0 +1,15 @@
"""Expose praisonaiagents HookRegistry for admin/API consumers."""
from __future__ import annotations
from typing import Any, Dict, List
def list_hooks_for_api() -> List[Dict[str, Any]]:
"""Flat hook list suitable for REST responses."""
try:
from praisonaiagents.hooks.registry import list_hooks_for_api as core_list
return core_list()
except ImportError:
return []
@@ -0,0 +1,30 @@
"""Ensure SDK schedule runner + optional ScheduleLoop daemon."""
from __future__ import annotations
import logging
log = logging.getLogger(__name__)
_runner_started = False
_loop = None
def ensure_schedule_runner() -> None:
"""Lazy-init FileScheduleStore-backed runner and optional ScheduleLoop."""
global _runner_started, _loop
if _runner_started:
return
try:
from praisonaiagents.scheduler import FileScheduleStore, ScheduleLoop
store = FileScheduleStore()
def on_trigger(job):
log.info("Schedule triggered: %s", getattr(job, "name", job))
_loop = ScheduleLoop(on_trigger=on_trigger, store=store)
_loop.start()
_runner_started = True
log.info("ScheduleLoop started for host integration")
except Exception as exc:
log.debug("Schedule runner unavailable: %s", exc)
@@ -0,0 +1,41 @@
"""Wire core token usage collector to optional persistence sink."""
from __future__ import annotations
from typing import Any, Optional
def register_usage_sink() -> Optional[Any]:
"""Attach global token collector to in-memory sink when available."""
try:
from praisonaiagents.telemetry.protocols import (
InMemoryTokenUsageSink,
InMemoryUsageQuery,
TokenCollectorUsageQuery,
)
from praisonaiagents.telemetry.token_collector import get_token_collector
sink = InMemoryTokenUsageSink()
collector = get_token_collector()
collector.set_sink(sink)
return sink
except ImportError:
return None
def get_usage_query():
"""Return UsageQueryProtocol adapter for UI consumption."""
try:
from praisonaiagents.telemetry.protocols import (
InMemoryUsageQuery,
TokenCollectorUsageQuery,
)
from praisonaiagents.telemetry.token_collector import get_token_collector
collector = get_token_collector()
sink = getattr(collector, "_sink", None)
if sink is not None and hasattr(sink, "records"):
return InMemoryUsageQuery(sink)
return TokenCollectorUsageQuery(collector)
except ImportError:
return None
@@ -0,0 +1,77 @@
"""Run praisonaiagents AgentFlow workflows with run tracking."""
from __future__ import annotations
import uuid
from pathlib import Path
from typing import Any, Dict, Optional
import yaml
def load_workflow_yaml(path: str) -> Dict[str, Any]:
"""Load workflow definition from a YAML file."""
with open(path, encoding="utf-8") as f:
data = yaml.safe_load(f) or {}
if not isinstance(data, dict):
raise ValueError("Workflow YAML must be a mapping")
return data
def run_workflow(
workflow_id: str,
*,
input_text: str = "",
workflow_config: Optional[Dict[str, Any]] = None,
yaml_path: Optional[str] = None,
) -> Dict[str, Any]:
"""Execute a workflow and return a serialisable run record."""
run_id = uuid.uuid4().hex[:12]
config = dict(workflow_config or {})
if yaml_path:
loaded = load_workflow_yaml(yaml_path)
config = {**loaded, **config}
steps = config.get("steps") or []
if not steps:
return {
"id": run_id,
"workflow_id": workflow_id,
"status": "failed",
"input": {"text": input_text},
"error": "Workflow has no steps",
}
try:
from praisonaiagents import Agent, AgentFlow
flow_steps = []
for i, step in enumerate(steps):
if isinstance(step, str):
flow_steps.append(
Agent(
name=f"step_{i + 1}",
instructions=step,
llm=config.get("model", "gpt-4o-mini"),
)
)
else:
flow_steps.append(step)
flow = AgentFlow(steps=flow_steps, name=config.get("name", "Workflow"))
result = flow.run(input_text)
return {
"id": run_id,
"workflow_id": workflow_id,
"status": "completed",
"input": {"text": input_text},
"output": result if isinstance(result, dict) else {"result": str(result)},
}
except Exception as exc:
return {
"id": run_id,
"workflow_id": workflow_id,
"status": "failed",
"input": {"text": input_text},
"error": str(exc),
}
@@ -0,0 +1,22 @@
"""AGENTS.md-style context file injection for host apps."""
from __future__ import annotations
from pathlib import Path
from typing import List, Optional
def load_context_files(
paths: Optional[List[str]] = None,
*,
cwd: Optional[Path] = None,
) -> str:
"""Load context from AGENTS.md-style files and return combined text."""
base = cwd or Path.cwd()
candidates = paths or ["AGENTS.md", "agents.md", ".agents/AGENTS.md"]
chunks: List[str] = []
for name in candidates:
path = base / name
if path.is_file():
chunks.append(path.read_text(encoding="utf-8"))
return "\n\n".join(chunks)
@@ -0,0 +1,27 @@
"""Pattern C — integrated AIUIGateway launcher."""
from __future__ import annotations
from typing import Any, Optional
async def run_integrated_gateway(
*,
port: int = 8080,
host: str = "127.0.0.1",
static_dir: Optional[str] = None,
ui_config: Optional[dict[str, Any]] = None,
**configure_kwargs: Any,
) -> None:
"""Start ``AIUIGateway`` after ``configure_host()`` (Pattern B/C parity)."""
from praisonai.integration.host_app import configure_host
from praisonaiui.integration import AIUIGateway
configure_host(**configure_kwargs)
gateway = AIUIGateway(
host=host,
port=port,
static_dir=static_dir,
ui_config=ui_config or {},
)
await gateway.start()
@@ -0,0 +1,163 @@
"""Official PraisonAI → PraisonAIUI host bootstrap (Pattern B).
Wires ``PraisonAISessionDataStore`` + ``PraisonAIProvider`` before ``create_app()``.
Set ``PRAISONAI_HOST_LEGACY=1`` to skip provider wiring (callback-only mode).
"""
from __future__ import annotations
import os
from typing import Any, Dict, List, Optional, Sequence
_CONFIGURED = False
def is_legacy_host() -> bool:
"""True when callback-only ``@aiui.reply`` mode is requested."""
return os.environ.get("PRAISONAI_HOST_LEGACY", "").strip().lower() in (
"1",
"true",
"yes",
)
def configure_host(
*,
pages: Optional[Sequence[str]] = None,
title: str = "PraisonAI",
logo: str = "🤖",
sidebar: bool = True,
page_header: bool = True,
theme: Optional[Dict[str, Any]] = None,
agents: Optional[List[Any]] = None,
agent_kwargs: Optional[Dict[str, Any]] = None,
gateway: Any = None,
modules: Optional[Sequence[str]] = None,
) -> None:
"""Apply PraisonAIUI host settings and wire L1 backends (unless legacy mode)."""
global _CONFIGURED
import praisonaiui as aiui
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
aiui.set_datastore(PraisonAISessionDataStore())
aiui.set_style("dashboard")
aiui.set_branding(title=title, logo=logo)
if pages is not None:
aiui.set_pages(list(pages))
dashboard_opts: Dict[str, Any] = {"sidebar": sidebar, "page_header": page_header}
if modules:
dashboard_opts["modules"] = list(modules)
aiui.set_dashboard(**dashboard_opts)
if theme:
aiui.set_theme(**theme)
try:
from praisonai.ui._external_agents import aiui_settings_entries
external = aiui_settings_entries()
if external and hasattr(aiui, "set_settings"):
aiui.set_settings(external)
except ImportError:
pass
if gateway is not None:
try:
from praisonaiui.features._gateway_ref import set_gateway
set_gateway(gateway)
except ImportError:
pass
if not is_legacy_host():
from praisonaiui.providers import PraisonAIProvider
from praisonaiui.server import set_provider
kwargs = dict(agent_kwargs or {})
if agents:
set_provider(PraisonAIProvider(agents=list(agents), **kwargs))
else:
set_provider(
PraisonAIProvider(
name=kwargs.pop("name", "PraisonAI"),
instructions=kwargs.pop(
"instructions",
"You are a helpful assistant.",
),
llm=kwargs.pop(
"llm", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")
),
**kwargs,
)
)
setup_bridges()
_CONFIGURED = True
def setup_bridges() -> None:
"""Register optional L1→L2 bridge hooks (usage sink, schedules, aiui backends)."""
import logging
log = logging.getLogger(__name__)
sink = None
try:
from praisonai.integration.bridges.usage_bridge import register_usage_sink
sink = register_usage_sink()
except Exception as exc:
log.debug("usage bridge unavailable: %s", exc)
try:
from praisonai.integration.bridges.schedules_runner import ensure_schedule_runner
ensure_schedule_runner()
except Exception as exc:
log.debug("schedule runner unavailable: %s", exc)
try:
import praisonaiui.backends as backends
from praisonai.integration.bridges.hooks_query import list_hooks_for_api
from praisonai.integration.bridges.workflows_service import run_workflow as svc_run
def _workflow_backend(wf_id, *, workflow, input_data):
text = (input_data or {}).get("text") or (input_data or {}).get("message") or ""
return svc_run(wf_id, input_text=text, workflow_config=workflow)
backends.set_backend("hooks", list_hooks_for_api)
backends.set_backend("workflows", _workflow_backend)
if sink is not None:
backends.set_backend("usage_sink", sink)
from praisonai.integration.bridges.usage_bridge import get_usage_query
query = get_usage_query()
if query is not None:
backends.set_backend("usage_query", query)
from praisonai.integration.bridges.approvals_bridge import (
get_approval_policies,
list_pending_approvals,
)
backends.set_backend("approvals_pending", list_pending_approvals)
backends.set_backend("approvals_policies", get_approval_policies)
except Exception as exc:
log.debug("aiui backend injection failed: %s", exc)
def create_host_app():
"""Return the Starlette app from PraisonAIUI (call after ``configure_host``)."""
from praisonaiui.server import create_app
if not _CONFIGURED:
configure_host()
return create_app()
def build_host_app(**configure_kwargs):
"""One-shot: configure host + return ``create_app()``."""
configure_host(**configure_kwargs)
return create_host_app()
@@ -0,0 +1,21 @@
"""Optional L3 page — bot / channel health."""
from __future__ import annotations
import praisonaiui as aiui
@aiui.page("bot-health", title="Bot Health", icon="🤖")
async def bot_health_page():
"""Dashboard page for channel and gateway status."""
status = {"gateway": "unknown", "channels": []}
try:
from praisonaiui.features._gateway_ref import get_gateway
gw = get_gateway()
if gw is not None:
status["gateway"] = "running"
status["agents"] = gw.list_agents() if hasattr(gw, "list_agents") else []
except ImportError:
pass
return status
@@ -0,0 +1,16 @@
"""Optional L3 page — workflow runs table."""
from __future__ import annotations
import praisonaiui as aiui
@aiui.page("workflow-runs", title="Workflow Runs", icon="🔄")
async def workflow_runs_page():
"""Dashboard page listing recent workflow runs."""
try:
from praisonai.integration.bridges.workflows_service import run_workflow
except ImportError:
return {"runs": [], "note": "Workflow bridge unavailable"}
return {"runs": [], "service": "WorkflowRunService", "status": "ready"}
+7 -12
View File
@@ -30,20 +30,15 @@ def __getattr__(name):
from .agent_scheduler import create_agent_scheduler
return create_agent_scheduler
elif name == 'create_scheduler':
# Return a factory function that creates a mock scheduler for testing
def _create_scheduler(provider='gcp', **kwargs):
from .agent_scheduler import AgentScheduler
# Create a mock agent and task for testing
class MockAgent:
pass
return AgentScheduler(MockAgent(), "test task")
return _create_scheduler
# Return the real deployment scheduler factory
from .deployment import create_deployment_scheduler
return create_deployment_scheduler
elif name == 'DeploymentScheduler':
from .agent_scheduler import AgentScheduler
return AgentScheduler
from .deployment import DeploymentScheduler
return DeploymentScheduler
elif name == 'create_deployment_scheduler':
from .agent_scheduler import create_agent_scheduler
return create_agent_scheduler
from .deployment import create_deployment_scheduler
return create_deployment_scheduler
elif name in ('ScheduledAgentExecutor', 'JobResult'):
from .executor import ScheduledAgentExecutor, JobResult
return ScheduledAgentExecutor if name == 'ScheduledAgentExecutor' else JobResult
@@ -1,11 +1,10 @@
"""
DEPRECATED: This module is deprecated. Use praisonai.scheduler instead.
Real deployment scheduler that integrates with the actual deployment system.
This file is kept for backward compatibility only.
All new code should import from praisonai.scheduler.
This module provides the actual deployment scheduler implementation that was
previously shadowed by the mock in __init__.py.
"""
import warnings
import logging
import threading
import time
@@ -14,18 +13,7 @@ from abc import ABC, abstractmethod
logger = logging.getLogger(__name__)
# Issue deprecation warning
warnings.warn(
"praisonai.scheduler module (root level) is deprecated. "
"Use 'from praisonai.scheduler import ...' instead.",
DeprecationWarning,
stacklevel=2
)
# Import from new scheduler module for backward compatibility
from .scheduler.base import ScheduleParser
# Keep old classes for backward compatibility
class DeployerInterface(ABC):
"""Abstract interface for deployers to ensure provider compatibility."""
@@ -34,35 +22,56 @@ class DeployerInterface(ABC):
"""Execute deployment. Returns True on success, False on failure."""
pass
class CloudDeployerAdapter(DeployerInterface):
"""Adapter for existing CloudDeployer to match interface."""
class DeployHandlerAdapter(DeployerInterface):
"""Adapter for the real DeployHandler to match the scheduler interface."""
def __init__(self):
from .deploy import CloudDeployer
self._deployer = CloudDeployer()
def __init__(self, provider: str = "gcp", config: Optional[Dict[str, Any]] = None):
self.provider = provider
self.config = config or {}
def deploy(self) -> bool:
"""Execute deployment using CloudDeployer."""
"""Execute deployment using the real DeployHandler."""
try:
self._deployer.run_commands()
from praisonai.cli.features.deploy import DeployHandler
handler = DeployHandler()
# Create args object for handler
class DeployArgs:
def __init__(self):
self.file = "agents.yaml"
self.type = None
self.provider = self.provider if hasattr(self, 'provider') else 'gcp'
self.json = False
self.background = False
# Create args with the provider from the scheduler
deploy_args = DeployArgs()
deploy_args.provider = self.provider
handler.handle_deploy(deploy_args)
return True
except Exception as e:
logger.error(f"Deployment failed: {e}")
return False
class DeploymentScheduler:
"""
Minimal deployment scheduler with provider-agnostic design.
Real deployment scheduler with provider-agnostic design.
Features:
- Simple interval-based scheduling
- Thread-safe operation
- Extensible deployer factory pattern
- Minimal dependencies
- Integrates with actual DeployHandler
- Provider dispatch support
"""
def __init__(self, schedule_config: Optional[Dict[str, Any]] = None):
self.config = schedule_config or {}
def __init__(self, provider: str = "gcp", config: Optional[Dict[str, Any]] = None):
self.provider = provider
self.config = config or {}
self.is_running = False
self._stop_event = threading.Event()
self._thread = None
@@ -77,8 +86,8 @@ class DeploymentScheduler:
if self._deployer:
return self._deployer
# Default to CloudDeployer for backward compatibility
return CloudDeployerAdapter()
# Use real DeployHandler via adapter
return DeployHandlerAdapter(self.provider, self.config)
def start(self, schedule_expr: str, max_retries: int = 3) -> bool:
"""
@@ -96,6 +105,7 @@ class DeploymentScheduler:
return False
try:
from .base import ScheduleParser
interval = ScheduleParser.parse(schedule_expr)
self.is_running = True
self._stop_event.clear()
@@ -165,30 +175,16 @@ class DeploymentScheduler:
logger.error(f"One-time deployment failed: {e}")
return False
def create_scheduler(provider: str = "gcp", config: Optional[Dict[str, Any]] = None) -> DeploymentScheduler:
def create_deployment_scheduler(provider: str = "gcp", config: Optional[Dict[str, Any]] = None) -> DeploymentScheduler:
"""
Factory function to create scheduler for different providers.
Factory function to create a real deployment scheduler for different providers.
Args:
provider: Deployment provider ("gcp", "aws", "azure", etc.)
config: Optional configuration dict
Returns:
Configured DeploymentScheduler instance
Configured DeploymentScheduler instance that uses real deployment logic
"""
scheduler = DeploymentScheduler(config)
# Provider-specific deployer setup can be added here
if provider == "gcp":
# Default CloudDeployer for GCP
pass
elif provider == "aws":
# Future: AWS deployer implementation
logger.warning("AWS provider not yet implemented, using default")
elif provider == "azure":
# Future: Azure deployer implementation
logger.warning("Azure provider not yet implemented, using default")
else:
logger.warning(f"Unknown provider {provider}, using default")
return scheduler
return DeploymentScheduler(provider, config)
+43 -9
View File
@@ -45,19 +45,26 @@ class ToolResolver:
Attributes:
_local_tools_cache: Cached tools from local tools.py
_local_tools_loaded: Whether local tools have been loaded
_registry: Optional ToolRegistry for wrapper-level tool registration
"""
def __init__(self, tools_py_path: Optional[str] = None):
def __init__(
self,
tools_py_path: Optional[str] = None,
registry: Optional["ToolRegistry"] = None,
):
"""Initialize the resolver.
Args:
tools_py_path: Optional path to tools.py. If None, uses ./tools.py
registry: Optional ToolRegistry to include in resolution chain
"""
self._tools_py_path = tools_py_path or "tools.py"
self._local_tools_cache: Mapping[str, Callable] = MappingProxyType({})
self._local_tools_loaded: bool = False
self._praisonai_tools_available: Optional[bool] = None
self._local_tools_lock = threading.Lock()
self._registry = registry
def _load_local_tools(self) -> Mapping[str, Callable]:
"""Load tools from local tools.py file.
@@ -163,7 +170,8 @@ class ToolResolver:
"""
# Cache availability check
if self._praisonai_tools_available is None:
self._praisonai_tools_available = importlib.util.find_spec("praisonai_tools") is not None
from ._framework_availability import is_available
self._praisonai_tools_available = is_available("praisonai_tools")
if not self._praisonai_tools_available:
return None
@@ -186,6 +194,25 @@ class ToolResolver:
return None
def _resolve_from_wrapper_registry(self, name: str) -> Optional[Callable]:
"""Resolve tool from the wrapper ToolRegistry.
Args:
name: Tool name to resolve
Returns:
Callable if found, None otherwise
"""
if self._registry is None:
return None
tool = self._registry.get_function(name)
if tool is not None:
logger.debug(f"Resolved '{name}' from wrapper ToolRegistry")
return tool
return None
def _resolve_from_registry(self, name: str) -> Optional[Callable]:
"""Resolve tool from the global tool registry.
@@ -213,9 +240,10 @@ class ToolResolver:
Resolution order:
1. Local tools.py (backward compat, custom tools)
2. praisonaiagents.tools.TOOL_MAPPINGS (built-in)
3. praisonai-tools package (external, optional)
4. Tool registry (plugins)
2. Wrapper ToolRegistry (register_function API)
3. praisonaiagents.tools.TOOL_MAPPINGS (built-in)
4. praisonai-tools package (external, optional)
5. Core SDK tool registry (plugins)
Args:
name: Tool name to resolve
@@ -236,17 +264,22 @@ class ToolResolver:
logger.debug(f"Resolved '{name}' from local tools.py")
return local_tools[name]
# 2. Check praisonaiagents.tools
# 2. Check wrapper ToolRegistry (NEW - ahead of SDK paths)
tool = self._resolve_from_wrapper_registry(name)
if tool is not None:
return tool
# 3. Check praisonaiagents.tools
tool = self._resolve_from_praisonaiagents(name)
if tool is not None:
return tool
# 3. Check praisonai-tools package
# 4. Check praisonai-tools package
tool = self._resolve_from_praisonai_tools(name)
if tool is not None:
return tool
# 4. Check tool registry
# 5. Check core SDK tool registry
tool = self._resolve_from_registry(name)
if tool is not None:
return tool
@@ -314,7 +347,8 @@ class ToolResolver:
# 3. Add praisonai-tools (if installed)
if self._praisonai_tools_available is None:
self._praisonai_tools_available = importlib.util.find_spec("praisonai_tools") is not None
from ._framework_availability import is_available
self._praisonai_tools_available = is_available("praisonai_tools")
if self._praisonai_tools_available:
try:
@@ -7,22 +7,22 @@ Requires: pip install "praisonai[ui]"
"""
import os
import praisonaiui as aiui
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
from praisonai.integration.host_app import configure_host, create_host_app, is_legacy_host
# ── Set up datastore bridge ─────────────────────────────────
aiui.set_datastore(PraisonAISessionDataStore())
configure_host(
title="PraisonAI Agents",
logo="🤖",
pages=["chat", "agents", "memory", "sessions"],
theme={"preset": "blue", "dark_mode": True, "radius": "lg"},
agent_kwargs={
"name": "AgentsRunner",
"instructions": "You are an agent runner that coordinates YAML-defined workflows.",
"llm": os.getenv("MODEL_NAME", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")),
},
)
# ── Dashboard style ─────────────────────────────────────────
aiui.set_style("dashboard")
aiui.set_branding(title="PraisonAI Agents", logo="🤖")
aiui.set_theme(preset="blue", dark_mode=True, radius="lg")
aiui.set_pages([
"chat",
"agents",
"memory",
"sessions",
])
# ── Load agents from YAML ───────────────────────────────────
def _load_agents_from_yaml():
@@ -30,20 +30,25 @@ def _load_agents_from_yaml():
agents_file = os.path.join(os.getcwd(), "agents.yaml")
if not os.path.exists(agents_file):
return []
try:
import yaml
with open(agents_file, 'r') as f:
with open(agents_file, "r") as f:
config = yaml.safe_load(f)
agents = []
if 'agents' in config:
for agent_def in config['agents']:
if "agents" in config:
for agent_def in config["agents"]:
agents.append({
"name": agent_def.get('name', 'Agent'),
"description": agent_def.get('description', 'YAML-defined agent'),
"instructions": agent_def.get('instructions', agent_def.get('role', '')),
"model": agent_def.get('model', os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")),
"name": agent_def.get("name", "Agent"),
"description": agent_def.get("description", "YAML-defined agent"),
"instructions": agent_def.get(
"instructions", agent_def.get("role", "")
),
"model": agent_def.get(
"model", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")
),
"icon": "🤖",
})
return agents
@@ -51,13 +56,13 @@ def _load_agents_from_yaml():
print(f"⚠️ Failed to load agents.yaml: {e}")
return []
# ── Register YAML agents ────────────────────────────────────
yaml_agents = _load_agents_from_yaml()
if yaml_agents:
try:
from praisonaiui.features.agents import get_agent_registry
registry = get_agent_registry()
for agent_def in yaml_agents:
registry.create(agent_def)
print(f" ✓ Agent: {agent_def['icon']} {agent_def['name']}")
@@ -66,7 +71,6 @@ if yaml_agents:
else:
print(" ℹ️ No agents.yaml found, using default agents")
# ── Default agents if no YAML ───────────────────────────────
if not yaml_agents:
DEFAULT_AGENTS = [
{
@@ -77,17 +81,18 @@ if not yaml_agents:
"icon": "🤖",
},
]
try:
from praisonaiui.features.agents import get_agent_registry
registry = get_agent_registry()
for agent_def in DEFAULT_AGENTS:
registry.create(agent_def)
print(f" ✓ Default Agent: {agent_def['icon']} {agent_def['name']}")
except Exception as e:
print(f" ⚠️ Default agent registration failed: {e}")
@aiui.starters
async def get_starters():
"""Suggest conversation starters for agents."""
@@ -97,43 +102,48 @@ async def get_starters():
{"label": "Agent Status", "message": "Show status of all agents", "icon": "📊"},
]
@aiui.welcome
async def on_welcome():
"""Welcome message."""
await aiui.say("👋 Welcome to PraisonAI Agents! This interface runs YAML-defined multi-agent workflows.")
await aiui.say(
"👋 Welcome to PraisonAI Agents! This interface runs YAML-defined multi-agent workflows."
)
# Session-scoped agent cache
_agents_cache = {}
@aiui.reply
async def on_message(message: str):
"""Handle messages with agent execution."""
await aiui.think("Processing...")
try:
from praisonaiagents import Agent
# Create or get cached agent
session_id = getattr(aiui.current_session, 'id', 'default')
if session_id not in _agents_cache:
_agents_cache[session_id] = Agent(
name="AgentsRunner",
instructions="You are an agent runner that coordinates YAML-defined workflows.",
llm=os.getenv("MODEL_NAME", "gpt-4o-mini"),
)
agent = _agents_cache[session_id]
result = await agent.achat(str(message))
# Stream the response
response_text = str(result) if result else "No response"
words = response_text.split(" ")
for i, word in enumerate(words):
await aiui.stream_token(word + (" " if i < len(words) - 1 else ""))
except Exception as e:
await aiui.say(f"❌ Error: {e}")
if is_legacy_host():
_agents_cache = {}
@aiui.cancel
async def on_cancel():
await aiui.say("⏹️ Stopped.")
@aiui.reply
async def on_message(message: str):
"""Handle messages with agent execution."""
await aiui.think("Processing...")
try:
from praisonaiagents import Agent
session_id = getattr(aiui.current_session, "id", "default")
if session_id not in _agents_cache:
_agents_cache[session_id] = Agent(
name="AgentsRunner",
instructions="You are an agent runner that coordinates YAML-defined workflows.",
llm=os.getenv("MODEL_NAME", "gpt-4o-mini"),
)
agent = _agents_cache[session_id]
result = await agent.achat(str(message))
response_text = str(result) if result else "No response"
words = response_text.split(" ")
for i, word in enumerate(words):
await aiui.stream_token(word + (" " if i < len(words) - 1 else ""))
except Exception as e:
await aiui.say(f"❌ Error: {e}")
@aiui.cancel
async def on_cancel():
await aiui.say("⏹️ Stopped.")
app = create_host_app()
Loaded 100 of 114 files, more files were not shown because too many files have changed in this diff. Show more