mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 11:41:32 -10:00
Compare commits
104
Commits
add-valkey
...
v4.6.52
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a8ce9eb7d | ||
|
|
9fcac3a951 | ||
|
|
6878780000 | ||
|
|
e55789e8cf | ||
|
|
e0fb8e7dd1 | ||
|
|
bc73dd1db9 | ||
|
|
5216274924 | ||
|
|
2e5eca1db6 | ||
|
|
f51fe294e7 | ||
|
|
a1b7ed5c91 | ||
|
|
aaed51eee8 | ||
|
|
48407be580 | ||
|
|
0ad42ff2c5 | ||
|
|
2943c3b77e | ||
|
|
84627f593c | ||
|
|
927020cea8 | ||
|
|
23ca2a80d6 | ||
|
|
f931173e60 | ||
|
|
ba12269d5d | ||
|
|
7839196391 | ||
|
|
e958968307 | ||
|
|
1c26a5bf14 | ||
|
|
ba8cd6b3c1 | ||
|
|
8327f8e8b8 | ||
|
|
f78ba6348c | ||
|
|
4f3daa8628 | ||
|
|
d7f68a0870 | ||
|
|
1bf62b0416 | ||
|
|
d5f1114aaf | ||
|
|
d364fa5020 | ||
|
|
c1db6317c8 | ||
|
|
f88727cb75 | ||
|
|
7ab0479e82 | ||
|
|
0e0241d15f | ||
|
|
5815dc61bf | ||
|
|
e720c0bc3d | ||
|
|
cd448fa710 | ||
|
|
c35dadebdb | ||
|
|
f9ae9e9aa4 | ||
|
|
9d9a9a7ad8 | ||
|
|
44d9d8c4c0 | ||
|
|
bf2656cee9 | ||
|
|
d55cd989ea | ||
|
|
0305214695 | ||
|
|
3f26bf166c | ||
|
|
ab19a13cc4 | ||
|
|
28d487b66d | ||
|
|
a06239fb15 | ||
|
|
dabe2cf671 | ||
|
|
619787c9da | ||
|
|
2fa6a6efd0 | ||
|
|
8acf77c531 | ||
|
|
5ff79d39c9 | ||
|
|
6902147f13 | ||
|
|
321b6b28a9 | ||
|
|
1c5b58aa13 | ||
|
|
71834d39d7 | ||
|
|
6971d8ecc9 | ||
|
|
8348c2b7ab | ||
|
|
9cfc636a92 | ||
|
|
b43e93c502 | ||
|
|
b09450d9dc | ||
|
|
710d607f6e | ||
|
|
a7910c2eb0 | ||
|
|
c98e1a1972 | ||
|
|
aee9552dd0 | ||
|
|
6507b8e7ef | ||
|
|
50652531e7 | ||
|
|
a949579759 | ||
|
|
79702ba1ff | ||
|
|
e9b75bfef9 | ||
|
|
982b7dfe4f | ||
|
|
f372821b9e | ||
|
|
445003042e | ||
|
|
00b40e8cc4 | ||
|
|
6dca959eda | ||
|
|
5ba8782bf7 | ||
|
|
a2402063f8 | ||
|
|
302bbcbec2 | ||
|
|
ac309e99ea | ||
|
|
727ee626c8 | ||
|
|
1dc61d3946 | ||
|
|
50a0399f1c | ||
|
|
40bfd66797 | ||
|
|
0da4a90c80 | ||
|
|
dc75c51fea | ||
|
|
b95a7f6ded | ||
|
|
92953fdbe6 | ||
|
|
b3cd7ecfd0 | ||
|
|
01ea0f5a05 | ||
|
|
eddcae8d31 | ||
|
|
71f891003e | ||
|
|
13750a4ccd | ||
|
|
c9e1d1a202 | ||
|
|
5515290329 | ||
|
|
cb1cefd94b | ||
|
|
a90666a19f | ||
|
|
89df701c2a | ||
|
|
480848c26d | ||
|
|
a064a286f3 | ||
|
|
1d78204b0b | ||
|
|
bba5d43cc1 | ||
|
|
c2dfa859c3 | ||
|
|
bb8a56e33c |
No files matched your search
@@ -0,0 +1,158 @@
|
||||
name: Cross-Repo Integration Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'src/praisonai/praisonai/integration/**'
|
||||
- 'src/praisonai/tests/integration/test_aiui_*'
|
||||
- 'src/praisonai/pyproject.toml'
|
||||
pull_request:
|
||||
branches: [ main ]
|
||||
paths:
|
||||
- 'src/praisonai/praisonai/integration/**'
|
||||
- 'src/praisonai/tests/integration/test_aiui_*'
|
||||
- 'src/praisonai/pyproject.toml'
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
cross-repo-integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: Checkout PraisonAI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: PraisonAI
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Checkout PraisonAIUI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: MervinPraison/PraisonAIUI
|
||||
path: PraisonAIUI
|
||||
ref: main
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
# Install PraisonAI packages in development mode
|
||||
cd PraisonAI/src/praisonai-agents
|
||||
pip install -e .
|
||||
cd ../praisonai
|
||||
pip install -e ".[ui,dev]"
|
||||
# Install PraisonAIUI in development mode
|
||||
cd ../../../PraisonAIUI
|
||||
pip install -e .
|
||||
|
||||
- name: Verify versions
|
||||
run: |
|
||||
python -c "import praisonaiagents; print(f'praisonaiagents: {praisonaiagents.__version__}')"
|
||||
python -c "import praisonai; print(f'praisonai: {praisonai.__version__}')"
|
||||
python -c "import praisonaiui; print(f'praisonaiui: {praisonaiui.__version__}')"
|
||||
|
||||
- name: Run PraisonAI integration tests
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
python -m pytest tests/integration/test_aiui_* -v --timeout=300
|
||||
|
||||
- name: Run PraisonAIUI integration tests
|
||||
run: |
|
||||
cd PraisonAIUI
|
||||
if [ -f tests/integration/test_agentic_roundtrip.py ]; then
|
||||
python -m pytest tests/integration/test_agentic_roundtrip.py -v --timeout=300
|
||||
fi
|
||||
if [ -f tests/test_feature_sdk_backends.py ]; then
|
||||
python -m pytest tests/test_feature_sdk_backends.py -v --timeout=300
|
||||
fi
|
||||
|
||||
- name: Test Pattern C CLI
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
# Test that ui-gateway command exists
|
||||
python -m praisonai serve --help | grep ui-gateway
|
||||
|
||||
- name: Test public API exports
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
python -c "from praisonai import run_integrated_gateway; print('✓ run_integrated_gateway imported')"
|
||||
python -c "from praisonai import configure_host; print('✓ configure_host imported')"
|
||||
|
||||
- name: Integration smoke test
|
||||
env:
|
||||
PRAISONAI_TEST_MODE: "1"
|
||||
PYTHONPATH: ${{ github.workspace }}/PraisonAI/src/praisonai:${{ github.workspace }}/PraisonAI/src/praisonai-agents:${{ github.workspace }}/PraisonAIUI
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
python -c "
|
||||
import sys
|
||||
sys.path.insert(0, '.')
|
||||
from praisonai.integration.host_app import configure_host
|
||||
from praisonai import run_integrated_gateway
|
||||
print('✓ Integration imports successful')
|
||||
|
||||
# Test configure_host with new parameters
|
||||
try:
|
||||
configure_host(
|
||||
style='dashboard',
|
||||
context_paths=['AGENTS.md'],
|
||||
title='Test App'
|
||||
)
|
||||
print('✓ configure_host with new parameters works')
|
||||
except Exception as e:
|
||||
print(f'× configure_host failed: {e}')
|
||||
sys.exit(1)
|
||||
"
|
||||
|
||||
# Optional job with API key for agentic tests (if secret is available)
|
||||
agentic-integration:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
if: ${{ vars.RUN_AGENTIC_TESTS == 'true' }}
|
||||
needs: cross-repo-integration
|
||||
|
||||
steps:
|
||||
- name: Checkout PraisonAI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
path: PraisonAI
|
||||
ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.sha }}
|
||||
|
||||
- name: Checkout PraisonAIUI
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
repository: MervinPraison/PraisonAIUI
|
||||
path: PraisonAIUI
|
||||
ref: main
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@v4
|
||||
with:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
python -m pip install --upgrade pip
|
||||
cd PraisonAI/src/praisonai-agents
|
||||
pip install -e .
|
||||
cd ../praisonai
|
||||
pip install -e ".[ui,dev]"
|
||||
cd ../../../PraisonAIUI
|
||||
pip install -e .
|
||||
|
||||
- name: Run agentic integration test
|
||||
env:
|
||||
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
|
||||
run: |
|
||||
cd PraisonAI/src/praisonai
|
||||
if [ -n "$OPENAI_API_KEY" ]; then
|
||||
python -m pytest tests/integration/test_aiui_host_agentic.py -v --timeout=600
|
||||
else
|
||||
echo "OPENAI_API_KEY not available, skipping agentic tests"
|
||||
fi
|
||||
@@ -382,7 +382,7 @@ praisonai flow
|
||||
|
||||
Open **http://localhost:7861** — use the **Agent** and **Agent Team** components to create sequential or parallel workflows. Connect Chat Input → Agent Team → Chat Output for instant multi-agent pipelines.
|
||||
|
||||
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/flow) — visual agent building, component reference, and deployment
|
||||
> 📖 [Full Flow docs](https://docs.praison.ai/docs/concepts/agentflow) — visual agent building, component reference, and deployment
|
||||
|
||||
### 8. PraisonAI UI 🤖 (Clean Chat)
|
||||
|
||||
|
||||
@@ -40,6 +40,7 @@ Methods:
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">chat_with_context</a>(message: str, context: 'ContextPack', **kwargs) -> str</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>() -> Optional[bool]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">checkpoints</a>(value: Optional[bool]) -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">clone_for_channel</a>() -> 'Agent'</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">close</a>() -> None</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">console</a>() -> Optional[Any]</code>
|
||||
* <code title="class Agent">Agent.<a href="./src/praisonai-agents/praisonaiagents/agent/agent.py">context_manager</a>() -> Optional[Any]</code>
|
||||
@@ -171,6 +172,7 @@ Methods:
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">safe_run</a>(**kwargs) -> ToolResult</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate</a>() -> bool</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_class</a>() -> bool</code>
|
||||
* <code title="class BaseTool">BaseTool.<a href="./src/praisonai-agents/praisonaiagents/tools/base.py">validate_schema_roundtrip</a>() -> bool</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">__call__</a>(*args, **kwargs) -> Any</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">check_availability</a>() -> tuple[bool, str]</code>
|
||||
* <code title="class FunctionTool">FunctionTool.<a href="./src/praisonai-agents/praisonaiagents/tools/decorator.py">injected_params</a>() -> Dict[str, Any]</code>
|
||||
@@ -477,8 +479,10 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">edit_skill</a>(name: str, content: str) -> dict</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_allowed_tools</a>(name: str) -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills</a>() -> List[SkillMetadata]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_available_skills_by_state</a>(state: SkillState) -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_instructions</a>(name: str) -> Optional[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skill</a>(name: str) -> Optional[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_skills_diagnostics</a>() -> Dict[str, ValidationResult]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">get_user_invocable_skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">invoke</a>(name: str, raw_args: str = '', session_id: Optional[str] = None, shell_exec: bool = False) -> Optional[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">load_resources</a>(name: str) -> bool</code>
|
||||
@@ -487,6 +491,7 @@ Methods:
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skill_names</a>() -> List[str]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">skills</a>() -> List[LoadedSkill]</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">to_prompt</a>() -> str</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">validate_skill_capabilities</a>(skill_name: str, force_refresh: bool = False) -> ValidationResult</code>
|
||||
* <code title="class SkillManager">SkillManager.<a href="./src/praisonai-agents/praisonaiagents/skills/manager.py">write_skill_file</a>(name: str, file_path: str, file_content: str) -> dict</code>
|
||||
* <code title="class SkillMetadata">SkillMetadata.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">from_properties</a>(props: SkillProperties) -> 'SkillMetadata'</code>
|
||||
* <code title="class SkillProperties">SkillProperties.<a href="./src/praisonai-agents/praisonaiagents/skills/models.py">to_dict</a>() -> dict</code>
|
||||
@@ -602,6 +607,13 @@ Methods:
|
||||
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">set_tool_budget</a>(tool_name: str, max_tokens: int, protected: bool = False) -> None</code>
|
||||
* <code title="class ContextManager">ContextManager.<a href="./src/praisonai-agents/praisonaiagents/context/manager.py">truncate_tool_output</a>(tool_name: str, output: str) -> str</code>
|
||||
|
||||
# UI
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonaiagents import A2UI
|
||||
```
|
||||
|
||||
# Config
|
||||
|
||||
Types:
|
||||
@@ -756,7 +768,7 @@ Methods:
|
||||
|
||||
Types:
|
||||
```python
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__, arun, run, run_integrated_gateway
|
||||
```
|
||||
|
||||
# CLI
|
||||
@@ -861,6 +873,7 @@ Methods:
|
||||
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor skills <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor troubleshoot <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai endpoints list <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
|
||||
@@ -892,11 +905,24 @@ Methods:
|
||||
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway stop <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway uninstall <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai github triage <a href="./src/praisonai/praisonai/cli/commands/github.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks list <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks remove <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban block <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban boards <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban comment <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban complete <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban create <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban dispatch <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban link <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban list <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban move <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban reclaim <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban show <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai kanban unblock <a href="./src/praisonai/praisonai/cli/commands/kanban.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge add <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge index <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
* <code title="cli">praisonai knowledge list <a href="./src/praisonai/praisonai/cli/commands/knowledge.py">--help</a></code>
|
||||
@@ -1054,6 +1080,7 @@ Methods:
|
||||
* <code title="cli">praisonai serve status <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve stop <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve ui <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve ui-gateway <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve unified <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai session delete <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai session export <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.43" \
|
||||
"praisonai>=4.6.52" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.43" \
|
||||
"praisonai>=4.6.52" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.43" \
|
||||
"praisonai>=4.6.52" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -16,8 +16,19 @@ def search_web(query: str) -> str:
|
||||
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression."""
|
||||
return str(eval(expression))
|
||||
"""Calculate a math expression safely."""
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
return str(_ev(ast.parse(expression, mode="eval")))
|
||||
|
||||
|
||||
# Check if we have an API key
|
||||
|
||||
@@ -19,20 +19,43 @@ import os
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""
|
||||
A simple calculator tool that evaluates mathematical expressions.
|
||||
|
||||
A simple calculator tool that evaluates mathematical expressions safely.
|
||||
|
||||
Args:
|
||||
expression: A mathematical expression to evaluate (e.g., "2 + 2")
|
||||
|
||||
|
||||
Returns:
|
||||
The result of the calculation
|
||||
"""
|
||||
import ast
|
||||
import operator
|
||||
|
||||
_OPS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod,
|
||||
ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
}
|
||||
|
||||
def _safe_eval(node):
|
||||
if isinstance(node, ast.Expression):
|
||||
return _safe_eval(node.body)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return node.value
|
||||
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_safe_eval(node.operand))
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_safe_eval(node.left), _safe_eval(node.right))
|
||||
raise ValueError(f"Unsupported expression: {ast.dump(node)}")
|
||||
|
||||
try:
|
||||
# Safe evaluation of mathematical expressions
|
||||
allowed_chars = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed_chars for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
result = eval(expression)
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
result = _safe_eval(tree)
|
||||
return f"Result: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@@ -24,12 +24,18 @@ def main():
|
||||
def calculate(expression: str) -> str:
|
||||
"""Safely evaluate a mathematical expression."""
|
||||
try:
|
||||
# Only allow safe math operations
|
||||
allowed = set("0123456789+-*/.(). ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return "Error: Invalid characters in expression"
|
||||
result = eval(expression) # Safe due to character filtering
|
||||
return f"Result: {result}"
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
return f"Result: {_ev(ast.parse(expression, mode='eval'))}"
|
||||
except Exception as e:
|
||||
return f"Error: {e}"
|
||||
|
||||
|
||||
@@ -21,9 +21,36 @@ def search_web(query: str) -> str:
|
||||
return f"Search results for: {query}"
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a mathematical expression."""
|
||||
"""Calculate a mathematical expression safely."""
|
||||
import ast
|
||||
import operator
|
||||
|
||||
_OPS = {
|
||||
ast.Add: operator.add,
|
||||
ast.Sub: operator.sub,
|
||||
ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv,
|
||||
ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod,
|
||||
ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg,
|
||||
ast.UAdd: operator.pos,
|
||||
}
|
||||
|
||||
def _eval(node):
|
||||
if isinstance(node, ast.Expression):
|
||||
return _eval(node.body)
|
||||
if isinstance(node, ast.Constant) and isinstance(node.value, (int, float)):
|
||||
return node.value
|
||||
if isinstance(node, ast.UnaryOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_eval(node.operand))
|
||||
if isinstance(node, ast.BinOp) and type(node.op) in _OPS:
|
||||
return _OPS[type(node.op)](_eval(node.left), _eval(node.right))
|
||||
raise ValueError(f"Unsupported expression: {ast.dump(node)}")
|
||||
|
||||
try:
|
||||
return f"Result: {eval(expression)}"
|
||||
tree = ast.parse(expression, mode="eval")
|
||||
return f"Result: {_eval(tree)}"
|
||||
except Exception:
|
||||
return "Invalid expression"
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Minimal A2UI example — agent + send_a2ui_messages tool.
|
||||
|
||||
Install:
|
||||
pip install praisonaiagents[a2ui]
|
||||
|
||||
Run:
|
||||
python send_surface.py
|
||||
|
||||
For live rendering, use PraisonAIUI with AG-UI (/agui) — see integrate-a2ui-frontend docs.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent
|
||||
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
|
||||
|
||||
agent = Agent(
|
||||
name="ui-assistant",
|
||||
instructions=(
|
||||
"When asked for UI, call send_a2ui_messages with A2UI v0.9 JSON "
|
||||
"(createSurface on surface id 'main')."
|
||||
),
|
||||
tools=[send_a2ui_messages],
|
||||
)
|
||||
|
||||
if __name__ == "__main__":
|
||||
result = agent.start("Show a simple welcome card on surface main")
|
||||
print(result)
|
||||
@@ -49,7 +49,18 @@ def calculate_with_tracking(expression: str) -> str:
|
||||
time.sleep(0.05)
|
||||
|
||||
try:
|
||||
result = eval(expression)
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
result = _ev(ast.parse(expression, mode="eval"))
|
||||
return f"Result: {result}"
|
||||
except Exception as e:
|
||||
return f"Error: {str(e)}"
|
||||
|
||||
@@ -36,10 +36,18 @@ def calculate(expression: str) -> dict:
|
||||
expression: Math expression (e.g., "2 + 2 * 3")
|
||||
"""
|
||||
try:
|
||||
allowed = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed for c in expression):
|
||||
return {"error": "Invalid characters"}
|
||||
return {"expression": expression, "result": eval(expression)}
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
return {"expression": expression, "result": _ev(ast.parse(expression, mode="eval"))}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
|
||||
@@ -47,12 +47,18 @@ def calculate(expression: str) -> Dict[str, Any]:
|
||||
The result of the calculation
|
||||
"""
|
||||
try:
|
||||
# Safe evaluation of mathematical expressions
|
||||
allowed_chars = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed_chars for c in expression):
|
||||
return {"error": "Invalid characters in expression"}
|
||||
|
||||
result = eval(expression) # Use safer alternative in production
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
result = _ev(ast.parse(expression, mode="eval"))
|
||||
return {"expression": expression, "result": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -49,13 +49,18 @@ def calculate(expression: str) -> Dict[str, Any]:
|
||||
The result of the calculation
|
||||
"""
|
||||
try:
|
||||
# Safe evaluation of mathematical expressions
|
||||
# In production, use a proper math parser
|
||||
allowed_chars = set("0123456789+-*/.() ")
|
||||
if not all(c in allowed_chars for c in expression):
|
||||
return {"error": "Invalid characters in expression"}
|
||||
|
||||
result = eval(expression) # Note: Use safer alternative in production
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
result = _ev(ast.parse(expression, mode="eval"))
|
||||
return {"expression": expression, "result": result}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
||||
|
||||
@@ -5,7 +5,7 @@ Demonstrates using should_run to conditionally execute agent steps
|
||||
based on input or previous results.
|
||||
"""
|
||||
|
||||
from praisonaiagents import Agent, Workflow, Task
|
||||
from praisonaiagents import Agent, AgentFlow, Task
|
||||
|
||||
# Condition functions
|
||||
def is_technical(ctx) -> bool:
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# Multi-Channel Bot Deployment Guide
|
||||
|
||||
This guide explains how to set up multiple bots on the same platform (e.g., multiple Telegram bots) for different roles using PraisonAI, similar to Hermes-style AI workforces.
|
||||
|
||||
## Overview
|
||||
|
||||
PraisonAI supports running multiple bots on the same platform, each routing to different agents with specialized knowledge and capabilities. This is useful for:
|
||||
|
||||
- **Role-based bots**: `@company_cfo_bot`, `@company_ops_bot`, `@company_content_bot`
|
||||
- **Department-specific agents**: Each with different knowledge brains and tool access
|
||||
- **Workflow separation**: Different bots handle different types of requests
|
||||
|
||||
## Quick Start with Onboard Wizard
|
||||
|
||||
The easiest way to set up multiple channels is using the onboard wizard:
|
||||
|
||||
```bash
|
||||
praisonai onboard
|
||||
```
|
||||
|
||||
The wizard now supports:
|
||||
1. **Multi-channel setup**: Add multiple bots for the same platform
|
||||
2. **Role-based naming**: Automatically generates `TELEGRAM_<ROLE>_BOT_TOKEN` environment variables
|
||||
3. **Agent routing**: Each channel routes to a different agent
|
||||
|
||||
### Onboard Wizard Flow
|
||||
|
||||
1. **Choose platform**: Start with your first platform (e.g., telegram)
|
||||
2. **Add more channels**: Wizard asks "Add another bot channel?"
|
||||
3. **Specify role**: Enter role name (e.g., "cfo", "ops", "content")
|
||||
4. **Configure tokens**: Each channel gets its own environment variable
|
||||
5. **Test connections**: Wizard tests each bot separately
|
||||
|
||||
## Environment Variable Convention
|
||||
|
||||
For multiple bots on the same platform, use this naming convention:
|
||||
|
||||
| Channel | Environment Variable | Bot Purpose |
|
||||
|---------|---------------------|-------------|
|
||||
| `telegram_cfo` | `TELEGRAM_CFO_BOT_TOKEN` | CFO-related tasks |
|
||||
| `telegram_ops` | `TELEGRAM_OPS_BOT_TOKEN` | Operations tasks |
|
||||
| `telegram_content` | `TELEGRAM_CONTENT_BOT_TOKEN` | Content creation |
|
||||
| `telegram_support` | `TELEGRAM_SUPPORT_BOT_TOKEN` | Customer support |
|
||||
|
||||
### Setting Up Tokens
|
||||
|
||||
1. **Create bots in @BotFather**:
|
||||
```text
|
||||
/newbot
|
||||
Bot name: Company CFO Bot
|
||||
Username: @company_cfo_bot
|
||||
-> Copy token
|
||||
|
||||
/newbot
|
||||
Bot name: Company Ops Bot
|
||||
Username: @company_ops_bot
|
||||
-> Copy token
|
||||
```
|
||||
|
||||
2. **Set environment variables**:
|
||||
```bash
|
||||
export TELEGRAM_CFO_BOT_TOKEN="123456789:ABC..."
|
||||
export TELEGRAM_OPS_BOT_TOKEN="987654321:DEF..."
|
||||
export TELEGRAM_CONTENT_BOT_TOKEN="456789123:GHI..."
|
||||
```
|
||||
|
||||
3. **Or use `~/.praisonai/.env`**:
|
||||
```env
|
||||
TELEGRAM_CFO_BOT_TOKEN=123456789:ABC...
|
||||
TELEGRAM_OPS_BOT_TOKEN=987654321:DEF...
|
||||
TELEGRAM_CONTENT_BOT_TOKEN=456789123:GHI...
|
||||
TELEGRAM_ALLOWED_USERS=123456789,987654321
|
||||
```
|
||||
|
||||
## Example Configuration
|
||||
|
||||
See `multi-telegram-hermes-workforce.yaml` for a complete example.
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Multiple Channels**:
|
||||
```yaml
|
||||
channels:
|
||||
telegram_cfo:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_CFO_BOT_TOKEN}
|
||||
routes:
|
||||
default: cfo
|
||||
|
||||
telegram_ops:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_OPS_BOT_TOKEN}
|
||||
routes:
|
||||
default: ops
|
||||
```
|
||||
|
||||
2. **Specialized Agents**:
|
||||
```yaml
|
||||
agents:
|
||||
cfo:
|
||||
instructions: "You are a CFO agent. Help with financial analysis..."
|
||||
model: gpt-4o-mini
|
||||
|
||||
ops:
|
||||
instructions: "You are an ops agent. Help with system monitoring..."
|
||||
model: gpt-4o-mini
|
||||
```
|
||||
|
||||
## Validation with Doctor
|
||||
|
||||
Use the doctor command to validate your multi-channel setup:
|
||||
|
||||
```bash
|
||||
praisonai doctor
|
||||
```
|
||||
|
||||
The doctor checks for:
|
||||
- ✅ **Unique tokens**: Each channel uses a different bot token
|
||||
- ✅ **Naming convention**: Follows `PLATFORM_ROLE_BOT_TOKEN` pattern
|
||||
- ✅ **Missing tokens**: All referenced environment variables are set
|
||||
- ❌ **Duplicate tokens**: Same token used by multiple channels (fails)
|
||||
|
||||
## Deployment
|
||||
|
||||
### Start Gateway
|
||||
|
||||
```bash
|
||||
# Start the multi-bot gateway
|
||||
praisonai gateway start --config multi-telegram-hermes-workforce.yaml
|
||||
|
||||
# Or install as daemon
|
||||
praisonai onboard # Wizard installs daemon automatically
|
||||
praisonai gateway status
|
||||
```
|
||||
|
||||
### Verify Setup
|
||||
|
||||
1. **Check health**: `curl http://127.0.0.1:8765/health`
|
||||
2. **View dashboard**: `praisonai claw` → http://127.0.0.1:8082
|
||||
3. **Test bots**: Send messages to each bot to verify routing
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Basic Multi-Role Setup
|
||||
```yaml
|
||||
# Single platform, multiple roles
|
||||
channels:
|
||||
telegram_support:
|
||||
token: ${TELEGRAM_SUPPORT_BOT_TOKEN}
|
||||
routes:
|
||||
default: support_agent
|
||||
|
||||
telegram_sales:
|
||||
token: ${TELEGRAM_SALES_BOT_TOKEN}
|
||||
routes:
|
||||
default: sales_agent
|
||||
```
|
||||
|
||||
### Cross-Platform Workforce
|
||||
```yaml
|
||||
# Multiple platforms, multiple roles
|
||||
channels:
|
||||
telegram_cfo:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_CFO_BOT_TOKEN}
|
||||
routes:
|
||||
default: cfo
|
||||
|
||||
discord_ops:
|
||||
platform: discord
|
||||
token: ${DISCORD_OPS_BOT_TOKEN}
|
||||
routes:
|
||||
default: ops
|
||||
|
||||
slack_content:
|
||||
platform: slack
|
||||
token: ${SLACK_CONTENT_BOT_TOKEN}
|
||||
routes:
|
||||
default: content
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Token is used by multiple channels"**
|
||||
- Each bot needs its own unique token from @BotFather
|
||||
- Check environment variables for duplicates
|
||||
|
||||
2. **"Channel skipped - no token"**
|
||||
- Set the environment variable: `export TELEGRAM_CFO_BOT_TOKEN="..."`
|
||||
- Or update `~/.praisonai/.env`
|
||||
|
||||
3. **"Bot not responding"**
|
||||
- Verify token is correct: `praisonai doctor`
|
||||
- Check bot is not already running elsewhere
|
||||
- Ensure allowlist includes your user ID
|
||||
|
||||
### Debugging Commands
|
||||
|
||||
```bash
|
||||
# Check configuration
|
||||
praisonai doctor
|
||||
|
||||
# View logs
|
||||
praisonai gateway logs
|
||||
|
||||
# Test connection
|
||||
curl -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
|
||||
http://127.0.0.1:8765/info
|
||||
```
|
||||
|
||||
## Migration from Single-Bot Setup
|
||||
|
||||
If you currently have a single bot setup, you can add more channels:
|
||||
|
||||
1. **Run onboard wizard**: `praisonai onboard`
|
||||
2. **Add new channels**: Wizard will preserve existing setup
|
||||
3. **Update configuration**: New channels added to existing `bot.yaml`
|
||||
4. **Restart gateway**: `praisonai gateway restart`
|
||||
|
||||
The wizard preserves backward compatibility with single-bot configurations.
|
||||
@@ -0,0 +1,19 @@
|
||||
# A2UI canvas agent — YAML example
|
||||
#
|
||||
# Usage:
|
||||
# pip install praisonaiagents[a2ui]
|
||||
# praisonai agent run --file a2ui-canvas.yaml --prompt "Show a welcome card"
|
||||
#
|
||||
# For live A2UI rendering, use PraisonAIUI (AG-UI / surfaces):
|
||||
# pip install praisonaiui
|
||||
# See examples/python/a2ui/send_surface.py and PraisonAIUI example 29.
|
||||
|
||||
agent:
|
||||
name: "ui-assistant"
|
||||
instructions: |
|
||||
You are a helpful assistant. When the user asks for UI elements
|
||||
(buttons, cards, forms), call send_a2ui_messages with valid A2UI v0.9 JSON.
|
||||
Use surface id "main" unless told otherwise.
|
||||
llm: gpt-4o-mini
|
||||
tools:
|
||||
- send_a2ui_messages
|
||||
@@ -0,0 +1,40 @@
|
||||
# Example: Agent with MCP Tool Integration
|
||||
#
|
||||
# This example shows how to configure an agent with MCP (Model Context Protocol)
|
||||
# servers for additional tool capabilities.
|
||||
#
|
||||
# Usage:
|
||||
# praisonai workflow run --file examples/yaml/agent-with-mcp.yaml
|
||||
|
||||
agent:
|
||||
name: mcp_assistant
|
||||
instructions: |
|
||||
You are a helpful assistant with access to filesystem and time tools.
|
||||
Use the MCP tools when needed to help users with file operations or time queries.
|
||||
llm: gpt-4o-mini
|
||||
|
||||
# MCP server configuration
|
||||
mcp:
|
||||
servers:
|
||||
filesystem:
|
||||
# Filesystem access for the agent
|
||||
command: npx
|
||||
args:
|
||||
- "-y"
|
||||
- "@modelcontextprotocol/server-filesystem"
|
||||
- "."
|
||||
env: {}
|
||||
enabled: true
|
||||
|
||||
time:
|
||||
# Time and date utilities
|
||||
command: npx
|
||||
args:
|
||||
- "-y"
|
||||
- "@modelcontextprotocol/server-time"
|
||||
env: {}
|
||||
enabled: true
|
||||
|
||||
# NOTE: Per-server tool filtering (tools.include/exclude) is not yet supported
|
||||
# by the MCP server config schema. Tool filtering should be applied after
|
||||
# calling mcp.get_tools() in your application code.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Multi-Telegram Channel Hermes Workforce Example
|
||||
# Generated by `praisonai onboard` with multi-channel support
|
||||
# Start with: praisonai gateway start --config multi-telegram-hermes-workforce.yaml
|
||||
|
||||
gateway:
|
||||
host: "127.0.0.1"
|
||||
port: 8765
|
||||
|
||||
# Legacy single-agent block for backward compatibility
|
||||
agent:
|
||||
name: "assistant"
|
||||
instructions: "You are a helpful AI assistant. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."
|
||||
model: "gpt-4o-mini"
|
||||
|
||||
# Multi-agent workforce with different specialized roles
|
||||
agents:
|
||||
cfo:
|
||||
instructions: "You are a CFO agent. You are a helpful AI assistant. Use your tools proactively to help with CFO-related tasks like financial analysis, budget planning, and cost optimization."
|
||||
model: gpt-4o-mini
|
||||
memory: false
|
||||
|
||||
ops:
|
||||
instructions: "You are an ops agent. You are a helpful AI assistant. Use your tools proactively to help with ops-related tasks like system monitoring, deployment, and infrastructure management."
|
||||
model: gpt-4o-mini
|
||||
memory: false
|
||||
|
||||
content:
|
||||
instructions: "You are a content agent. You are a helpful AI assistant. Use your tools proactively to help with content-related tasks like writing, editing, and content strategy."
|
||||
model: gpt-4o-mini
|
||||
memory: false
|
||||
|
||||
# Multiple Telegram channels - each with unique tokens and routing
|
||||
channels:
|
||||
telegram_cfo:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_CFO_BOT_TOKEN}
|
||||
group_policy: mention_only
|
||||
allowed_users: ${TELEGRAM_ALLOWED_USERS}
|
||||
# auto_approve_tools: true
|
||||
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
|
||||
routes:
|
||||
default: cfo
|
||||
|
||||
telegram_ops:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_OPS_BOT_TOKEN}
|
||||
group_policy: mention_only
|
||||
allowed_users: ${TELEGRAM_ALLOWED_USERS}
|
||||
# auto_approve_tools: true
|
||||
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
|
||||
routes:
|
||||
default: ops
|
||||
|
||||
telegram_content:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_CONTENT_BOT_TOKEN}
|
||||
group_policy: mention_only
|
||||
allowed_users: ${TELEGRAM_ALLOWED_USERS}
|
||||
# auto_approve_tools: true
|
||||
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
|
||||
routes:
|
||||
default: content
|
||||
|
||||
# Legacy routing block
|
||||
routing:
|
||||
default: assistant
|
||||
@@ -95,4 +95,5 @@ chroma_db/
|
||||
.windsurf
|
||||
.ruff_cache
|
||||
*.sfdx
|
||||
.sfdx/
|
||||
.sfdx/
|
||||
.cursor/
|
||||
@@ -79,10 +79,20 @@ def get_time(timezone: Literal["est", "pst"]) -> str:
|
||||
|
||||
|
||||
def calculate(expression: str) -> str:
|
||||
"""Calculate a math expression."""
|
||||
"""Calculate a math expression safely."""
|
||||
try:
|
||||
result = eval(expression)
|
||||
return f"Result: {result}"
|
||||
import ast, operator
|
||||
_OPS = {ast.Add: operator.add, ast.Sub: operator.sub, ast.Mult: operator.mul,
|
||||
ast.Div: operator.truediv, ast.FloorDiv: operator.floordiv,
|
||||
ast.Mod: operator.mod, ast.Pow: operator.pow,
|
||||
ast.USub: operator.neg, ast.UAdd: operator.pos}
|
||||
def _ev(n):
|
||||
if isinstance(n, ast.Expression): return _ev(n.body)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, (int, float)): return n.value
|
||||
if isinstance(n, ast.UnaryOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.operand))
|
||||
if isinstance(n, ast.BinOp) and type(n.op) in _OPS: return _OPS[type(n.op)](_ev(n.left), _ev(n.right))
|
||||
raise ValueError(f"Unsupported: {ast.dump(n)}")
|
||||
return f"Result: {_ev(ast.parse(expression, mode='eval'))}"
|
||||
except Exception:
|
||||
return "Error: Invalid expression"
|
||||
|
||||
|
||||
@@ -343,6 +343,7 @@ _LAZY_IMPORTS = {
|
||||
# UI (optional)
|
||||
'AGUI': ('praisonaiagents.ui.agui', 'AGUI'),
|
||||
'A2A': ('praisonaiagents.ui.a2a', 'A2A'),
|
||||
'A2UI': ('praisonaiagents.ui.a2ui', 'A2UI'),
|
||||
|
||||
# Feature configs
|
||||
'MemoryConfig': ('praisonaiagents.config.feature_configs', 'MemoryConfig'),
|
||||
@@ -383,6 +384,7 @@ _LAZY_IMPORTS = {
|
||||
'MultiAgentExecutionConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentExecutionConfig'),
|
||||
'MultiAgentPlanningConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentPlanningConfig'),
|
||||
'MultiAgentMemoryConfig': ('praisonaiagents.config.feature_configs', 'MultiAgentMemoryConfig'),
|
||||
'ToolSearchConfig': ('praisonaiagents.config.feature_configs', 'ToolSearchConfig'),
|
||||
|
||||
# Parameter resolver
|
||||
'resolve': ('praisonaiagents.config.param_resolver', 'resolve'),
|
||||
@@ -814,6 +816,6 @@ def __dir__():
|
||||
# - Workflow, Task, Route, Parallel, Loop, Repeat, Pipeline, etc.
|
||||
# - MCP, FlowDisplay, track_workflow, FastContext, etc.
|
||||
# - Plan, PlanStep, TodoList, PlanningAgent, ApprovalCallback, etc.
|
||||
# - RAG, RAGConfig, RAGResult, AGUI, A2A, etc.
|
||||
# - RAG, RAGConfig, RAGResult, AGUI, A2A, A2UI, etc.
|
||||
# - All telemetry, display, and utility functions
|
||||
# ============================================================================
|
||||
@@ -556,6 +556,7 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
|
||||
backend: Optional[Any] = None, # External managed agent backend (e.g., ManagedAgentIntegration)
|
||||
cli_backend: Optional[Union[str, Any]] = None, # CLI backend for delegating turns (e.g., "claude-code")
|
||||
interrupt_controller: Optional['InterruptController'] = None, # G2: Cooperative cancellation
|
||||
tool_search: Optional[Union[bool, str, Dict[str, Any], 'ToolSearchConfig']] = False, # Progressive tool disclosure
|
||||
sandbox: Optional[Union[bool, 'SandboxConfig']] = None, # Sandbox for safe code execution
|
||||
):
|
||||
"""Initialize an Agent instance.
|
||||
@@ -660,6 +661,14 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
|
||||
When provided, agent delegates entire conversation turns to the CLI tool
|
||||
instead of using the built-in LLM. Enables session continuity and
|
||||
tool integration through external AI coding assistants.
|
||||
tool_search: Progressive tool disclosure configuration. Accepts:
|
||||
- bool: False=disabled (default), True=auto mode
|
||||
- str: Mode ("auto", "on", "off")
|
||||
- Dict[str, Any]: Config overrides (e.g. {"threshold_pct": 15})
|
||||
- ToolSearchConfig: Custom configuration
|
||||
When enabled, replaces large tool schemas with bridge tools (tool_search,
|
||||
tool_describe, tool_call) to save context. Core SDK tools never defer.
|
||||
Auto mode activates based on token threshold. Opt-in feature.
|
||||
|
||||
Raises:
|
||||
ValueError: If all of name, role, goal, backstory, and instructions are None.
|
||||
@@ -1430,6 +1439,34 @@ class Agent(SandboxMixin, UnifiedExecutionMixin, ToolExecutionMixin, ChatHandler
|
||||
web_search = False
|
||||
web_fetch = False
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Resolve TOOL_SEARCH param
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Fast path: None/False -> disabled (zero overhead)
|
||||
if tool_search is None or tool_search is False:
|
||||
self._tool_search_config = None
|
||||
elif tool_search is True:
|
||||
# True -> auto mode with defaults
|
||||
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
|
||||
self._tool_search_config = _ToolSearchConfig(enabled="auto")
|
||||
elif isinstance(tool_search, str):
|
||||
# String mode ("auto", "on", "off")
|
||||
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
|
||||
self._tool_search_config = _ToolSearchConfig.from_raw(tool_search)
|
||||
elif isinstance(tool_search, dict):
|
||||
# Dict -> config overrides
|
||||
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
|
||||
self._tool_search_config = _ToolSearchConfig(**tool_search)
|
||||
else:
|
||||
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
|
||||
if isinstance(tool_search, _ToolSearchConfig):
|
||||
self._tool_search_config = tool_search
|
||||
else:
|
||||
raise TypeError(
|
||||
"tool_search must be False/None, True, a mode string, "
|
||||
"a dict of ToolSearchConfig fields, or ToolSearchConfig"
|
||||
)
|
||||
|
||||
# ============================================================
|
||||
# END CONSOLIDATED PARAMS EXTRACTION
|
||||
# ============================================================
|
||||
@@ -1952,6 +1989,130 @@ Your Goal: {self.goal}
|
||||
# Sandbox configuration - initialize SandboxMixin
|
||||
super().__init__(sandbox=sandbox)
|
||||
|
||||
def __deepcopy__(self, memo: dict) -> "Agent":
|
||||
"""Custom deepcopy that creates fresh threading primitives.
|
||||
|
||||
threading.RLock (self.__cache_lock) and threading.Lock (self._cost_lock)
|
||||
cannot be deep-copied on CPython < 3.13. This hook deep-copies every
|
||||
other attribute normally and replaces the locks with new instances so
|
||||
that copy.deepcopy(agent) works in any Python version.
|
||||
"""
|
||||
import copy
|
||||
cls = self.__class__
|
||||
result = cls.__new__(cls)
|
||||
memo[id(self)] = result
|
||||
for k, v in self.__dict__.items():
|
||||
if k in ("_Agent__cache_lock",):
|
||||
object.__setattr__(result, k, threading.RLock())
|
||||
elif k == "_cost_lock":
|
||||
object.__setattr__(result, k, threading.Lock())
|
||||
else:
|
||||
object.__setattr__(result, k, copy.deepcopy(v, memo))
|
||||
return result
|
||||
|
||||
def clone_for_channel(self) -> "Agent":
|
||||
"""Return a fully independent copy of this agent for a gateway channel.
|
||||
|
||||
This method safely clones an agent for use in multi-channel scenarios
|
||||
by avoiding deepcopy issues with tools/handoffs and creating fresh
|
||||
instances of mutable objects like interrupt_controller.
|
||||
|
||||
Returns:
|
||||
Agent: A new Agent instance with copied configuration but fresh
|
||||
locks, interrupt controller, and shallow-copied tools.
|
||||
"""
|
||||
import copy
|
||||
from ..tools.base import BaseTool
|
||||
|
||||
# Build kwargs for new Agent instance, copying core attributes
|
||||
clone_kwargs = {
|
||||
# Core identity
|
||||
'name': self.name,
|
||||
'role': self.role,
|
||||
'goal': self.goal,
|
||||
'backstory': self.backstory,
|
||||
'instructions': self.instructions,
|
||||
|
||||
# LLM configuration
|
||||
'llm': self.llm,
|
||||
'base_url': getattr(self, 'base_url', None),
|
||||
'api_key': getattr(self, 'api_key', None),
|
||||
'auth': getattr(self, 'auth', None),
|
||||
|
||||
# Shallow copy tools to avoid deepcopy issues with nested objects
|
||||
'tools': list(self.tools) if self.tools else None,
|
||||
|
||||
# Skip handoffs entirely - they shouldn't be shared across channels
|
||||
# and can contain nested Agent instances that cause RLock issues
|
||||
'handoffs': None,
|
||||
|
||||
# Feature configurations - check for actual stored config objects
|
||||
'memory': getattr(self, '_memory_config', None),
|
||||
'knowledge': getattr(self, '_knowledge_config', None),
|
||||
'planning': getattr(self, '_planning_config', None),
|
||||
'reflection': getattr(self, '_reflection_config', None),
|
||||
'guardrails': getattr(self, '_guardrails_config', None),
|
||||
'web': getattr(self, '_web_config', None),
|
||||
'context': getattr(self, '_context_config', None),
|
||||
'autonomy': getattr(self, '_autonomy_config', None),
|
||||
'output': getattr(self, '_output_config', None),
|
||||
'execution': getattr(self, '_execution_config', None),
|
||||
'templates': getattr(self, '_template_config', None),
|
||||
'caching': getattr(self, '_caching_config', None),
|
||||
'hooks': getattr(self, '_hooks_config', None),
|
||||
'skills': getattr(self, '_skills_config', None),
|
||||
'approval': getattr(self, '_approval_config', None),
|
||||
'learn': getattr(self, '_learn_config', None),
|
||||
'tool_search': getattr(self, '_tool_search_config', None),
|
||||
|
||||
# Tool configuration
|
||||
'tool_timeout': getattr(self, '_tool_timeout', None),
|
||||
'parallel_tool_calls': getattr(self, 'parallel_tool_calls', False),
|
||||
|
||||
# CLI backend
|
||||
'cli_backend': getattr(self, '_cli_backend', None),
|
||||
|
||||
# Create fresh interrupt controller to avoid shared state
|
||||
'interrupt_controller': None, # Let new instance create its own
|
||||
|
||||
# Sandbox config
|
||||
'sandbox': getattr(self, '_sandbox_config', None),
|
||||
}
|
||||
|
||||
# Handle deprecated parameters for backward compatibility
|
||||
import warnings
|
||||
|
||||
# Check for deprecated standalone attributes and emit warnings
|
||||
deprecated_attrs = [
|
||||
('allow_code_execution', 'execution'),
|
||||
('code_execution_mode', 'execution'),
|
||||
('auto_save', 'memory'),
|
||||
('rate_limiter', 'execution'),
|
||||
('allow_delegation', 'handoffs'),
|
||||
('verification_hooks', 'autonomy')
|
||||
]
|
||||
|
||||
for old_attr, new_param in deprecated_attrs:
|
||||
if hasattr(self, old_attr):
|
||||
value = getattr(self, old_attr)
|
||||
if value is not None and value != False: # Skip None/False defaults
|
||||
warnings.warn(
|
||||
f"Deprecated attribute '{old_attr}' found in agent clone. "
|
||||
f"Use '{new_param}=' parameter instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
# For basic compatibility, include in clone_kwargs if not already set
|
||||
if old_attr == 'allow_code_execution' and clone_kwargs['execution'] is None:
|
||||
from ..config.execution import ExecutionConfig
|
||||
clone_kwargs['execution'] = ExecutionConfig(code_execution=value)
|
||||
elif old_attr == 'auto_save' and clone_kwargs['memory'] is None:
|
||||
from ..config.memory import MemoryConfig
|
||||
clone_kwargs['memory'] = MemoryConfig(auto_save=value)
|
||||
|
||||
# Create new Agent instance
|
||||
return self.__class__(**{k: v for k, v in clone_kwargs.items() if v is not None})
|
||||
|
||||
@property
|
||||
def _telemetry(self):
|
||||
"""Lazy-loaded telemetry instance for performance."""
|
||||
@@ -4616,60 +4777,57 @@ Answer:"""
|
||||
error=f"Agent guardrail validation error: {str(e)}"
|
||||
)
|
||||
|
||||
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
|
||||
"""Apply guardrail validation with retry logic.
|
||||
|
||||
Args:
|
||||
response_text: The response to validate
|
||||
prompt: Original prompt for regeneration if needed
|
||||
temperature: Temperature for regeneration
|
||||
tools: Tools for regeneration
|
||||
|
||||
Returns:
|
||||
str: The validated response text or None if validation fails after retries
|
||||
"""
|
||||
def _validate_with_guardrail(self, response_text):
|
||||
"""Validate response with guardrail. Returns (success, result, error)."""
|
||||
if not self._guardrail_fn:
|
||||
return response_text
|
||||
return True, response_text, None
|
||||
|
||||
from ..main import TaskOutput
|
||||
|
||||
task_output = TaskOutput(
|
||||
description="Agent response output",
|
||||
raw=response_text,
|
||||
agent=self.name
|
||||
)
|
||||
|
||||
guardrail_result = self._process_guardrail(task_output)
|
||||
|
||||
if guardrail_result.success:
|
||||
# Return the potentially modified result
|
||||
if guardrail_result.result and hasattr(guardrail_result.result, 'raw'):
|
||||
return True, guardrail_result.result.raw, None
|
||||
elif guardrail_result.result:
|
||||
return True, str(guardrail_result.result), None
|
||||
else:
|
||||
return True, response_text, None
|
||||
else:
|
||||
return False, None, guardrail_result.error
|
||||
|
||||
def _apply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
|
||||
"""Apply guardrail validation with retry logic (sync version)."""
|
||||
retry_count = 0
|
||||
current_response = response_text
|
||||
|
||||
while retry_count <= self.max_guardrail_retries:
|
||||
# Create TaskOutput object
|
||||
task_output = TaskOutput(
|
||||
description="Agent response output",
|
||||
raw=current_response,
|
||||
agent=self.name
|
||||
)
|
||||
success, result, error = self._validate_with_guardrail(current_response)
|
||||
|
||||
# Process guardrail
|
||||
guardrail_result = self._process_guardrail(task_output)
|
||||
|
||||
if guardrail_result.success:
|
||||
if success:
|
||||
logging.info(f"Agent {self.name}: Guardrail validation passed")
|
||||
# Return the potentially modified result
|
||||
if guardrail_result.result and hasattr(guardrail_result.result, 'raw'):
|
||||
return guardrail_result.result.raw
|
||||
elif guardrail_result.result:
|
||||
return str(guardrail_result.result)
|
||||
else:
|
||||
return current_response
|
||||
return result
|
||||
|
||||
# Guardrail failed
|
||||
if retry_count >= self.max_guardrail_retries:
|
||||
raise Exception(
|
||||
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
|
||||
f"Last error: {guardrail_result.error}"
|
||||
f"Last error: {error}"
|
||||
)
|
||||
|
||||
retry_count += 1
|
||||
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {guardrail_result.error}")
|
||||
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
|
||||
|
||||
# Regenerate response for retry
|
||||
try:
|
||||
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {guardrail_result.error}. Please provide an improved response."
|
||||
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
|
||||
response = self._chat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
if response and response.choices:
|
||||
content = response.choices[0].message.content
|
||||
@@ -4678,10 +4836,46 @@ Answer:"""
|
||||
raise Exception("Failed to generate retry response")
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
|
||||
# If we can't regenerate, fail the guardrail
|
||||
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
|
||||
|
||||
return current_response
|
||||
|
||||
async def _aapply_guardrail_with_retry(self, response_text, prompt, temperature=1.0, tools=None, task_name=None, task_description=None, task_id=None):
|
||||
"""Apply guardrail validation with retry logic (async version)."""
|
||||
retry_count = 0
|
||||
current_response = response_text
|
||||
|
||||
while retry_count <= self.max_guardrail_retries:
|
||||
success, result, error = self._validate_with_guardrail(current_response)
|
||||
|
||||
if success:
|
||||
logging.info(f"Agent {self.name}: Guardrail validation passed")
|
||||
return result
|
||||
|
||||
# Guardrail failed
|
||||
if retry_count >= self.max_guardrail_retries:
|
||||
raise Exception(
|
||||
f"Agent {self.name} guardrail retry failed: {e}"
|
||||
f"Agent {self.name} response failed guardrail validation after {self.max_guardrail_retries} retries. "
|
||||
f"Last error: {error}"
|
||||
)
|
||||
|
||||
retry_count += 1
|
||||
logging.warning(f"Agent {self.name}: Guardrail validation failed (retry {retry_count}/{self.max_guardrail_retries}): {error}")
|
||||
|
||||
# Regenerate response for retry (async version)
|
||||
try:
|
||||
retry_prompt = f"{prompt}\n\nNote: Previous response failed validation due to: {error}. Please provide an improved response."
|
||||
response = await self._execute_unified_achat_completion([{"role": "user", "content": retry_prompt}], temperature, tools, task_name=task_name, task_description=task_description, task_id=task_id)
|
||||
if response and hasattr(response, 'choices') and response.choices:
|
||||
content = response.choices[0].message.content
|
||||
current_response = content.strip() if content else ""
|
||||
elif isinstance(response, str):
|
||||
current_response = response.strip()
|
||||
else:
|
||||
raise Exception("Failed to generate retry response")
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Error during guardrail retry: {e}")
|
||||
raise Exception(f"Agent {self.name} guardrail retry failed: {e}")
|
||||
|
||||
return current_response
|
||||
|
||||
|
||||
@@ -331,6 +331,7 @@ Your Goal: {self.goal}"""
|
||||
- Callable functions
|
||||
- String function names
|
||||
- Objects with to_openai_tool() method
|
||||
- Tool Search progressive disclosure (if enabled)
|
||||
|
||||
Args:
|
||||
tools: List of tools in various formats or None to use self.tools
|
||||
@@ -344,10 +345,14 @@ Your Goal: {self.goal}"""
|
||||
if not tools:
|
||||
return []
|
||||
|
||||
# Check cache first
|
||||
# Check cache first - include tool_search config in cache key for safety
|
||||
tools_key = self._get_tools_cache_key(tools)
|
||||
cached_tools = self._cache_get(self._formatted_tools_cache, tools_key)
|
||||
if cached_tools is not None:
|
||||
tool_search_enabled = getattr(self, '_tool_search_config', None) is not None
|
||||
cache_key = f"{tools_key}:tool_search={tool_search_enabled}"
|
||||
cached_entry = self._cache_get(self._formatted_tools_cache, cache_key)
|
||||
if cached_entry is not None:
|
||||
cached_tools, cached_metadata = cached_entry
|
||||
self._tool_search_metadata = cached_metadata
|
||||
return cached_tools
|
||||
|
||||
formatted_tools = []
|
||||
@@ -399,9 +404,46 @@ Your Goal: {self.goal}"""
|
||||
logging.error(f"Tools are not JSON serializable: {e}")
|
||||
return []
|
||||
|
||||
# Cache the formatted tools with LRU eviction
|
||||
self._cache_put(self._formatted_tools_cache, tools_key, formatted_tools)
|
||||
return formatted_tools
|
||||
# Apply tool search assembly if enabled (after formatting, before caching)
|
||||
if hasattr(self, '_tool_search_config') and self._tool_search_config is not None:
|
||||
try:
|
||||
from ..tools.tool_search import assemble_tool_defs
|
||||
# Get context length from LLM config if available
|
||||
context_length = getattr(self, '_context_window_size', None)
|
||||
|
||||
# Assemble tools with bridge mode check
|
||||
assembled_tools, metadata = assemble_tool_defs(
|
||||
tool_defs=formatted_tools,
|
||||
config=self._tool_search_config,
|
||||
context_length=context_length
|
||||
)
|
||||
|
||||
# Store metadata for bridge tool dispatch
|
||||
self._tool_search_metadata = metadata
|
||||
formatted_tools = assembled_tools
|
||||
|
||||
except ImportError:
|
||||
# Tool search module not available, continue with original tools
|
||||
logging.warning("Tool search requested but tool_search module not available")
|
||||
|
||||
# Strip __praisonai_deferrable__ from provider-facing tool payloads
|
||||
# Keep the marker only for internal tool classification
|
||||
cleaned_tools = []
|
||||
for tool in formatted_tools:
|
||||
if isinstance(tool, dict) and "__praisonai_deferrable__" in tool:
|
||||
tool_copy = tool.copy()
|
||||
tool_copy.pop("__praisonai_deferrable__", None)
|
||||
cleaned_tools.append(tool_copy)
|
||||
else:
|
||||
cleaned_tools.append(tool)
|
||||
|
||||
# Cache the formatted tools with LRU eviction, including tool search metadata
|
||||
self._cache_put(
|
||||
self._formatted_tools_cache,
|
||||
cache_key,
|
||||
(cleaned_tools, getattr(self, "_tool_search_metadata", None)),
|
||||
)
|
||||
return cleaned_tools
|
||||
|
||||
def _build_multimodal_prompt(
|
||||
self,
|
||||
@@ -507,7 +549,7 @@ Your Goal: {self.goal}"""
|
||||
emit_events=True
|
||||
)
|
||||
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=True, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
|
||||
def _chat_completion(self, messages, temperature=1.0, tools=None, stream=None, reasoning_steps=False, task_name=None, task_description=None, task_id=None, response_format=None, _retry_depth=0):
|
||||
start_time = time.time()
|
||||
|
||||
# --- Context compaction (opt-in via ExecutionConfig.context_compaction) ---
|
||||
@@ -577,6 +619,39 @@ Your Goal: {self.goal}"""
|
||||
# Use the new _format_tools_for_completion helper method
|
||||
formatted_tools = self._format_tools_for_completion(tools)
|
||||
|
||||
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
|
||||
if stream is None:
|
||||
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
|
||||
try:
|
||||
# First attempt: try with streaming enabled for better user experience
|
||||
stream_callback = self.stream_emitter.emit if hasattr(self, 'stream_emitter') else None
|
||||
final_response = self._execute_unified_chat_completion(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
stream=True, # Try streaming first
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
response_format=response_format,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=True
|
||||
)
|
||||
return final_response
|
||||
except ValueError as e:
|
||||
if "Streaming is not supported" in str(e):
|
||||
# Fallback: retry with non-streaming for sync adapters
|
||||
logging.debug(f"{self.name}: Streaming not supported by adapter, falling back to non-streaming")
|
||||
stream = False # Set for the main execution below
|
||||
else:
|
||||
raise # Re-raise if it's a different ValueError
|
||||
except Exception:
|
||||
# For any other exception, fall back to non-streaming
|
||||
logging.debug(f"{self.name}: Streaming attempt failed, falling back to non-streaming")
|
||||
stream = False # Set for the main execution below
|
||||
|
||||
# If stream was explicitly set or fallback occurred, use the specified/fallback value
|
||||
try:
|
||||
# NEW: Unified protocol dispatch path (Issue #1304, #1362)
|
||||
# UNIFIED: Single protocol-driven dispatch path (fixes DRY violation)
|
||||
@@ -774,7 +849,7 @@ Your Goal: {self.goal}"""
|
||||
messages,
|
||||
temperature=1.0,
|
||||
tools=None,
|
||||
stream=True,
|
||||
stream=None,
|
||||
reasoning_steps=False,
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
@@ -806,6 +881,47 @@ Your Goal: {self.goal}"""
|
||||
# Cache the dispatcher
|
||||
self._unified_dispatcher = dispatcher
|
||||
|
||||
# Smart fallback for streaming: try streaming first, fall back to non-streaming if unsupported
|
||||
if stream is None:
|
||||
# Auto-detect: prefer streaming for better UX, fallback if adapter doesn't support it
|
||||
try:
|
||||
# First attempt: try with streaming enabled for better user experience
|
||||
if stream_callback is None and hasattr(self, 'stream_emitter'):
|
||||
stream_callback = getattr(self.stream_emitter, 'emit', None)
|
||||
final_response = self._unified_dispatcher.chat_completion(
|
||||
messages=messages,
|
||||
tools=tools,
|
||||
tool_choice=getattr(self, 'tool_choice', None),
|
||||
temperature=temperature,
|
||||
max_tokens=getattr(self, 'max_tokens', None),
|
||||
stream=True, # Try streaming first
|
||||
response_format=response_format,
|
||||
execute_tool_fn=getattr(self, 'execute_tool', None),
|
||||
console=self.console if (self.verbose or True) else None, # Enable console for streaming
|
||||
display_fn=self._display_generating if self.verbose else None,
|
||||
stream_callback=stream_callback,
|
||||
emit_events=emit_events,
|
||||
verbose=self.verbose,
|
||||
max_iterations=10,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
agent_name=getattr(self, 'name', 'assistant')
|
||||
)
|
||||
return final_response
|
||||
except ValueError as e:
|
||||
if "Streaming is not supported" in str(e):
|
||||
# Fallback: retry with non-streaming for sync adapters
|
||||
logging.debug(f"Agent: Streaming not supported by adapter, falling back to non-streaming")
|
||||
stream = False # Set for the main execution below
|
||||
else:
|
||||
raise # Re-raise if it's a different ValueError
|
||||
except Exception:
|
||||
# For any other exception, fall back to non-streaming
|
||||
logging.debug(f"Agent: Streaming attempt failed, falling back to non-streaming")
|
||||
stream = False # Set for the main execution below
|
||||
|
||||
# Execute unified dispatch with all necessary parameters
|
||||
# Includes all parameters from both legacy paths to ensure full compatibility
|
||||
try:
|
||||
@@ -857,7 +973,7 @@ Your Goal: {self.goal}"""
|
||||
messages,
|
||||
temperature=1.0,
|
||||
tools=None,
|
||||
stream=True,
|
||||
stream=True, # Async methods keep stream=True default (async adapters support streaming vs sync smart fallback)
|
||||
reasoning_steps=False,
|
||||
task_name=None,
|
||||
task_description=None,
|
||||
@@ -2079,7 +2195,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Apply guardrail validation for custom LLM response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(normalized_content, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(prompt, validated_response, start_time)
|
||||
@@ -2187,22 +2303,216 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
task_id=task_id,
|
||||
response_format=response_format
|
||||
)
|
||||
return await self._finalize_unified_achat_response(
|
||||
response=response,
|
||||
original_prompt=original_prompt,
|
||||
temperature=temperature,
|
||||
tools=tools,
|
||||
output_json=output_json,
|
||||
output_pydantic=output_pydantic,
|
||||
reasoning_steps=reasoning_steps,
|
||||
stream=stream,
|
||||
messages=messages,
|
||||
chat_history_length=chat_history_length,
|
||||
start_time=start_time,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
)
|
||||
|
||||
# Process response - mirror sync _chat_impl behavior (lines ~1544-1602)
|
||||
if not response:
|
||||
# Rollback chat history on response failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Extract response content using the same method as sync
|
||||
response_text = self._extract_llm_response_content(response)
|
||||
if isinstance(response_text, str):
|
||||
response_text = response_text.strip()
|
||||
|
||||
# Handle output_json or output_pydantic if specified
|
||||
if output_json or output_pydantic:
|
||||
# Add to chat history and return raw response
|
||||
# User message already added before LLM call via _build_messages
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
# Apply guardrail validation even for JSON output
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for JSON output: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# For regular responses (no self-reflection)
|
||||
if not self.self_reflect:
|
||||
# User message already added before LLM call via _build_messages
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB (non-reflect path)
|
||||
self._persist_message("assistant", response_text)
|
||||
if self.verbose:
|
||||
logging.debug(f"Agent {self.name} final response: {response_text}")
|
||||
# Return only reasoning content if reasoning_steps is True
|
||||
if reasoning_steps and hasattr(response, 'choices') and response.choices and hasattr(response.choices[0].message, 'reasoning_content') and response.choices[0].message.reasoning_content:
|
||||
# Apply guardrail to reasoning content
|
||||
try:
|
||||
validated_reasoning = await self._aapply_guardrail_with_retry(response.choices[0].message.reasoning_content, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_reasoning, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_reasoning, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed for reasoning content: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
else:
|
||||
# Apply guardrail to regular response content
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# If self-reflection is enabled, implement reflection logic
|
||||
if self.self_reflect:
|
||||
# Implement async self-reflection similar to sync path
|
||||
reflection_prompt = f"""
|
||||
Reflect on your previous response: '{response_text}'.
|
||||
{self.reflect_prompt if self.reflect_prompt else "Identify any flaws, improvements, or actions."}
|
||||
Provide a "satisfactory" status ('yes' or 'no').
|
||||
Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
"""
|
||||
|
||||
# Add reflection prompt to messages
|
||||
reflection_messages = messages + [
|
||||
{"role": "assistant", "content": response_text},
|
||||
{"role": "user", "content": reflection_prompt}
|
||||
]
|
||||
|
||||
reflection_count = 0
|
||||
|
||||
while True:
|
||||
try:
|
||||
# Check if OpenAI client is available for self-reflection
|
||||
if self._openai_client is None:
|
||||
# For custom LLMs, self-reflection with structured output is not supported
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection'](f"Agent {self.name}: Self-reflection with structured output is not supported for custom LLM providers. Skipping reflection.", console=self.console)
|
||||
# Return the original response without reflection
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
reflection_response = await self._openai_client.async_client.beta.chat.completions.parse(
|
||||
model=self.reflect_llm if self.reflect_llm else self.llm,
|
||||
messages=reflection_messages,
|
||||
temperature=temperature,
|
||||
response_format=_get_display_functions()['ReflectionOutput']
|
||||
)
|
||||
|
||||
reflection_output = reflection_response.choices[0].message.parsed
|
||||
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection'](f"Agent {self.name} self reflection (using {self.reflect_llm if self.reflect_llm else self.llm}): reflection='{reflection_output.reflection}' satisfactory='{reflection_output.satisfactory}'", console=self.console)
|
||||
|
||||
# Only consider satisfactory after minimum reflections
|
||||
if reflection_output.satisfactory == "yes" and reflection_count >= self.min_reflect - 1:
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Agent marked the response as satisfactory after meeting minimum reflections", console=self.console)
|
||||
# Add to chat history and return
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Check if we've hit max reflections
|
||||
if reflection_count >= self.max_reflect - 1:
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_self_reflection']("Maximum reflection count reached, returning current response", console=self.console)
|
||||
# Add to chat history and return
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after max reflections: {e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
|
||||
# Regenerate response based on reflection
|
||||
regenerate_messages = reflection_messages + [
|
||||
{"role": "assistant", "content": f"Self Reflection: {reflection_output.reflection} Satisfactory?: {reflection_output.satisfactory}"},
|
||||
{"role": "user", "content": "Now regenerate your response using the reflection you made"}
|
||||
]
|
||||
|
||||
new_response = await self._execute_unified_achat_completion(
|
||||
messages=regenerate_messages,
|
||||
temperature=temperature,
|
||||
tools=formatted_tools,
|
||||
stream=stream,
|
||||
reasoning_steps=reasoning_steps,
|
||||
task_name=task_name,
|
||||
task_description=task_description,
|
||||
task_id=task_id,
|
||||
response_format=response_format
|
||||
)
|
||||
|
||||
if new_response:
|
||||
new_response_text = self._extract_llm_response_content(new_response)
|
||||
if isinstance(new_response_text, str):
|
||||
response_text = new_response_text.strip()
|
||||
# Update reflection_messages to include the new response for next iteration
|
||||
reflection_messages = regenerate_messages + [
|
||||
{"role": "assistant", "content": response_text}
|
||||
]
|
||||
|
||||
reflection_count += 1
|
||||
|
||||
except Exception as e:
|
||||
if self.verbose:
|
||||
_get_display_functions()['display_error'](f"Error in parsing self-reflection json {e}. Retrying", console=self.console)
|
||||
logging.error("Reflection parsing failed.", exc_info=True)
|
||||
reflection_count += 1
|
||||
if reflection_count >= self.max_reflect:
|
||||
# Return original response after max reflection attempts
|
||||
self._append_to_chat_history({"role": "assistant", "content": response_text})
|
||||
# Persist assistant message to DB
|
||||
self._persist_message("assistant", response_text)
|
||||
try:
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
except Exception as guard_e:
|
||||
logging.error(f"Agent {self.name}: Guardrail validation failed after reflection error: {guard_e}")
|
||||
# Rollback chat history on guardrail failure
|
||||
self._truncate_chat_history(chat_history_length)
|
||||
return None
|
||||
continue
|
||||
|
||||
# This should never be reached due to the returns above
|
||||
# But adding as safety fallback
|
||||
logging.warning(f"Agent {self.name}: Unexpected code path reached in unified dispatch")
|
||||
return None
|
||||
else:
|
||||
# LEGACY: Check if OpenAI client is available
|
||||
if self._openai_client is None:
|
||||
@@ -2339,7 +2649,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
# Apply guardrail validation for OpenAI client response
|
||||
try:
|
||||
validated_response = self._apply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
validated_response = await self._aapply_guardrail_with_retry(response_text, original_prompt, temperature, tools, task_name, task_description, task_id)
|
||||
# Execute callback after validation
|
||||
self._execute_callback_and_display(original_prompt, validated_response, time.time() - start_time, task_name, task_description, task_id)
|
||||
return await self._atrigger_after_agent_hook(original_prompt, validated_response, start_time)
|
||||
|
||||
@@ -403,9 +403,33 @@ class MemoryMixin:
|
||||
self._session_store.add_user_message(self._session_id, content)
|
||||
elif role == "assistant":
|
||||
self._session_store.add_assistant_message(self._session_id, content)
|
||||
self._persist_session_stats()
|
||||
except Exception as e:
|
||||
logging.warning(f"Failed to persist message to session store: {e}")
|
||||
|
||||
def _persist_session_stats(self):
|
||||
"""Flush agent cost/token stats into session JSON metadata."""
|
||||
store = getattr(self, "_session_store", None)
|
||||
session_id = getattr(self, "_session_id", None)
|
||||
if store is None or session_id is None:
|
||||
return
|
||||
if not hasattr(store, "update_session_metadata"):
|
||||
return
|
||||
try:
|
||||
summary = getattr(self, "cost_summary", {}) or {}
|
||||
llm = getattr(self, "llm", None)
|
||||
model = str(llm) if llm is not None else None
|
||||
store.update_session_metadata(
|
||||
session_id,
|
||||
model=model,
|
||||
total_tokens=summary.get("tokens_in", 0) + summary.get("tokens_out", 0),
|
||||
cost=summary.get("cost", 0),
|
||||
source=getattr(self, "_session_source", "chat"),
|
||||
agent_id=getattr(self, "agent_id", None) or getattr(self, "_agent_id", None),
|
||||
)
|
||||
except Exception as e:
|
||||
logging.debug(f"Session stats persist skipped: {e}")
|
||||
|
||||
def session_id(self) -> Optional[str]:
|
||||
"""Get the current session ID."""
|
||||
return self._session_id
|
||||
|
||||
@@ -26,6 +26,21 @@ if TYPE_CHECKING:
|
||||
class ToolExecutionMixin:
|
||||
"""Mixin providing toolexecution methods for the Agent class."""
|
||||
|
||||
def _get_existing_stream_emitter(self):
|
||||
"""Return an already-initialized stream emitter without creating one."""
|
||||
emitter = getattr(self, "_stream_emitter", None)
|
||||
if emitter is not None:
|
||||
return emitter
|
||||
|
||||
# Support name-mangled private attributes across class renames/inheritance.
|
||||
for cls in type(self).mro():
|
||||
mangled = f"_{cls.__name__}__stream_emitter"
|
||||
if hasattr(self, mangled):
|
||||
emitter = getattr(self, mangled, None)
|
||||
if emitter is not None:
|
||||
return emitter
|
||||
return None
|
||||
|
||||
def _resolve_tool_names(self, tool_names):
|
||||
"""Resolve tool names to actual tool instances from registry.
|
||||
|
||||
@@ -120,6 +135,12 @@ class ToolExecutionMixin:
|
||||
"""
|
||||
logging.debug(f"{self.name} executing tool {function_name} with arguments: {arguments}")
|
||||
|
||||
# Handle bridge tool unwrapping BEFORE trace/stream/hooks (design invariant #6)
|
||||
# Only intercept when tool_search is active; otherwise fall through to real tool execution
|
||||
if (getattr(self, '_tool_search_config', None) is not None and
|
||||
function_name in ("tool_search", "tool_describe", "tool_call")):
|
||||
return self._handle_bridge_tool_call(function_name, arguments, tool_call_id)
|
||||
|
||||
# NOTE: tool_call callback is triggered by display_tool_call in openai_client.py
|
||||
# Do NOT call it here to avoid duplicate output
|
||||
|
||||
@@ -160,8 +181,9 @@ class ToolExecutionMixin:
|
||||
|
||||
# Emit TOOL_CALL_START to stream_emitter (for AIUI/AG-UI consumers)
|
||||
# Zero overhead when no callbacks registered
|
||||
if hasattr(self, '_Agent__stream_emitter') and getattr(self, "_Agent__stream_emitter", None) is not None and getattr(self, "_Agent__stream_emitter", None).has_callbacks:
|
||||
getattr(self, "_Agent__stream_emitter", None).emit(StreamEvent(
|
||||
_stream_emitter = self._get_existing_stream_emitter()
|
||||
if _stream_emitter is not None and _stream_emitter.has_callbacks:
|
||||
_stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_START,
|
||||
timestamp=_tool_start_perf,
|
||||
tool_call={
|
||||
@@ -275,10 +297,10 @@ class ToolExecutionMixin:
|
||||
|
||||
# Emit TOOL_CALL_RESULT to stream_emitter (for AIUI/AG-UI consumers)
|
||||
# Zero overhead when no callbacks registered
|
||||
if hasattr(self, '_Agent__stream_emitter') and getattr(self, "_Agent__stream_emitter", None) is not None and getattr(self, "_Agent__stream_emitter", None).has_callbacks:
|
||||
if _stream_emitter is not None and _stream_emitter.has_callbacks:
|
||||
# Truncate result for stream event (keep it reasonable for UI display)
|
||||
result_summary = str(result)[:500] if result else None
|
||||
getattr(self, "_Agent__stream_emitter", None).emit(StreamEvent(
|
||||
_stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_RESULT,
|
||||
timestamp=_time.perf_counter(),
|
||||
tool_call={
|
||||
@@ -290,6 +312,18 @@ class ToolExecutionMixin:
|
||||
agent_id=self.name,
|
||||
metadata={"duration_ms": _duration_ms},
|
||||
))
|
||||
_stream_emitter.emit(StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_END,
|
||||
timestamp=_time.perf_counter(),
|
||||
tool_call={
|
||||
"name": function_name,
|
||||
"arguments": arguments,
|
||||
"result": result_summary,
|
||||
"id": tool_call_id,
|
||||
},
|
||||
agent_id=self.name,
|
||||
metadata={"duration_ms": _duration_ms},
|
||||
))
|
||||
|
||||
# Trigger AFTER_TOOL hook
|
||||
from ..hooks import HookEvent, AfterToolInput
|
||||
@@ -926,4 +960,97 @@ class ToolExecutionMixin:
|
||||
def pending_approval_count(self) -> int:
|
||||
"""Number of approval requests still waiting."""
|
||||
return len(self._pending_approvals)
|
||||
|
||||
|
||||
def _handle_bridge_tool_call(self, function_name: str, arguments: Dict[str, Any], tool_call_id: Optional[str] = None) -> Any:
|
||||
"""
|
||||
Handle bridge tool calls (tool_search, tool_describe, tool_call).
|
||||
|
||||
This implements the tool search unwrapping logic before trace/stream/hooks
|
||||
as required by design invariant #6.
|
||||
|
||||
Args:
|
||||
function_name: Bridge tool name
|
||||
arguments: Arguments passed to bridge tool
|
||||
tool_call_id: Optional tool call ID
|
||||
|
||||
Returns:
|
||||
Result of bridge tool execution or unwrapped real tool call
|
||||
"""
|
||||
# Ensure tool search metadata is available
|
||||
if not hasattr(self, '_tool_search_metadata') or self._tool_search_metadata is None:
|
||||
return "Tool search not available or not in bridge mode"
|
||||
|
||||
metadata = self._tool_search_metadata
|
||||
|
||||
# Check if we're in bridge mode
|
||||
if not metadata.get("bridge_mode", False):
|
||||
return "Tool search not in bridge mode"
|
||||
|
||||
# Get deferrable tools from metadata
|
||||
deferrable_tools = metadata.get("deferrable_tools", [])
|
||||
|
||||
if function_name == "tool_search":
|
||||
# Handle tool_search bridge call
|
||||
try:
|
||||
from ..tools.tool_search import dispatch_tool_search
|
||||
query = arguments.get("query", "")
|
||||
limit = arguments.get("limit", None)
|
||||
|
||||
result = dispatch_tool_search(
|
||||
query=query,
|
||||
limit=limit,
|
||||
deferrable_tools=deferrable_tools,
|
||||
config=self._tool_search_config
|
||||
)
|
||||
return json.dumps(result, indent=2)
|
||||
except ImportError:
|
||||
return "Tool search module not available"
|
||||
except Exception as e:
|
||||
logging.error(f"Error in tool_search: {e}")
|
||||
return f"Error searching tools: {e}"
|
||||
|
||||
elif function_name == "tool_describe":
|
||||
# Handle tool_describe bridge call
|
||||
try:
|
||||
from ..tools.tool_search import dispatch_tool_describe
|
||||
tool_name = arguments.get("tool_name", "")
|
||||
|
||||
result = dispatch_tool_describe(
|
||||
tool_name=tool_name,
|
||||
deferrable_tools=deferrable_tools
|
||||
)
|
||||
return json.dumps(result, indent=2)
|
||||
except ImportError:
|
||||
return "Tool search module not available"
|
||||
except Exception as e:
|
||||
logging.error(f"Error in tool_describe: {e}")
|
||||
return f"Error describing tool: {e}"
|
||||
|
||||
elif function_name == "tool_call":
|
||||
# Handle tool_call bridge - unwrap and recurse with real tool
|
||||
try:
|
||||
from ..tools.tool_search import resolve_underlying_call
|
||||
|
||||
# Unwrap the real tool call
|
||||
real_function_name, real_arguments = resolve_underlying_call(function_name, arguments)
|
||||
|
||||
# Validate that the real tool is in our deferrable set (security check)
|
||||
deferrable_names = {
|
||||
tool_def.get("function", {}).get("name", "")
|
||||
for tool_def in deferrable_tools
|
||||
}
|
||||
|
||||
if real_function_name not in deferrable_names:
|
||||
return f"Tool '{real_function_name}' is not available for execution"
|
||||
|
||||
# Recursively execute the real tool (this will go through normal execution path)
|
||||
return self.execute_tool(real_function_name, real_arguments, tool_call_id)
|
||||
|
||||
except ImportError:
|
||||
return "Tool search module not available"
|
||||
except Exception as e:
|
||||
logging.error(f"Error in tool_call unwrap: {e}")
|
||||
return f"Error executing tool: {e}"
|
||||
|
||||
else:
|
||||
return f"Unknown bridge tool: {function_name}"
|
||||
@@ -15,9 +15,9 @@ from enum import Enum
|
||||
|
||||
# Import token tracking
|
||||
try:
|
||||
from ..telemetry.token_collector import _token_collector
|
||||
from ..telemetry.token_collector import get_token_collector
|
||||
except ImportError:
|
||||
_token_collector = None
|
||||
get_token_collector = None
|
||||
|
||||
# Import async utility for hot-path usage
|
||||
try:
|
||||
@@ -1609,25 +1609,25 @@ class AgentTeam:
|
||||
original_on_task_start = self.on_task_start
|
||||
original_on_task_complete = self.on_task_complete
|
||||
|
||||
def composed_on_task_start(task):
|
||||
def composed_on_task_start(task, task_id):
|
||||
try:
|
||||
verbose_task_start_callback(task)
|
||||
verbose_task_start_callback(task, task_id)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task start callback: {e}")
|
||||
if original_on_task_start:
|
||||
try:
|
||||
original_on_task_start(task)
|
||||
original_on_task_start(task, task_id)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in original task start callback: {e}")
|
||||
|
||||
def composed_on_task_complete(task):
|
||||
def composed_on_task_complete(task, task_output):
|
||||
try:
|
||||
verbose_task_complete_callback(task)
|
||||
verbose_task_complete_callback(task, task_output)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task complete callback: {e}")
|
||||
if original_on_task_complete:
|
||||
try:
|
||||
original_on_task_complete(task)
|
||||
original_on_task_complete(task, task_output)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in original task complete callback: {e}")
|
||||
|
||||
@@ -1826,18 +1826,19 @@ class AgentTeam:
|
||||
|
||||
def get_token_usage_summary(self) -> Dict[str, Any]:
|
||||
"""Get a summary of token usage across all agents and tasks."""
|
||||
if not _token_collector:
|
||||
if not get_token_collector:
|
||||
return {"error": "Token tracking not available"}
|
||||
|
||||
return _token_collector.get_session_summary()
|
||||
return get_token_collector().get_session_summary()
|
||||
|
||||
def get_detailed_token_report(self) -> Dict[str, Any]:
|
||||
"""Get a detailed token usage report."""
|
||||
if not _token_collector:
|
||||
if not get_token_collector:
|
||||
return {"error": "Token tracking not available"}
|
||||
|
||||
summary = _token_collector.get_session_summary()
|
||||
recent = _token_collector.get_recent_interactions(limit=20)
|
||||
collector = get_token_collector()
|
||||
summary = collector.get_session_summary()
|
||||
recent = collector.get_recent_interactions(limit=20)
|
||||
|
||||
# Calculate cost estimates (example rates)
|
||||
cost_per_1k_input = 0.0005 # $0.0005 per 1K input tokens
|
||||
@@ -1861,11 +1862,11 @@ class AgentTeam:
|
||||
|
||||
def display_token_usage(self):
|
||||
"""Display token usage in a formatted table."""
|
||||
if not _token_collector:
|
||||
if not get_token_collector:
|
||||
print("Token tracking not available")
|
||||
return
|
||||
|
||||
summary = _token_collector.get_session_summary()
|
||||
summary = get_token_collector().get_session_summary()
|
||||
|
||||
print("\n" + "="*50)
|
||||
print("TOKEN USAGE SUMMARY")
|
||||
|
||||
@@ -39,6 +39,7 @@ __all__ = [
|
||||
"CachingConfig",
|
||||
"HooksConfig",
|
||||
"SkillsConfig",
|
||||
"ToolSearchConfig",
|
||||
# Type aliases
|
||||
"MemoryParam",
|
||||
"KnowledgeParam",
|
||||
@@ -122,6 +123,7 @@ _MODULE_MAP = {
|
||||
"CachingConfig": "feature_configs",
|
||||
"HooksConfig": "feature_configs",
|
||||
"SkillsConfig": "feature_configs",
|
||||
"ToolSearchConfig": "feature_configs",
|
||||
"MemoryParam": "feature_configs",
|
||||
"KnowledgeParam": "feature_configs",
|
||||
"PlanningParam": "feature_configs",
|
||||
|
||||
@@ -8,6 +8,7 @@ Provides dataclasses for consolidated feature configuration:
|
||||
- ReflectionConfig: Self-reflection settings
|
||||
- GuardrailConfig: Safety and validation
|
||||
- WebConfig: Web search and fetch
|
||||
- ToolSearchConfig: Progressive tool disclosure
|
||||
|
||||
All configs follow the agent-centric pattern:
|
||||
- False: Feature disabled (zero overhead)
|
||||
@@ -16,21 +17,22 @@ All configs follow the agent-centric pattern:
|
||||
- Instance: Pre-configured manager/engine
|
||||
|
||||
Usage:
|
||||
from praisonaiagents import Agent, MemoryConfig, KnowledgeConfig
|
||||
from praisonaiagents import Agent, MemoryConfig, KnowledgeConfig, ToolSearchConfig
|
||||
|
||||
# Simple enable
|
||||
agent = Agent(instructions="...", memory=True)
|
||||
agent = Agent(instructions="...", memory=True, tool_search=True)
|
||||
|
||||
# With config
|
||||
agent = Agent(
|
||||
instructions="...",
|
||||
memory=MemoryConfig(backend="redis", user_id="user123"),
|
||||
knowledge=KnowledgeConfig(sources=["docs/"], rerank=True),
|
||||
tool_search=ToolSearchConfig(enabled="auto", threshold_pct=15),
|
||||
)
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Dict, List, Any, Optional, Callable, Tuple, Union
|
||||
from typing import Dict, List, Any, Optional, Callable, Tuple, Union, FrozenSet
|
||||
from enum import Enum
|
||||
|
||||
# Import AutonomyConfig from canonical location (no circular dep)
|
||||
@@ -1072,6 +1074,25 @@ class MultiAgentMemoryConfig:
|
||||
}
|
||||
|
||||
|
||||
# Import ToolSearchConfig from tools module to avoid duplication
|
||||
def __get_tool_search_config():
|
||||
try:
|
||||
from ..tools.tool_search import ToolSearchConfig as _ToolSearchConfig
|
||||
return _ToolSearchConfig
|
||||
except ImportError:
|
||||
# Fallback minimal config if tools module not available
|
||||
@dataclass
|
||||
class FallbackToolSearchConfig:
|
||||
enabled: Union[bool, str] = "auto"
|
||||
threshold_pct: float = 10.0
|
||||
search_default_limit: int = 5
|
||||
max_search_limit: int = 20
|
||||
core_tools: Optional[FrozenSet[str]] = None
|
||||
return FallbackToolSearchConfig
|
||||
|
||||
ToolSearchConfig = __get_tool_search_config()
|
||||
|
||||
|
||||
class AutonomyLevel(str, Enum):
|
||||
"""Autonomy levels for agent behavior."""
|
||||
SUGGEST = "suggest"
|
||||
@@ -1092,6 +1113,7 @@ CachingParam = Union[bool, CachingConfig]
|
||||
HooksParam = Union[List[Any], HooksConfig]
|
||||
SkillsParam = Union[List[str], SkillsConfig]
|
||||
AutonomyParam = Union[bool, Dict[str, Any], "AutonomyConfig"]
|
||||
ToolSearchParam = Union[bool, str, Dict[str, Any], ToolSearchConfig]
|
||||
|
||||
|
||||
# =============================================================================
|
||||
@@ -1343,6 +1365,21 @@ def resolve_autonomy(value: AutonomyParam) -> Optional[AutonomyConfig]:
|
||||
return value
|
||||
|
||||
|
||||
def resolve_tool_search(value: ToolSearchParam) -> Optional[ToolSearchConfig]:
|
||||
"""Resolve tool_search= parameter following precedence ladder."""
|
||||
if value is None or value is False:
|
||||
return None
|
||||
if value is True:
|
||||
return ToolSearchConfig()
|
||||
if isinstance(value, str):
|
||||
return ToolSearchConfig(enabled=value)
|
||||
if isinstance(value, dict):
|
||||
return ToolSearchConfig(**value)
|
||||
if isinstance(value, ToolSearchConfig):
|
||||
return value
|
||||
return value
|
||||
|
||||
|
||||
__all__ = [
|
||||
# Enums
|
||||
"MemoryBackend",
|
||||
@@ -1368,6 +1405,7 @@ __all__ = [
|
||||
"HooksConfig",
|
||||
"SkillsConfig",
|
||||
"AutonomyConfig",
|
||||
"ToolSearchConfig",
|
||||
# Config classes (Multi-Agent)
|
||||
"MultiAgentHooksConfig",
|
||||
"MultiAgentOutputConfig",
|
||||
@@ -1388,6 +1426,7 @@ __all__ = [
|
||||
"HooksParam",
|
||||
"SkillsParam",
|
||||
"AutonomyParam",
|
||||
"ToolSearchParam",
|
||||
# Precedence ladder resolvers
|
||||
"resolve_memory",
|
||||
"resolve_knowledge",
|
||||
@@ -1399,4 +1438,5 @@ __all__ = [
|
||||
"resolve_execution",
|
||||
"resolve_caching",
|
||||
"resolve_autonomy",
|
||||
"resolve_tool_search",
|
||||
]
|
||||
@@ -0,0 +1,13 @@
|
||||
"""Hooks query protocol for UI/API consumers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Dict, List, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class HooksQueryProtocol(Protocol):
|
||||
"""Read path for registered hooks."""
|
||||
|
||||
def list_hooks_for_api(self) -> List[Dict]:
|
||||
...
|
||||
@@ -439,3 +439,13 @@ def has_hook(event: Union[str, HookEvent]) -> bool:
|
||||
)
|
||||
|
||||
return get_default_registry().has_hooks(event)
|
||||
|
||||
|
||||
def list_hooks_for_api() -> List[Dict]:
|
||||
"""Flat hook list suitable for REST/UI consumers."""
|
||||
registry = get_default_registry()
|
||||
flat: List[Dict] = []
|
||||
for event, hooks in registry.list_hooks().items():
|
||||
for h in hooks:
|
||||
flat.append({"event": event, "source": "sdk", **h})
|
||||
return flat
|
||||
@@ -68,6 +68,14 @@ class HookEvent(str, Enum):
|
||||
SCHEDULE_REMOVE = "schedule_remove"
|
||||
SCHEDULE_TRIGGER = "schedule_trigger"
|
||||
|
||||
# Kanban task lifecycle (wrapper dispatcher + tools emit these)
|
||||
KANBAN_TASK_CREATED = "kanban_task_created"
|
||||
KANBAN_TASK_CLAIMED = "kanban_task_claimed"
|
||||
KANBAN_TASK_MOVED = "kanban_task_moved"
|
||||
KANBAN_TASK_DONE = "kanban_task_done"
|
||||
KANBAN_TASK_BLOCKED = "kanban_task_blocked"
|
||||
KANBAN_TASK_FAILED = "kanban_task_failed"
|
||||
|
||||
# Claude Code parity events
|
||||
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
|
||||
NOTIFICATION = "notification" # When notification is sent
|
||||
@@ -101,6 +109,34 @@ class HookInput:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KanbanHookInput(HookInput):
|
||||
"""Hook input for kanban task lifecycle events.
|
||||
|
||||
Used by wrapper dispatcher and tools when emitting kanban events.
|
||||
Observability adapters can subscribe to these via the hook registry.
|
||||
"""
|
||||
task_id: str = ""
|
||||
board: str = "default"
|
||||
status: str = ""
|
||||
assignee: Optional[str] = field(default=None)
|
||||
from_status: Optional[str] = field(default=None)
|
||||
to_status: Optional[str] = field(default=None)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result = super().to_dict()
|
||||
result.update({
|
||||
"task_id": self.task_id,
|
||||
"board": self.board,
|
||||
"status": self.status,
|
||||
"assignee": self.assignee,
|
||||
"from_status": self.from_status,
|
||||
"to_status": self.to_status,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookOutput:
|
||||
"""Base hook output - common fields for all events."""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Kanban protocols and types for PraisonAI Agents.
|
||||
|
||||
This module provides the protocol contracts for kanban functionality,
|
||||
allowing the wrapper (praisonai) and PraisonAIUI to share a stable
|
||||
interface without coupling the core to SQLite or other heavy implementations.
|
||||
"""
|
||||
|
||||
from praisonaiagents.kanban.protocols import (
|
||||
KanbanStoreProtocol,
|
||||
KanbanTaskProtocol,
|
||||
KanbanCommentingProtocol,
|
||||
KanbanLinkingProtocol,
|
||||
VALID_KANBAN_STATUSES,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"KanbanStoreProtocol",
|
||||
"KanbanTaskProtocol",
|
||||
"KanbanCommentingProtocol",
|
||||
"KanbanLinkingProtocol",
|
||||
"VALID_KANBAN_STATUSES",
|
||||
]
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Kanban protocols for PraisonAI Agents.
|
||||
|
||||
Defines the protocol contracts that the wrapper (praisonai) and PraisonAIUI
|
||||
can use to implement kanban functionality with a stable interface.
|
||||
|
||||
This follows AGENTS.md §3.2 Protocol-First Design:
|
||||
- Protocols define WHAT (interface contract)
|
||||
- Implementations define HOW (concrete behavior)
|
||||
- Core SDK has protocols only, wrapper has heavy implementations
|
||||
"""
|
||||
|
||||
from typing import Protocol, TypedDict, runtime_checkable
|
||||
|
||||
|
||||
# Valid kanban statuses matching PraisonAIUI columns + archived
|
||||
VALID_KANBAN_STATUSES: frozenset[str] = frozenset([
|
||||
"triage",
|
||||
"todo",
|
||||
"ready",
|
||||
"running",
|
||||
"blocked",
|
||||
"review",
|
||||
"done",
|
||||
"archived"
|
||||
])
|
||||
|
||||
|
||||
class KanbanTaskProtocol(TypedDict, total=False):
|
||||
"""Typed dict shape for kanban task fields.
|
||||
|
||||
Defines the expected structure of task objects returned by
|
||||
KanbanStoreProtocol implementations.
|
||||
"""
|
||||
id: str
|
||||
title: str
|
||||
body: str
|
||||
status: str
|
||||
assignee: str | None
|
||||
priority: str | None
|
||||
tenant: str | None
|
||||
board: str
|
||||
created_at: float
|
||||
updated_at: float
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class KanbanStoreProtocol(Protocol):
|
||||
"""Protocol contract for kanban store implementations.
|
||||
|
||||
This protocol defines the core interface that PraisonAIUI expects
|
||||
for injected kanban stores. Wrapper implementations must
|
||||
implement all methods to be compatible.
|
||||
|
||||
Duck typing contract matches InjectedKanbanStore in PraisonAIUI.
|
||||
"""
|
||||
|
||||
def get_board(
|
||||
self,
|
||||
*,
|
||||
board: str = "default",
|
||||
tenant: str | None = None,
|
||||
include_archived: bool = False,
|
||||
) -> dict:
|
||||
"""Get board data with tasks grouped by status.
|
||||
|
||||
Args:
|
||||
board: Board name (default: "default")
|
||||
tenant: Optional tenant filter
|
||||
include_archived: Whether to include archived tasks
|
||||
|
||||
Returns:
|
||||
Board data with tasks organized by status columns
|
||||
"""
|
||||
...
|
||||
|
||||
def get_task(self, task_id: str) -> dict | None:
|
||||
"""Get a single task by ID.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
|
||||
Returns:
|
||||
Task data or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def create_task(self, data: dict) -> dict:
|
||||
"""Create a new task.
|
||||
|
||||
Args:
|
||||
data: Task creation data (title, body, status, etc.)
|
||||
|
||||
Returns:
|
||||
Created task data with assigned ID
|
||||
"""
|
||||
...
|
||||
|
||||
def update_task(self, task_id: str, data: dict) -> dict | None:
|
||||
"""Update an existing task.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
data: Fields to update
|
||||
|
||||
Returns:
|
||||
Updated task data or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def move_task(self, task_id: str, status: str) -> dict | None:
|
||||
"""Move a task to a different status.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
status: New status (must be in VALID_KANBAN_STATUSES)
|
||||
|
||||
Returns:
|
||||
Updated task data or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def bulk_update(self, task_ids: list[str], status: str) -> dict:
|
||||
"""Update multiple tasks to the same status.
|
||||
|
||||
Args:
|
||||
task_ids: List of task identifiers
|
||||
status: New status for all tasks
|
||||
|
||||
Returns:
|
||||
Results summary with success/failure counts
|
||||
"""
|
||||
...
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
"""Delete a task.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def list_events(self, since: float = 0.0, board: str = "default") -> list[dict]:
|
||||
"""List kanban events since a timestamp.
|
||||
|
||||
Args:
|
||||
since: Unix timestamp to filter events from
|
||||
board: Board name to filter events
|
||||
|
||||
Returns:
|
||||
List of event data
|
||||
"""
|
||||
...
|
||||
|
||||
def health(self) -> dict:
|
||||
"""Check store health status.
|
||||
|
||||
Returns:
|
||||
Health status information
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class KanbanCommentingProtocol(Protocol):
|
||||
"""Extension protocol for kanban task commenting functionality.
|
||||
|
||||
This protocol is implemented separately from KanbanStoreProtocol to allow
|
||||
stores to optionally support commenting without breaking isinstance checks
|
||||
on the core protocol.
|
||||
"""
|
||||
|
||||
def add_comment(self, task_id: str, text: str, author: str | None = None) -> dict | None:
|
||||
"""Add a comment to a task.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
text: Comment text
|
||||
author: Optional comment author
|
||||
|
||||
Returns:
|
||||
Comment data or None if task not found
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class KanbanLinkingProtocol(Protocol):
|
||||
"""Extension protocol for kanban task linking functionality.
|
||||
|
||||
This protocol is implemented separately from KanbanStoreProtocol to allow
|
||||
stores to optionally support task relationships without breaking isinstance
|
||||
checks on the core protocol.
|
||||
"""
|
||||
|
||||
def link_tasks(self, parent_id: str, child_id: str) -> bool:
|
||||
"""Link two tasks in parent-child relationship.
|
||||
|
||||
Args:
|
||||
parent_id: Parent task identifier
|
||||
child_id: Child task identifier
|
||||
|
||||
Returns:
|
||||
True if linked successfully, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def unlink_tasks(self, parent_id: str, child_id: str) -> bool:
|
||||
"""Unlink parent-child task relationship.
|
||||
|
||||
Args:
|
||||
parent_id: Parent task identifier
|
||||
child_id: Child task identifier
|
||||
|
||||
Returns:
|
||||
True if unlinked successfully, False otherwise
|
||||
"""
|
||||
...
|
||||
@@ -174,7 +174,14 @@ class ChromaKnowledgeAdapter:
|
||||
|
||||
# Add where filter if any conditions specified
|
||||
if where_filter:
|
||||
query_kwargs["where"] = where_filter
|
||||
# ChromaDB validation expects exactly one condition in the where clause
|
||||
# When multiple conditions exist, use $and operator to combine them
|
||||
if len(where_filter) == 1:
|
||||
query_kwargs["where"] = where_filter
|
||||
else:
|
||||
# Convert multiple conditions to ChromaDB's $and format
|
||||
and_conditions = [{k: v} for k, v in where_filter.items()]
|
||||
query_kwargs["where"] = {"$and": and_conditions}
|
||||
|
||||
response = self.collection.query(**query_kwargs)
|
||||
|
||||
@@ -197,6 +204,10 @@ class ChromaKnowledgeAdapter:
|
||||
return SearchResult(results=items)
|
||||
|
||||
except Exception as e:
|
||||
# Log ChromaDB query errors for better debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"ChromaDB search failed: {e}")
|
||||
return SearchResult(results=[])
|
||||
|
||||
def add(self, content: Any, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
|
||||
@@ -73,10 +73,10 @@ def _get_live():
|
||||
|
||||
# Import token tracking
|
||||
try:
|
||||
from ..telemetry.token_collector import TokenMetrics, _token_collector
|
||||
from ..telemetry.token_collector import TokenMetrics, get_token_collector
|
||||
except ImportError:
|
||||
TokenMetrics = None
|
||||
_token_collector = None
|
||||
get_token_collector = None
|
||||
|
||||
# Logging is already configured in _logging.py via __init__.py
|
||||
|
||||
@@ -4519,7 +4519,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
|
||||
def _track_token_usage(self, response: Dict[str, Any], model: str) -> Optional[TokenMetrics]:
|
||||
"""Extract and track token usage from LLM response."""
|
||||
if not TokenMetrics or not _token_collector:
|
||||
if not TokenMetrics or not get_token_collector:
|
||||
return None
|
||||
|
||||
# Note: metrics check moved to call sites for performance
|
||||
@@ -4551,7 +4551,7 @@ Output MUST be JSON with 'reflection' and 'satisfactory'.
|
||||
# Track in global collector
|
||||
# Extract provider from model string (e.g., 'openai' from 'openai/gpt-4o-mini')
|
||||
provider = model.split('/')[0] if '/' in model else 'litellm'
|
||||
_token_collector.track_tokens(
|
||||
get_token_collector().track_tokens(
|
||||
model=model,
|
||||
agent=self.current_agent_name,
|
||||
metrics=metrics,
|
||||
|
||||
@@ -83,11 +83,23 @@ def __getattr__(name):
|
||||
elif name == "OAUTH_CALLBACK_PATH":
|
||||
from .mcp_oauth_callback import OAUTH_CALLBACK_PATH
|
||||
return OAUTH_CALLBACK_PATH
|
||||
# Protocols (lazy loaded)
|
||||
elif name == "MCPClientProtocol":
|
||||
from .protocols import MCPClientProtocol
|
||||
return MCPClientProtocol
|
||||
# Loader (lazy loaded)
|
||||
elif name == "load_mcp_tools":
|
||||
from .loader import load_mcp_tools
|
||||
return load_mcp_tools
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
__all__ = [
|
||||
# Client
|
||||
"MCP",
|
||||
# Protocols (lazy loaded)
|
||||
"MCPClientProtocol",
|
||||
# Loader (lazy loaded)
|
||||
"load_mcp_tools",
|
||||
# Server (lazy loaded)
|
||||
"ToolsMCPServer",
|
||||
"launch_tools_mcp_server",
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
MCP Tools Loader for PraisonAI Agents.
|
||||
|
||||
Provides a thin helper function to load MCP tools for agents
|
||||
following the agent-centric design principles in AGENTS.md.
|
||||
"""
|
||||
|
||||
from typing import List, Optional
|
||||
|
||||
from ..memory.mcp_config import MCPConfig, MCPConfigManager
|
||||
from .mcp import MCP
|
||||
|
||||
|
||||
def load_mcp_tools(
|
||||
names: Optional[List[str]] = None,
|
||||
*,
|
||||
configs: Optional[List[MCPConfig]] = None,
|
||||
prefix_tools: bool = True,
|
||||
) -> List[MCP]:
|
||||
"""
|
||||
Load MCP tools for use with agents.
|
||||
|
||||
This is a thin helper that bridges the gap between configuration
|
||||
and agent tool setup, following the "few lines to run an agent"
|
||||
principle from AGENTS.md.
|
||||
|
||||
Args:
|
||||
names: Specific config names to load (None = all enabled)
|
||||
configs: Optional injected configs from wrapper TOML loader
|
||||
prefix_tools: Whether to prefix tool names when multiple servers
|
||||
are loaded to avoid collisions (default: True).
|
||||
NOTE: Currently not implemented - collisions possible with multiple servers.
|
||||
|
||||
Returns:
|
||||
List of MCP client instances ready for agent use
|
||||
|
||||
Examples:
|
||||
# Load all enabled MCP servers from JSON config
|
||||
>>> from praisonaiagents import Agent
|
||||
>>> from praisonaiagents.mcp import load_mcp_tools
|
||||
>>> agent = Agent(name="assistant", tools=load_mcp_tools())
|
||||
|
||||
# Load specific servers only
|
||||
>>> tools = load_mcp_tools(["filesystem", "github"])
|
||||
>>> agent = Agent(name="coder", tools=tools)
|
||||
|
||||
# Inject configs from wrapper TOML
|
||||
>>> from praisonai.cli.configuration import get_config_loader
|
||||
>>> loader = get_config_loader()
|
||||
>>> config = loader.load()
|
||||
>>> toml_configs = [MCPConfig(...) for server in config.mcp.servers]
|
||||
>>> tools = load_mcp_tools(configs=toml_configs)
|
||||
"""
|
||||
mcp_clients: List[MCP] = []
|
||||
|
||||
if configs is not None:
|
||||
# Use injected configs from wrapper (TOML loading)
|
||||
target_configs = configs
|
||||
if names:
|
||||
# Filter by names if specified
|
||||
target_configs = [c for c in configs if c.name in names]
|
||||
else:
|
||||
# Load from MCPConfigManager (JSON files)
|
||||
manager = MCPConfigManager()
|
||||
if names:
|
||||
target_configs = [manager.get_config(name) for name in names]
|
||||
target_configs = [c for c in target_configs if c and c.enabled]
|
||||
else:
|
||||
target_configs = manager.get_enabled_configs()
|
||||
|
||||
for config in target_configs:
|
||||
if not config.enabled:
|
||||
continue
|
||||
|
||||
# Convert config to MCP instance
|
||||
mcp = config.to_mcp_instance()
|
||||
if mcp:
|
||||
# Apply tool filtering if configured (B3)
|
||||
# TODO: Wire include/exclude filters when B3 is implemented
|
||||
|
||||
# Apply tool prefix if multiple servers (B4)
|
||||
if prefix_tools and len(target_configs) > 1:
|
||||
# Sanitize server name for use as prefix
|
||||
server_name = config.name.replace('-', '_').replace(' ', '_')
|
||||
# TODO: Tool name prefixing not yet implemented
|
||||
# Need to modify tool names in MCP instance to avoid collisions
|
||||
# e.g., "read_file" -> "filesystem_read_file"
|
||||
pass
|
||||
|
||||
mcp_clients.append(mcp)
|
||||
|
||||
return mcp_clients
|
||||
@@ -187,7 +187,8 @@ class MCP:
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(self, command_or_string=None, args=None, *, command=None, timeout=60, debug=False, **kwargs):
|
||||
def __init__(self, command_or_string=None, args=None, *, command=None, timeout=60, debug=False,
|
||||
allowed_tools: Optional[List[str]] = None, disabled_tools: Optional[List[str]] = None, **kwargs):
|
||||
"""
|
||||
Initialize the MCP connection and get tools.
|
||||
|
||||
@@ -201,7 +202,13 @@ class MCP:
|
||||
command: Alternative parameter name for backward compatibility
|
||||
timeout: Timeout in seconds for MCP server initialization and tool calls (default: 60)
|
||||
debug: Enable debug logging for MCP operations (default: False)
|
||||
allowed_tools: Include whitelist - only these tools will be available (default: None = all tools)
|
||||
disabled_tools: Exclude blacklist - these tools will be filtered out (default: None = no exclusions)
|
||||
**kwargs: Additional parameters for StdioServerParameters
|
||||
|
||||
Note:
|
||||
If both allowed_tools and disabled_tools are specified, allowed_tools takes precedence
|
||||
(include wins over exclude).
|
||||
"""
|
||||
# Check if MCP is available
|
||||
if not MCP_AVAILABLE:
|
||||
@@ -240,6 +247,8 @@ class MCP:
|
||||
# Store additional parameters
|
||||
self.timeout = timeout
|
||||
self.debug = debug
|
||||
self.allowed_tools = allowed_tools
|
||||
self.disabled_tools = disabled_tools
|
||||
|
||||
# Check if this is a WebSocket URL (ws:// or wss://)
|
||||
if isinstance(command_or_string, str) and re.match(r'^wss?://', command_or_string):
|
||||
@@ -254,7 +263,7 @@ class MCP:
|
||||
auth_token=auth_token,
|
||||
options=kwargs
|
||||
)
|
||||
self._tools = list(self.websocket_client.tools)
|
||||
self._tools = self._apply_tool_filters(list(self.websocket_client.tools))
|
||||
self.is_sse = False
|
||||
self.is_http_stream = False
|
||||
self.is_websocket = True
|
||||
@@ -268,7 +277,7 @@ class MCP:
|
||||
# Legacy SSE URL - use SSE transport for backward compatibility
|
||||
from .mcp_sse import SSEMCPClient
|
||||
self.sse_client = SSEMCPClient(command_or_string, debug=debug, timeout=timeout)
|
||||
self._tools = list(self.sse_client.tools)
|
||||
self._tools = self._apply_tool_filters(list(self.sse_client.tools))
|
||||
self.is_sse = True
|
||||
self.is_http_stream = False
|
||||
self.is_npx = False
|
||||
@@ -295,7 +304,7 @@ class MCP:
|
||||
timeout=timeout,
|
||||
options=transport_options
|
||||
)
|
||||
self._tools = list(self.http_stream_client.tools)
|
||||
self._tools = self._apply_tool_filters(list(self.http_stream_client.tools))
|
||||
self.is_sse = False
|
||||
self.is_http_stream = True
|
||||
self.is_npx = False
|
||||
@@ -325,20 +334,10 @@ class MCP:
|
||||
self.is_sse = False
|
||||
self.is_http_stream = False
|
||||
|
||||
# Ensure UTF-8 encoding in environment for Docker compatibility
|
||||
env = kwargs.get('env', {})
|
||||
if not env:
|
||||
env = os.environ.copy()
|
||||
|
||||
# Always set Python encoding
|
||||
env['PYTHONIOENCODING'] = 'utf-8'
|
||||
|
||||
# Only set locale variables on Unix systems
|
||||
if platform.system() != 'Windows':
|
||||
env.update({
|
||||
'LC_ALL': 'C.UTF-8',
|
||||
'LANG': 'C.UTF-8'
|
||||
})
|
||||
# Build safe environment for stdio MCP servers
|
||||
# Use safe baseline + explicit env from config (B5 security policy)
|
||||
custom_env = kwargs.get('env', {})
|
||||
env = self._build_safe_env(custom_env)
|
||||
|
||||
kwargs['env'] = env
|
||||
|
||||
@@ -369,7 +368,7 @@ class MCP:
|
||||
self._initialize_npx_mcp_tools(cmd, arguments)
|
||||
else:
|
||||
# Generate tool functions immediately and store them
|
||||
self._tools = self._generate_tool_functions()
|
||||
self._tools = self._apply_tool_filters(self._generate_tool_functions())
|
||||
|
||||
def _generate_tool_functions(self) -> List[Callable]:
|
||||
"""
|
||||
@@ -489,7 +488,7 @@ class MCP:
|
||||
logging.debug(f"Initializing NPX MCP tools with command: {cmd} {' '.join(arguments)}")
|
||||
|
||||
# Generate tool functions using the regular MCP approach
|
||||
self._tools = self._generate_tool_functions()
|
||||
self._tools = self._apply_tool_filters(self._generate_tool_functions())
|
||||
|
||||
if self.debug:
|
||||
logging.debug(f"Generated {len(self._tools)} NPX MCP tools")
|
||||
@@ -499,6 +498,100 @@ class MCP:
|
||||
logging.error(f"Failed to initialize NPX MCP tools: {e}")
|
||||
raise RuntimeError(f"Failed to initialize NPX MCP tools: {e}")
|
||||
|
||||
def _apply_tool_filters(self, raw_tools: List[Any]) -> List[Any]:
|
||||
"""
|
||||
Apply tool filtering based on allowed_tools and disabled_tools.
|
||||
|
||||
Args:
|
||||
raw_tools: Unfiltered tool list
|
||||
|
||||
Returns:
|
||||
Filtered tool list
|
||||
"""
|
||||
if not self.allowed_tools and not self.disabled_tools:
|
||||
return raw_tools
|
||||
|
||||
# Import filter functions
|
||||
from .mcp_utils import filter_tools_by_allowlist, filter_disabled_tools
|
||||
|
||||
# Convert tools to the format expected by filter functions (list of dicts with 'name')
|
||||
# Raw tools might be function objects, so we need to extract names
|
||||
tool_defs = []
|
||||
for tool in raw_tools:
|
||||
if hasattr(tool, '__name__'):
|
||||
tool_defs.append({"name": tool.__name__, "_tool_obj": tool})
|
||||
elif isinstance(tool, dict) and 'name' in tool:
|
||||
tool_defs.append(tool)
|
||||
else:
|
||||
# Skip tools that don't have a clear name
|
||||
continue
|
||||
|
||||
# Include wins over exclude - apply allowlist exclusively if provided
|
||||
if self.allowed_tools:
|
||||
tool_defs = filter_tools_by_allowlist(tool_defs, self.allowed_tools)
|
||||
elif self.disabled_tools:
|
||||
tool_defs = filter_disabled_tools(tool_defs, self.disabled_tools)
|
||||
|
||||
# Convert back to original format
|
||||
filtered_tools = []
|
||||
for tool_def in tool_defs:
|
||||
if "_tool_obj" in tool_def:
|
||||
filtered_tools.append(tool_def["_tool_obj"])
|
||||
else:
|
||||
filtered_tools.append(tool_def)
|
||||
|
||||
return filtered_tools
|
||||
|
||||
def _build_safe_env(self, custom_env: Optional[dict] = None) -> dict:
|
||||
"""
|
||||
Build a safe environment for stdio MCP servers.
|
||||
|
||||
Merges only safe baseline environment variables with explicit custom ones,
|
||||
following the security policy outlined in AGENTS.md.
|
||||
|
||||
Args:
|
||||
custom_env: Optional custom environment variables from config
|
||||
|
||||
Returns:
|
||||
Safe environment dictionary
|
||||
"""
|
||||
# Safe baseline environment variables
|
||||
safe_baseline = {
|
||||
'PATH': os.environ.get('PATH', ''),
|
||||
'HOME': os.environ.get('HOME', os.path.expanduser('~')),
|
||||
'USER': os.environ.get('USER', ''),
|
||||
'LANG': os.environ.get('LANG', 'C.UTF-8'),
|
||||
'LC_ALL': os.environ.get('LC_ALL', 'C.UTF-8'),
|
||||
'PYTHONIOENCODING': 'utf-8',
|
||||
}
|
||||
|
||||
# Platform-specific safe variables
|
||||
if platform.system() == 'Windows':
|
||||
safe_baseline.update({
|
||||
'SYSTEMROOT': os.environ.get('SYSTEMROOT', ''),
|
||||
'COMSPEC': os.environ.get('COMSPEC', ''),
|
||||
'USERNAME': os.environ.get('USERNAME', ''),
|
||||
'USERPROFILE': os.environ.get('USERPROFILE', ''),
|
||||
'APPDATA': os.environ.get('APPDATA', ''),
|
||||
'LOCALAPPDATA': os.environ.get('LOCALAPPDATA', ''),
|
||||
'TEMP': os.environ.get('TEMP', ''),
|
||||
'TMP': os.environ.get('TMP', ''),
|
||||
})
|
||||
else:
|
||||
safe_baseline.update({
|
||||
'TMPDIR': os.environ.get('TMPDIR', '/tmp'),
|
||||
'SHELL': os.environ.get('SHELL', '/bin/sh'),
|
||||
})
|
||||
|
||||
# Start with safe baseline
|
||||
env = {k: v for k, v in safe_baseline.items() if v} # Only include non-empty values
|
||||
|
||||
# Add explicit custom environment variables from config
|
||||
if custom_env:
|
||||
env.update(custom_env)
|
||||
|
||||
return env
|
||||
|
||||
def __iter__(self) -> Iterable[Callable]:
|
||||
"""
|
||||
Allow the MCP instance to be used directly as an iterable of tools.
|
||||
@@ -609,7 +702,8 @@ class MCP:
|
||||
"name": tool.name,
|
||||
"description": tool.description if hasattr(tool, 'description') else f"Call the {tool.name} tool",
|
||||
"parameters": parameters
|
||||
}
|
||||
},
|
||||
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
|
||||
})
|
||||
|
||||
return openai_tools
|
||||
|
||||
@@ -156,7 +156,8 @@ class HTTPStreamMCPTool:
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": fixed_schema
|
||||
}
|
||||
},
|
||||
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
|
||||
}
|
||||
|
||||
class HTTPStreamTransport:
|
||||
|
||||
@@ -121,7 +121,8 @@ class SSEMCPTool:
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": fixed_schema
|
||||
}
|
||||
},
|
||||
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
|
||||
}
|
||||
|
||||
class SSEMCPClient:
|
||||
|
||||
@@ -363,7 +363,8 @@ class WebSocketMCPTool:
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"parameters": fixed_schema
|
||||
}
|
||||
},
|
||||
"__praisonai_deferrable__": True # Mark MCP tools as deferrable for tool search
|
||||
}
|
||||
|
||||
class WebSocketMCPClient:
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Model Context Protocol (MCP) client protocols for PraisonAI Agents.
|
||||
|
||||
Defines protocol interfaces for MCP client extensibility following
|
||||
the protocol-driven design pattern outlined in AGENTS.md.
|
||||
"""
|
||||
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class MCPClientProtocol(Protocol):
|
||||
"""
|
||||
Protocol interface for MCP client implementations.
|
||||
|
||||
Defines the minimal contract that all MCP clients must follow,
|
||||
enabling extensibility through different transport mechanisms
|
||||
(stdio, SSE, HTTP stream, WebSocket) while maintaining a
|
||||
consistent interface for agents.
|
||||
"""
|
||||
|
||||
def list_tools(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
List available tools from the MCP server.
|
||||
|
||||
Returns:
|
||||
List of tool definitions from the server
|
||||
"""
|
||||
...
|
||||
|
||||
def get_tools(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Get available tools from the MCP server.
|
||||
|
||||
Alias for list_tools() to maintain backward compatibility.
|
||||
|
||||
Returns:
|
||||
List of tool definitions from the server
|
||||
"""
|
||||
...
|
||||
|
||||
def call_tool(self, name: str, args: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Call a tool on the MCP server.
|
||||
|
||||
Args:
|
||||
name: Tool name to call
|
||||
args: Tool arguments
|
||||
|
||||
Returns:
|
||||
Tool execution result
|
||||
|
||||
Raises:
|
||||
Exception: If tool call fails
|
||||
"""
|
||||
...
|
||||
|
||||
def shutdown(self) -> None:
|
||||
"""
|
||||
Shutdown the MCP client and clean up resources.
|
||||
|
||||
This should gracefully close connections, terminate
|
||||
subprocesses, and release any held resources.
|
||||
"""
|
||||
...
|
||||
|
||||
async def async_list_tools(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Asynchronously list available tools from the MCP server.
|
||||
|
||||
Returns:
|
||||
List of tool definitions from the server
|
||||
"""
|
||||
...
|
||||
|
||||
async def async_get_tools(self) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Asynchronously get available tools from the MCP server.
|
||||
|
||||
Alias for async_list_tools() to maintain backward compatibility.
|
||||
|
||||
Returns:
|
||||
List of tool definitions from the server
|
||||
"""
|
||||
...
|
||||
|
||||
async def async_call_tool(self, name: str, args: Dict[str, Any]) -> Any:
|
||||
"""
|
||||
Asynchronously call a tool on the MCP server.
|
||||
|
||||
Args:
|
||||
name: Tool name to call
|
||||
args: Tool arguments
|
||||
|
||||
Returns:
|
||||
Tool execution result
|
||||
|
||||
Raises:
|
||||
Exception: If tool call fails
|
||||
"""
|
||||
...
|
||||
|
||||
async def async_shutdown(self) -> None:
|
||||
"""
|
||||
Asynchronously shutdown the MCP client and clean up resources.
|
||||
|
||||
This should gracefully close connections, terminate
|
||||
subprocesses, and release any held resources.
|
||||
"""
|
||||
...
|
||||
@@ -81,7 +81,7 @@ class MCPConfig:
|
||||
MCP instance ready for use with agents
|
||||
"""
|
||||
try:
|
||||
from praisonaiagents.tools.mcp import MCP
|
||||
from praisonaiagents.mcp import MCP
|
||||
|
||||
# Resolve environment variables
|
||||
resolved_env = {}
|
||||
|
||||
@@ -6,7 +6,13 @@ No heavy imports - only stdlib.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..knowledge.models import SearchResultItem
|
||||
|
||||
|
||||
ResultItem = Union[Dict[str, Any], "SearchResultItem"]
|
||||
|
||||
|
||||
def _estimate_tokens(text: str) -> int:
|
||||
@@ -19,6 +25,39 @@ def _estimate_tokens(text: str) -> int:
|
||||
return len(text) // 4 + 1
|
||||
|
||||
|
||||
def _extract_value(item: ResultItem, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Extract value from either dict or object (e.g., SearchResultItem).
|
||||
|
||||
Args:
|
||||
item: Dictionary or object with attributes
|
||||
key: Key/attribute name to extract
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Extracted value or default
|
||||
"""
|
||||
if isinstance(item, dict):
|
||||
return item.get(key, default)
|
||||
else:
|
||||
return getattr(item, key, default)
|
||||
|
||||
|
||||
def _extract_metadata_value(
|
||||
item: ResultItem,
|
||||
metadata: Dict[str, Any],
|
||||
key: str,
|
||||
default: Any = "",
|
||||
) -> Any:
|
||||
"""
|
||||
Extract metadata value with fallback to top-level item attribute.
|
||||
"""
|
||||
value = metadata.get(key)
|
||||
if value is None:
|
||||
return _extract_value(item, key, default)
|
||||
return value
|
||||
|
||||
|
||||
def _chunk_hash(text: str, source: Optional[str] = None) -> str:
|
||||
"""
|
||||
Generate stable hash for chunk deduplication.
|
||||
@@ -35,31 +74,38 @@ def _chunk_hash(text: str, source: Optional[str] = None) -> str:
|
||||
|
||||
|
||||
def deduplicate_chunks(
|
||||
results: List[Dict[str, Any]],
|
||||
results: List[ResultItem],
|
||||
similarity_threshold: float = 0.9,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> List[ResultItem]:
|
||||
"""
|
||||
Deduplicate chunks by content hash.
|
||||
|
||||
Handles both dict format and SearchResultItem objects.
|
||||
|
||||
Args:
|
||||
results: List of search results with 'text' or 'memory' key
|
||||
results: List of search results (dicts or SearchResultItem objects)
|
||||
similarity_threshold: Not used currently (hash-based dedup)
|
||||
|
||||
Returns:
|
||||
Deduplicated list of results
|
||||
"""
|
||||
seen_hashes: Set[str] = set()
|
||||
unique_results: List[Dict[str, Any]] = []
|
||||
unique_results: List[ResultItem] = []
|
||||
|
||||
for result in results:
|
||||
# Skip None results
|
||||
if result is None:
|
||||
continue
|
||||
# Handle different result formats
|
||||
text = result.get("text") or result.get("memory", "")
|
||||
|
||||
# Handle different result formats (dict or SearchResultItem)
|
||||
text = _extract_value(result, "text") or _extract_value(result, "memory", "")
|
||||
|
||||
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
|
||||
metadata = result.get("metadata") or {}
|
||||
source = metadata.get("source", "")
|
||||
metadata = _extract_value(result, "metadata") or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
source = _extract_metadata_value(result, metadata, "source", "")
|
||||
|
||||
chunk_id = _chunk_hash(text, source)
|
||||
|
||||
@@ -106,17 +152,19 @@ def truncate_context(
|
||||
|
||||
|
||||
def build_context(
|
||||
results: List[Dict[str, Any]],
|
||||
results: List[ResultItem],
|
||||
max_tokens: int = 4000,
|
||||
deduplicate: bool = True,
|
||||
separator: str = "\n\n---\n\n",
|
||||
include_source: bool = True,
|
||||
) -> Tuple[str, List[Dict[str, Any]]]:
|
||||
) -> Tuple[str, List[ResultItem]]:
|
||||
"""
|
||||
Build context string from retrieval results.
|
||||
|
||||
Handles both dict format and SearchResultItem objects.
|
||||
|
||||
Args:
|
||||
results: List of search results
|
||||
results: List of search results (dicts or SearchResultItem objects)
|
||||
max_tokens: Maximum tokens for context
|
||||
deduplicate: Whether to deduplicate chunks
|
||||
separator: Separator between chunks
|
||||
@@ -133,7 +181,7 @@ def build_context(
|
||||
results = deduplicate_chunks(results)
|
||||
|
||||
context_parts: List[str] = []
|
||||
used_results: List[Dict[str, Any]] = []
|
||||
used_results: List[ResultItem] = []
|
||||
current_tokens = 0
|
||||
separator_tokens = _estimate_tokens(separator)
|
||||
|
||||
@@ -141,17 +189,21 @@ def build_context(
|
||||
# Skip None results
|
||||
if result is None:
|
||||
continue
|
||||
# Handle different result formats
|
||||
text = result.get("text") or result.get("memory", "")
|
||||
|
||||
# Handle different result formats (dict or SearchResultItem)
|
||||
text = _extract_value(result, "text") or _extract_value(result, "memory", "")
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Build chunk text with optional source
|
||||
if include_source:
|
||||
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
|
||||
metadata = result.get("metadata") or {}
|
||||
source = metadata.get("source", "")
|
||||
filename = metadata.get("filename", "")
|
||||
metadata = _extract_value(result, "metadata") or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
source = _extract_metadata_value(result, metadata, "source", "")
|
||||
filename = _extract_metadata_value(result, metadata, "filename", "")
|
||||
source_label = filename or source or f"Source {i + 1}"
|
||||
chunk_text = f"[{source_label}]\n{text}"
|
||||
else:
|
||||
@@ -194,11 +246,14 @@ class DefaultContextBuilder:
|
||||
|
||||
def build(
|
||||
self,
|
||||
results: List[Dict[str, Any]],
|
||||
results: List[ResultItem],
|
||||
max_tokens: int = 4000,
|
||||
deduplicate: bool = True,
|
||||
) -> str:
|
||||
"""Build context string from results."""
|
||||
"""Build context string from results.
|
||||
|
||||
Handles both dict format and SearchResultItem objects.
|
||||
"""
|
||||
context, _ = build_context(
|
||||
results=results,
|
||||
max_tokens=max_tokens,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
# Scheduler — runner vs loop
|
||||
|
||||
## ScheduleRunner (stateless)
|
||||
|
||||
`ScheduleRunner` is **stateless**. Call `get_due_jobs()` whenever you want to check for jobs that should fire. It does not run background threads.
|
||||
|
||||
Use when:
|
||||
- CLI one-shot tick (`praisonai schedule tick`)
|
||||
- Host integration that polls on its own timer
|
||||
- Tests
|
||||
|
||||
## ScheduleLoop (optional daemon)
|
||||
|
||||
`ScheduleLoop` is an **optional background daemon**. You must call `.start()` explicitly; it polls the runner and invokes `on_trigger` callbacks.
|
||||
|
||||
Use when:
|
||||
- Long-running host process (Pattern B/C via `setup_bridges()`)
|
||||
- Standalone scheduler service
|
||||
|
||||
```python
|
||||
from praisonaiagents.scheduler import FileScheduleStore, ScheduleRunner, ScheduleLoop
|
||||
|
||||
store = FileScheduleStore()
|
||||
runner = ScheduleRunner(store)
|
||||
loop = ScheduleLoop(runner)
|
||||
loop.start() # optional daemon
|
||||
```
|
||||
|
||||
The wrapper host calls `ensure_schedule_runner()` which starts `ScheduleLoop` when the scheduler optional dependency is available.
|
||||
@@ -32,7 +32,22 @@ When you provide a `session_id` to an Agent:
|
||||
|
||||
### Default Storage Location
|
||||
|
||||
Sessions are stored in: `~/.praison/sessions/{session_id}.json`
|
||||
Sessions are stored in: `~/.praisonai/sessions/{session_id}.json`
|
||||
|
||||
### Session metadata contract (Issue #48)
|
||||
|
||||
List endpoints and UI dashboards may expose these optional root-level fields:
|
||||
|
||||
| Field | Type | Description |
|
||||
|-------|------|-------------|
|
||||
| `model` | string | LLM model used in the session |
|
||||
| `total_tokens` | int | Cumulative input+output tokens |
|
||||
| `cost` | float | Estimated USD cost |
|
||||
| `agent_id` | string | Gateway or registry agent id |
|
||||
| `source` | string | Origin: `chat`, `gateway`, `cli`, `api` |
|
||||
| `agent_name` | string | Human-readable agent name |
|
||||
|
||||
Fields are persisted in session JSON `metadata` and mirrored at root on save. Populated automatically after each assistant turn when using the default session store.
|
||||
|
||||
### Session File Format
|
||||
|
||||
@@ -45,7 +60,13 @@ Sessions are stored in: `~/.praison/sessions/{session_id}.json`
|
||||
],
|
||||
"created_at": "2026-01-02T04:00:00+00:00",
|
||||
"updated_at": "2026-01-02T04:01:00+00:00",
|
||||
"agent_name": "Assistant"
|
||||
"agent_name": "Assistant",
|
||||
"agent_id": "agent-abc",
|
||||
"source": "chat",
|
||||
"model": "gpt-4o-mini",
|
||||
"total_tokens": 128,
|
||||
"cost": 0.0004,
|
||||
"metadata": {}
|
||||
}
|
||||
```
|
||||
|
||||
|
||||
@@ -144,6 +144,74 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._extended_cache: Dict[str, ExtendedSessionData] = {}
|
||||
self._cache_mtimes: Dict[str, float] = {} # Track file modification times
|
||||
|
||||
|
||||
def _load_session_from_disk(self, session_id: str, filepath: str) -> ExtendedSessionData:
|
||||
"""Load extended session JSON from disk (caller must hold FileLock)."""
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return ExtendedSessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return ExtendedSessionData(session_id=session_id)
|
||||
|
||||
def _modify_session_locked(
|
||||
self,
|
||||
session_id: str,
|
||||
mutator,
|
||||
*,
|
||||
error_label: str = "modify session",
|
||||
) -> bool:
|
||||
"""Locked read-modify-write preserving extended session fields."""
|
||||
result = super()._modify_session_locked(
|
||||
session_id, mutator, error_label=error_label
|
||||
)
|
||||
if result:
|
||||
with self._lock:
|
||||
cached = self._cache.get(session_id)
|
||||
if isinstance(cached, ExtendedSessionData):
|
||||
self._extended_cache[session_id] = cached
|
||||
return result
|
||||
|
||||
def _is_cache_valid(self, session_id: str) -> bool:
|
||||
"""Check if cached session is still valid based on file mtime."""
|
||||
if session_id not in self._extended_cache:
|
||||
return False
|
||||
|
||||
filepath = self._get_session_path(session_id)
|
||||
if not os.path.exists(filepath):
|
||||
return False
|
||||
|
||||
try:
|
||||
current_mtime = os.path.getmtime(filepath)
|
||||
cached_mtime = self._cache_mtimes.get(session_id, 0)
|
||||
return current_mtime <= cached_mtime
|
||||
except (OSError, IOError):
|
||||
return False
|
||||
|
||||
def _read_session_fresh(self, session_id: str) -> ExtendedSessionData:
|
||||
"""Reload from disk and keep _cache and _extended_cache in sync."""
|
||||
session = super()._read_session_fresh(session_id)
|
||||
if not isinstance(session, ExtendedSessionData):
|
||||
session = ExtendedSessionData.from_session_data(session)
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
|
||||
# Update cache with fresh file mtime
|
||||
filepath = self._get_session_path(session_id)
|
||||
try:
|
||||
mtime = os.path.getmtime(filepath) if os.path.exists(filepath) else time.time()
|
||||
except (OSError, IOError):
|
||||
mtime = time.time()
|
||||
|
||||
with self._lock:
|
||||
self._extended_cache[session_id] = session
|
||||
self._cache_mtimes[session_id] = mtime
|
||||
|
||||
return session
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
@@ -157,56 +225,32 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
|
||||
Overrides parent to preserve extended session data.
|
||||
"""
|
||||
from .store import SessionMessage
|
||||
|
||||
# Load extended session (force reload to get latest)
|
||||
session = self._load_extended_session(session_id, force_reload=True)
|
||||
|
||||
|
||||
message = SessionMessage(
|
||||
role=role,
|
||||
content=content,
|
||||
timestamp=time.time(),
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
session.messages.append(message)
|
||||
|
||||
# Trim messages if over limit
|
||||
if len(session.messages) > self.max_messages:
|
||||
session.messages = session.messages[-self.max_messages:]
|
||||
|
||||
return self._save_extended_session(session)
|
||||
|
||||
def _apply(session: SessionData) -> None:
|
||||
session.messages.append(message)
|
||||
if len(session.messages) > self.max_messages:
|
||||
session.messages = session.messages[-self.max_messages :]
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="add message to session"
|
||||
)
|
||||
|
||||
def _load_extended_session(self, session_id: str, force_reload: bool = False) -> ExtendedSessionData:
|
||||
"""Load extended session from disk."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
# Check cache first (unless force reload)
|
||||
if not force_reload:
|
||||
with self._lock:
|
||||
if session_id in self._extended_cache:
|
||||
return self._extended_cache[session_id]
|
||||
|
||||
# Load from disk
|
||||
if not os.path.exists(filepath):
|
||||
session = ExtendedSessionData(session_id=session_id)
|
||||
with self._lock:
|
||||
self._extended_cache[session_id] = session
|
||||
return session
|
||||
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
session = ExtendedSessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to load session {session_id}: {e}")
|
||||
session = ExtendedSessionData(session_id=session_id)
|
||||
"""Load extended session with smart caching based on file modification time."""
|
||||
# Force reload bypasses cache validation
|
||||
if force_reload or not self._is_cache_valid(session_id):
|
||||
return self._read_session_fresh(session_id)
|
||||
|
||||
# Cache is valid, return cached version
|
||||
with self._lock:
|
||||
self._extended_cache[session_id] = session
|
||||
|
||||
return session
|
||||
return self._extended_cache[session_id]
|
||||
|
||||
def _save_extended_session(self, session: ExtendedSessionData) -> bool:
|
||||
"""Save extended session to disk."""
|
||||
@@ -234,8 +278,15 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
|
||||
os.replace(temp_path, filepath)
|
||||
|
||||
# Update cache with current file mtime after successful write
|
||||
try:
|
||||
mtime = os.path.getmtime(filepath)
|
||||
except (OSError, IOError):
|
||||
mtime = time.time()
|
||||
|
||||
with self._lock:
|
||||
self._extended_cache[session.session_id] = session
|
||||
self._cache_mtimes[session.session_id] = mtime
|
||||
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
@@ -278,12 +329,13 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
# Update parent's children list
|
||||
# Update parent's children list without clobbering concurrent message writes
|
||||
if parent_id:
|
||||
parent = self._load_extended_session(parent_id)
|
||||
if sid not in parent.children_ids:
|
||||
parent.children_ids.append(sid)
|
||||
self._save_extended_session(parent)
|
||||
def _apply(parent_session: SessionData) -> None:
|
||||
assert isinstance(parent_session, ExtendedSessionData)
|
||||
if sid not in parent_session.children_ids:
|
||||
parent_session.children_ids.append(sid)
|
||||
self._modify_session_locked(parent_id, _apply, error_label="update parent children")
|
||||
|
||||
self._save_extended_session(session)
|
||||
return sid
|
||||
@@ -330,11 +382,15 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
metadata=copy.deepcopy(parent.metadata),
|
||||
)
|
||||
|
||||
# Update parent's children list
|
||||
parent.children_ids.append(new_id)
|
||||
|
||||
self._save_extended_session(forked)
|
||||
self._save_extended_session(parent)
|
||||
|
||||
def _register_fork(parent: SessionData) -> None:
|
||||
if new_id not in parent.children_ids:
|
||||
parent.children_ids.append(new_id)
|
||||
|
||||
self._modify_session_locked(
|
||||
session_id, _register_fork, error_label="register forked session"
|
||||
)
|
||||
|
||||
return new_id
|
||||
|
||||
@@ -385,18 +441,22 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
Returns:
|
||||
The snapshot ID
|
||||
"""
|
||||
# Force reload to get latest messages
|
||||
session = self._load_extended_session(session_id, force_reload=True)
|
||||
|
||||
snapshot = SessionSnapshot(
|
||||
session_id=session_id,
|
||||
message_index=len(session.messages) - 1 if session.messages else -1,
|
||||
message_index=-1, # Set from fresh session inside locked write
|
||||
label=label,
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
session.snapshots.append(snapshot)
|
||||
self._save_extended_session(session)
|
||||
|
||||
def _record_snapshot(session: SessionData) -> None:
|
||||
snapshot.message_index = (
|
||||
len(session.messages) - 1 if session.messages else -1
|
||||
)
|
||||
session.snapshots.append(snapshot)
|
||||
|
||||
self._modify_session_locked(
|
||||
session_id, _record_snapshot, error_label="create snapshot"
|
||||
)
|
||||
|
||||
return snapshot.id
|
||||
|
||||
@@ -416,26 +476,30 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
session = self._load_extended_session(session_id)
|
||||
|
||||
# Find the snapshot
|
||||
snapshot = None
|
||||
for s in session.snapshots:
|
||||
if s.id == snapshot_id:
|
||||
snapshot = s
|
||||
break
|
||||
|
||||
if snapshot is None:
|
||||
logger.warning(f"Snapshot {snapshot_id} not found")
|
||||
def _apply(session: SessionData) -> None:
|
||||
assert isinstance(session, ExtendedSessionData)
|
||||
|
||||
# Find the snapshot
|
||||
snapshot = None
|
||||
for s in session.snapshots:
|
||||
if s.id == snapshot_id:
|
||||
snapshot = s
|
||||
break
|
||||
|
||||
if snapshot is None:
|
||||
logger.warning(f"Snapshot {snapshot_id} not found")
|
||||
raise ValueError(f"Snapshot {snapshot_id} not found")
|
||||
|
||||
# Revert messages
|
||||
if snapshot.message_index >= 0:
|
||||
session.messages = session.messages[:snapshot.message_index + 1]
|
||||
else:
|
||||
session.messages = []
|
||||
|
||||
try:
|
||||
return self._modify_session_locked(session_id, _apply, error_label="revert to snapshot")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
# Revert messages
|
||||
if snapshot.message_index >= 0:
|
||||
session.messages = session.messages[:snapshot.message_index + 1]
|
||||
else:
|
||||
session.messages = []
|
||||
|
||||
return self._save_extended_session(session)
|
||||
|
||||
def revert_to_message(self, session_id: str, message_index: int) -> bool:
|
||||
"""
|
||||
@@ -448,27 +512,33 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
Returns:
|
||||
True if successful
|
||||
"""
|
||||
# Force reload to get latest messages
|
||||
session = self._load_extended_session(session_id, force_reload=True)
|
||||
|
||||
if message_index < 0 or message_index >= len(session.messages):
|
||||
logger.warning(f"Invalid message index {message_index}")
|
||||
def _apply(session: SessionData) -> None:
|
||||
assert isinstance(session, ExtendedSessionData)
|
||||
|
||||
if message_index < 0 or message_index >= len(session.messages):
|
||||
logger.warning(f"Invalid message index {message_index}")
|
||||
raise ValueError(f"Invalid message index {message_index}")
|
||||
|
||||
session.messages = session.messages[:message_index + 1]
|
||||
|
||||
try:
|
||||
return self._modify_session_locked(session_id, _apply, error_label="revert to message")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
session.messages = session.messages[:message_index + 1]
|
||||
return self._save_extended_session(session)
|
||||
|
||||
def share_session(self, session_id: str) -> bool:
|
||||
"""Mark a session as shared."""
|
||||
session = self._load_extended_session(session_id)
|
||||
session.is_shared = True
|
||||
return self._save_extended_session(session)
|
||||
def _apply(session: SessionData) -> None:
|
||||
assert isinstance(session, ExtendedSessionData)
|
||||
session.is_shared = True
|
||||
return self._modify_session_locked(session_id, _apply, error_label="share session")
|
||||
|
||||
def unshare_session(self, session_id: str) -> bool:
|
||||
"""Mark a session as not shared."""
|
||||
session = self._load_extended_session(session_id)
|
||||
session.is_shared = False
|
||||
return self._save_extended_session(session)
|
||||
def _apply(session: SessionData) -> None:
|
||||
assert isinstance(session, ExtendedSessionData)
|
||||
session.is_shared = False
|
||||
return self._modify_session_locked(session_id, _apply, error_label="unshare session")
|
||||
|
||||
def is_shared(self, session_id: str) -> bool:
|
||||
"""Check if a session is shared."""
|
||||
@@ -477,9 +547,10 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
|
||||
def set_title(self, session_id: str, title: str) -> bool:
|
||||
"""Set session title."""
|
||||
session = self._load_extended_session(session_id)
|
||||
session.title = title
|
||||
return self._save_extended_session(session)
|
||||
def _apply(session: SessionData) -> None:
|
||||
assert isinstance(session, ExtendedSessionData)
|
||||
session.title = title
|
||||
return self._modify_session_locked(session_id, _apply, error_label="set session title")
|
||||
|
||||
async def auto_title(self, session_id: str) -> bool:
|
||||
"""Generate and set title automatically from first exchange.
|
||||
@@ -534,12 +605,19 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
title = await generate_title_async(user_msg, assistant_msg)
|
||||
|
||||
if title and title.strip():
|
||||
# Reload session to avoid overwriting concurrent updates
|
||||
fresh_session = await asyncio.to_thread(self._load_extended_session, session_id)
|
||||
# Only set title if it's still empty
|
||||
if not fresh_session.title or not fresh_session.title.strip():
|
||||
fresh_session.title = title.strip()
|
||||
return await asyncio.to_thread(self._save_extended_session, fresh_session)
|
||||
# Use locked read-modify-write to avoid overwriting concurrent updates
|
||||
def _apply(fresh_session: SessionData) -> None:
|
||||
assert isinstance(fresh_session, ExtendedSessionData)
|
||||
# Only set title if it's still empty
|
||||
if not fresh_session.title or not fresh_session.title.strip():
|
||||
fresh_session.title = title.strip()
|
||||
|
||||
return await asyncio.to_thread(
|
||||
self._modify_session_locked,
|
||||
session_id,
|
||||
_apply,
|
||||
error_label="auto-title session"
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
# Title generation failed - log with context instead of silent failure
|
||||
@@ -551,7 +629,17 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
|
||||
def get_extended_session(self, session_id: str) -> ExtendedSessionData:
|
||||
"""Get extended session data."""
|
||||
return self._load_extended_session(session_id)
|
||||
return self._read_session_fresh(session_id)
|
||||
|
||||
def invalidate_cache(self, session_id: Optional[str] = None) -> None:
|
||||
"""Invalidate base and extended in-memory caches atomically."""
|
||||
with self._lock:
|
||||
if session_id:
|
||||
self._cache.pop(session_id, None)
|
||||
self._extended_cache.pop(session_id, None)
|
||||
else:
|
||||
self._cache.clear()
|
||||
self._extended_cache.clear()
|
||||
|
||||
def export_session(self, session_id: str) -> Dict[str, Any]:
|
||||
"""
|
||||
|
||||
@@ -129,3 +129,16 @@ class SessionStoreProtocol(Protocol):
|
||||
True if the session exists.
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class CheckpointQueryProtocol(Protocol):
|
||||
"""Read path for session checkpoints / rollback snapshots."""
|
||||
|
||||
def list_checkpoints(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
"""Return checkpoint metadata for a session."""
|
||||
...
|
||||
|
||||
def get_checkpoint(self, session_id: str, checkpoint_id: str) -> Optional[Dict[str, Any]]:
|
||||
"""Return a single checkpoint payload."""
|
||||
...
|
||||
@@ -15,14 +15,14 @@ import threading
|
||||
import time
|
||||
|
||||
# fcntl is Unix-only; on Windows, use msvcrt for file locking
|
||||
if sys.platform != 'win32':
|
||||
try:
|
||||
import fcntl
|
||||
_HAS_FCNTL = True
|
||||
else:
|
||||
except ImportError:
|
||||
_HAS_FCNTL = False
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from ..paths import get_sessions_dir
|
||||
|
||||
@@ -81,7 +81,7 @@ class SessionData:
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
return {
|
||||
data = {
|
||||
"session_id": self.session_id,
|
||||
"messages": [m.to_dict() for m in self.messages],
|
||||
"created_at": self.created_at,
|
||||
@@ -92,6 +92,10 @@ class SessionData:
|
||||
"gateway_session_id": self.gateway_session_id,
|
||||
"agent_id": self.agent_id,
|
||||
}
|
||||
for key in ("model", "llm", "total_tokens", "token_count", "cost", "source"):
|
||||
if key in self.metadata:
|
||||
data[key] = self.metadata[key]
|
||||
return data
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> "SessionData":
|
||||
@@ -137,7 +141,8 @@ class FileLock:
|
||||
self._lock_path = filepath + ".lock"
|
||||
|
||||
def __enter__(self):
|
||||
self.acquire()
|
||||
if not self.acquire():
|
||||
raise IOError(f"Failed to acquire file lock for {self.filepath} after {self.timeout}s")
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
@@ -162,11 +167,11 @@ class FileLock:
|
||||
if _HAS_FCNTL:
|
||||
fcntl.flock(self._lock_file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
|
||||
else:
|
||||
# Warn once about degraded locking (should never happen with current platform detection)
|
||||
# Warn once about degraded locking on non-Windows platforms without fcntl
|
||||
global _WARNED_NO_FCNTL
|
||||
if not _WARNED_NO_FCNTL:
|
||||
logger.warning(
|
||||
"File locking unavailable on this platform (no fcntl/msvcrt); "
|
||||
"File locking unavailable on this platform (fcntl not available); "
|
||||
"concurrent writers may corrupt session files."
|
||||
)
|
||||
_WARNED_NO_FCNTL = True
|
||||
@@ -260,32 +265,88 @@ class DefaultSessionStore:
|
||||
def _load_session(self, session_id: str) -> SessionData:
|
||||
"""Load session from disk with file locking."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
# Check cache first
|
||||
with self._lock:
|
||||
if session_id in self._cache:
|
||||
return self._cache[session_id]
|
||||
|
||||
# Load from disk
|
||||
if not os.path.exists(filepath):
|
||||
session = SessionData(session_id=session_id)
|
||||
|
||||
# When a session file exists, always reload under FileLock so reads
|
||||
# from another DefaultSessionStore instance (or process) are visible.
|
||||
if os.path.exists(filepath):
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
session = self._load_session_from_disk(session_id, filepath)
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
return session
|
||||
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
|
||||
with self._lock:
|
||||
if session_id in self._cache:
|
||||
return self._cache[session_id]
|
||||
session = SessionData(session_id=session_id)
|
||||
self._cache[session_id] = session
|
||||
return session
|
||||
|
||||
def _load_session_from_disk(self, session_id: str, filepath: str) -> SessionData:
|
||||
"""Load session JSON from disk (caller must hold FileLock)."""
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
session = SessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError) as e:
|
||||
logger.warning(f"Failed to load session {session_id}: {e}")
|
||||
session = SessionData(session_id=session_id)
|
||||
|
||||
return SessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return SessionData(session_id=session_id)
|
||||
|
||||
def _read_session_fresh(self, session_id: str) -> SessionData:
|
||||
"""Reload session from disk and refresh the in-process cache."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
session = self._load_session_from_disk(session_id, filepath)
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
|
||||
return session
|
||||
|
||||
def _modify_session_locked(
|
||||
self,
|
||||
session_id: str,
|
||||
mutator: Callable[[SessionData], None],
|
||||
*,
|
||||
error_label: str = "modify session",
|
||||
) -> bool:
|
||||
"""Apply mutator after reloading from disk under FileLock."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
session = self._load_session_from_disk(session_id, filepath)
|
||||
mutator(session)
|
||||
session.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
if len(session.messages) > self.max_messages:
|
||||
session.messages = session.messages[-self.max_messages:]
|
||||
|
||||
try:
|
||||
dir_path = os.path.dirname(filepath) or "."
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=dir_path,
|
||||
delete=False,
|
||||
suffix=".tmp",
|
||||
) as f:
|
||||
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
temp_path = f.name
|
||||
|
||||
os.replace(temp_path, filepath)
|
||||
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
logger.error(f"Failed to {error_label} {session_id}: {e}")
|
||||
try:
|
||||
if "temp_path" in locals():
|
||||
os.remove(temp_path)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
def _save_session(self, session: SessionData) -> bool:
|
||||
"""Save session to disk with atomic write."""
|
||||
@@ -435,13 +496,13 @@ class DefaultSessionStore:
|
||||
Returns:
|
||||
List of {"role": "user/assistant", "content": "..."} dicts.
|
||||
"""
|
||||
session = self._load_session(session_id)
|
||||
session = self._read_session_fresh(session_id)
|
||||
limit = max_messages or self.max_messages
|
||||
return session.get_chat_history(limit)
|
||||
|
||||
def get_session(self, session_id: str) -> SessionData:
|
||||
"""Get full session data."""
|
||||
return self._load_session(session_id)
|
||||
return self._read_session_fresh(session_id)
|
||||
|
||||
def set_agent_info(
|
||||
self,
|
||||
@@ -450,27 +511,65 @@ class DefaultSessionStore:
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Set agent info for a session."""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
|
||||
def _apply(session: SessionData) -> None:
|
||||
if agent_name:
|
||||
session.agent_name = agent_name
|
||||
if user_id:
|
||||
session.user_id = user_id
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="set agent info for session"
|
||||
)
|
||||
|
||||
def clear_session(self, session_id: str) -> bool:
|
||||
"""Clear all messages from a session."""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
return self._modify_session_locked(
|
||||
session_id,
|
||||
lambda session: session.messages.clear(),
|
||||
error_label="clear session",
|
||||
)
|
||||
|
||||
def set_chat_history(
|
||||
self,
|
||||
session_id: str,
|
||||
messages: List[Dict[str, str]],
|
||||
) -> bool:
|
||||
"""Replace session messages atomically (file-locked read-modify-write)."""
|
||||
|
||||
def _apply(session: SessionData) -> None:
|
||||
session.messages.clear()
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
|
||||
for msg in messages:
|
||||
session.messages.append(
|
||||
SessionMessage(
|
||||
role=msg.get("role", "user"),
|
||||
content=msg.get("content", ""),
|
||||
timestamp=msg.get("timestamp", time.time()),
|
||||
metadata=msg.get("metadata", {}),
|
||||
)
|
||||
)
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="set chat history"
|
||||
)
|
||||
|
||||
def update_session_metadata(self, session_id: str, **fields: Any) -> bool:
|
||||
"""Merge run stats / metadata fields into a persisted session."""
|
||||
if not fields:
|
||||
return True
|
||||
|
||||
def _apply(session: SessionData) -> None:
|
||||
for key, value in fields.items():
|
||||
if value is None:
|
||||
continue
|
||||
session.metadata[key] = value
|
||||
if key in ("agent_id", "agent_name", "user_id"):
|
||||
setattr(session, key, value)
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="update session metadata"
|
||||
)
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session completely."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
@@ -500,10 +599,16 @@ class DefaultSessionStore:
|
||||
data = json.load(f)
|
||||
sessions.append({
|
||||
"session_id": data.get("session_id", filename[:-5]),
|
||||
"id": data.get("session_id", filename[:-5]),
|
||||
"agent_name": data.get("agent_name"),
|
||||
"agent_id": data.get("agent_id") or (data.get("metadata") or {}).get("agent_id"),
|
||||
"source": data.get("source") or (data.get("metadata") or {}).get("source"),
|
||||
"created_at": data.get("created_at"),
|
||||
"updated_at": data.get("updated_at"),
|
||||
"message_count": len(data.get("messages", [])),
|
||||
"model": data.get("model") or data.get("llm") or (data.get("metadata") or {}).get("model"),
|
||||
"total_tokens": data.get("total_tokens") or data.get("token_count") or (data.get("metadata") or {}).get("total_tokens"),
|
||||
"cost": data.get("cost") or (data.get("metadata") or {}).get("cost"),
|
||||
})
|
||||
except (json.JSONDecodeError, IOError):
|
||||
continue
|
||||
@@ -563,7 +668,7 @@ class DefaultSessionStore:
|
||||
List of SessionData objects for the specified agent
|
||||
"""
|
||||
session_ids = self.list_sessions_by_agent(agent_name, limit)
|
||||
return [self._load_session(sid) for sid in session_ids]
|
||||
return [self._read_session_fresh(sid) for sid in session_ids]
|
||||
|
||||
def get_agent_chat_history(
|
||||
self,
|
||||
@@ -617,16 +722,15 @@ class DefaultSessionStore:
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
def _apply(session: SessionData) -> None:
|
||||
if gateway_session_id:
|
||||
session.gateway_session_id = gateway_session_id
|
||||
if agent_id:
|
||||
session.agent_id = agent_id
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="set gateway info for session"
|
||||
)
|
||||
|
||||
def get_by_gateway_session(self, gateway_session_id: str) -> Optional[SessionData]:
|
||||
"""Get session data linked to a gateway session.
|
||||
|
||||
@@ -19,7 +19,9 @@ Usage:
|
||||
__all__ = [
|
||||
# Models
|
||||
"SkillProperties",
|
||||
"SkillMetadata",
|
||||
"SkillMetadata",
|
||||
"SkillRequirements",
|
||||
"SkillState",
|
||||
"ParseError",
|
||||
"ValidationError",
|
||||
# Parser
|
||||
@@ -54,13 +56,17 @@ __all__ = [
|
||||
"SkillActivatedEvent",
|
||||
# Budget
|
||||
"SkillPromptBudget",
|
||||
# Capability validation
|
||||
"CapabilityValidator",
|
||||
"EnforcementLevel",
|
||||
"ValidationResult",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid import overhead."""
|
||||
if name in ("SkillProperties", "SkillMetadata", "ParseError", "ValidationError"):
|
||||
from .models import SkillProperties, SkillMetadata, ParseError, ValidationError
|
||||
if name in ("SkillProperties", "SkillMetadata", "SkillRequirements", "SkillState", "ParseError", "ValidationError"):
|
||||
from .models import SkillProperties, SkillMetadata, SkillRequirements, SkillState, ParseError, ValidationError
|
||||
return locals()[name]
|
||||
|
||||
if name in ("parse_frontmatter", "find_skill_md", "read_properties"):
|
||||
@@ -130,5 +136,9 @@ def __getattr__(name: str):
|
||||
return None
|
||||
|
||||
return load_skill
|
||||
|
||||
if name in ("CapabilityValidator", "EnforcementLevel", "ValidationResult"):
|
||||
from .capability_validator import CapabilityValidator, EnforcementLevel, ValidationResult
|
||||
return locals()[name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Capability validation for Agent Skills.
|
||||
|
||||
This module implements the capability gate enforcement system for skills,
|
||||
checking that required tools, servers, and environment variables are available
|
||||
before allowing skill activation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from enum import Enum
|
||||
|
||||
from .models import SkillProperties, SkillRequirements, SkillState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnforcementLevel(Enum):
|
||||
"""Enforcement level for capability gates."""
|
||||
DISABLED = "disabled" # No enforcement (existing behavior)
|
||||
TELEMETRY = "telemetry" # Log only, no blocking
|
||||
WARN = "warn" # Warning but allow activation
|
||||
STRICT = "strict" # Hard failure, block activation
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of capability validation for a skill."""
|
||||
skill_name: str
|
||||
state: SkillState
|
||||
satisfied_tools: List[str]
|
||||
missing_tools: List[str]
|
||||
satisfied_servers: List[str]
|
||||
missing_servers: List[str]
|
||||
satisfied_env_vars: List[str]
|
||||
missing_env_vars: List[str]
|
||||
warnings: List[str]
|
||||
errors: List[str]
|
||||
|
||||
@property
|
||||
def is_fully_satisfied(self) -> bool:
|
||||
"""Check if all requirements are satisfied."""
|
||||
return not any([
|
||||
self.missing_tools,
|
||||
self.missing_servers,
|
||||
self.missing_env_vars,
|
||||
self.errors
|
||||
])
|
||||
|
||||
@property
|
||||
def has_critical_missing(self) -> bool:
|
||||
"""Check if critical requirements are missing (tools, servers)."""
|
||||
return bool(self.missing_tools or self.missing_servers)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"skill_name": self.skill_name,
|
||||
"state": self.state.value,
|
||||
"satisfied_tools": self.satisfied_tools,
|
||||
"missing_tools": self.missing_tools,
|
||||
"satisfied_servers": self.satisfied_servers,
|
||||
"missing_servers": self.missing_servers,
|
||||
"satisfied_env_vars": self.satisfied_env_vars,
|
||||
"missing_env_vars": self.missing_env_vars,
|
||||
"warnings": self.warnings,
|
||||
"errors": self.errors,
|
||||
"is_fully_satisfied": self.is_fully_satisfied,
|
||||
"has_critical_missing": self.has_critical_missing
|
||||
}
|
||||
|
||||
|
||||
class CapabilityValidator:
|
||||
"""Validates skill requirements against available capabilities.
|
||||
|
||||
This is the core capability gate enforcement engine. It checks skill
|
||||
requirements against the runtime context (available tools, servers, etc.)
|
||||
and determines whether a skill should be activated based on policy.
|
||||
"""
|
||||
|
||||
def __init__(self, enforcement_level: EnforcementLevel = EnforcementLevel.WARN):
|
||||
"""Initialize the validator.
|
||||
|
||||
Args:
|
||||
enforcement_level: How strictly to enforce requirements
|
||||
"""
|
||||
self.enforcement_level = enforcement_level
|
||||
self._tool_cache: Optional[Set[str]] = None
|
||||
self._server_cache: Optional[Set[str]] = None
|
||||
|
||||
def validate_skill(
|
||||
self,
|
||||
skill: SkillProperties,
|
||||
available_tools: Optional[Set[str]] = None,
|
||||
available_servers: Optional[Set[str]] = None
|
||||
) -> ValidationResult:
|
||||
"""Validate a skill's requirements against available capabilities.
|
||||
|
||||
Args:
|
||||
skill: Skill to validate
|
||||
available_tools: Set of available tool names (auto-detected if None)
|
||||
available_servers: Set of available server names (auto-detected if None)
|
||||
|
||||
Returns:
|
||||
ValidationResult with validation status and details
|
||||
"""
|
||||
if not skill.requirements or skill.requirements.is_empty():
|
||||
# No requirements specified - skill is always available
|
||||
return ValidationResult(
|
||||
skill_name=skill.name,
|
||||
state=SkillState.ACTIVE,
|
||||
satisfied_tools=[],
|
||||
missing_tools=[],
|
||||
satisfied_servers=[],
|
||||
missing_servers=[],
|
||||
satisfied_env_vars=[],
|
||||
missing_env_vars=[],
|
||||
warnings=[],
|
||||
errors=[]
|
||||
)
|
||||
|
||||
# Get available capabilities
|
||||
if available_tools is None:
|
||||
available_tools = self._get_available_tools()
|
||||
if available_servers is None:
|
||||
available_servers = self._get_available_servers()
|
||||
|
||||
requirements = skill.requirements
|
||||
|
||||
# Validate tools
|
||||
satisfied_tools = [tool for tool in requirements.tools if tool in available_tools]
|
||||
missing_tools = [tool for tool in requirements.tools if tool not in available_tools]
|
||||
|
||||
# Validate servers
|
||||
satisfied_servers = [srv for srv in requirements.servers if srv in available_servers]
|
||||
missing_servers = [srv for srv in requirements.servers if srv not in available_servers]
|
||||
|
||||
# Validate environment variables
|
||||
satisfied_env_vars = [var for var in requirements.env_vars if os.getenv(var)]
|
||||
missing_env_vars = [var for var in requirements.env_vars if not os.getenv(var)]
|
||||
|
||||
# Determine warnings and errors
|
||||
warnings = []
|
||||
errors = []
|
||||
|
||||
if missing_tools:
|
||||
msg = f"Missing required tools: {', '.join(missing_tools)}"
|
||||
if self.enforcement_level in (EnforcementLevel.STRICT,):
|
||||
errors.append(msg)
|
||||
else:
|
||||
warnings.append(msg)
|
||||
|
||||
if missing_servers:
|
||||
msg = f"Missing required servers: {', '.join(missing_servers)}"
|
||||
if self.enforcement_level in (EnforcementLevel.STRICT,):
|
||||
errors.append(msg)
|
||||
else:
|
||||
warnings.append(msg)
|
||||
|
||||
if missing_env_vars:
|
||||
msg = f"Missing required environment variables: {', '.join(missing_env_vars)}"
|
||||
if self.enforcement_level == EnforcementLevel.STRICT:
|
||||
errors.append(msg)
|
||||
else:
|
||||
warnings.append(msg)
|
||||
|
||||
# Determine skill state
|
||||
if errors:
|
||||
state = SkillState.UNAVAILABLE
|
||||
elif warnings:
|
||||
state = SkillState.DEGRADED
|
||||
else:
|
||||
state = SkillState.ACTIVE
|
||||
|
||||
result = ValidationResult(
|
||||
skill_name=skill.name,
|
||||
state=state,
|
||||
satisfied_tools=satisfied_tools,
|
||||
missing_tools=missing_tools,
|
||||
satisfied_servers=satisfied_servers,
|
||||
missing_servers=missing_servers,
|
||||
satisfied_env_vars=satisfied_env_vars,
|
||||
missing_env_vars=missing_env_vars,
|
||||
warnings=warnings,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
# Log based on enforcement level
|
||||
self._log_validation_result(result)
|
||||
|
||||
return result
|
||||
|
||||
def _get_available_tools(self) -> Set[str]:
|
||||
"""Get set of available tool names from registry."""
|
||||
if self._tool_cache is None:
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
registry = get_registry()
|
||||
self._tool_cache = set(registry.list_tools())
|
||||
except ImportError:
|
||||
logger.debug("Tool registry not available")
|
||||
self._tool_cache = set()
|
||||
return self._tool_cache
|
||||
|
||||
def _get_available_servers(self) -> Set[str]:
|
||||
"""Get set of available MCP server names."""
|
||||
if self._server_cache is None:
|
||||
# TODO: Implement MCP server discovery
|
||||
# For now, return empty set - this can be extended later
|
||||
self._server_cache = set()
|
||||
return self._server_cache
|
||||
|
||||
def _log_validation_result(self, result: ValidationResult) -> None:
|
||||
"""Log validation result based on enforcement level."""
|
||||
if self.enforcement_level == EnforcementLevel.DISABLED:
|
||||
return
|
||||
|
||||
skill_name = result.skill_name
|
||||
|
||||
if result.state == SkillState.UNAVAILABLE:
|
||||
if self.enforcement_level == EnforcementLevel.STRICT:
|
||||
logger.error(f"Skill '{skill_name}' blocked: {'; '.join(result.errors)}")
|
||||
else:
|
||||
logger.warning(f"Skill '{skill_name}' has critical missing dependencies: {'; '.join(result.errors)}")
|
||||
|
||||
elif result.state == SkillState.DEGRADED:
|
||||
logger.warning(f"Skill '{skill_name}' partially available: {'; '.join(result.warnings)}")
|
||||
|
||||
elif result.state == SkillState.ACTIVE:
|
||||
logger.debug(f"Skill '{skill_name}' requirements satisfied")
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear cached capability information."""
|
||||
self._tool_cache = None
|
||||
self._server_cache = None
|
||||
@@ -108,7 +108,7 @@ class SkillLoader:
|
||||
return False
|
||||
|
||||
try:
|
||||
content = skill_md.read_text()
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
_, body = parse_frontmatter(content)
|
||||
skill.instructions = body
|
||||
return True
|
||||
@@ -136,7 +136,7 @@ class SkillLoader:
|
||||
for script_file in scripts_dir.iterdir():
|
||||
if script_file.is_file():
|
||||
try:
|
||||
scripts[script_file.name] = script_file.read_text()
|
||||
scripts[script_file.name] = script_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
except PermissionError:
|
||||
@@ -166,7 +166,7 @@ class SkillLoader:
|
||||
for ref_file in refs_dir.iterdir():
|
||||
if ref_file.is_file():
|
||||
try:
|
||||
refs[ref_file.name] = ref_file.read_text()
|
||||
refs[ref_file.name] = ref_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
except PermissionError:
|
||||
|
||||
@@ -5,12 +5,13 @@ from typing import List, Optional, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import SkillMetadata
|
||||
from .models import SkillMetadata, SkillState
|
||||
from .discovery import discover_skills, get_default_skill_dirs
|
||||
from .loader import SkillLoader, LoadedSkill
|
||||
from .prompt import generate_skills_xml
|
||||
from .substitution import render_skill_body
|
||||
from .shell_render import render_shell_blocks
|
||||
from .capability_validator import CapabilityValidator, EnforcementLevel, ValidationResult
|
||||
|
||||
|
||||
class SkillManager:
|
||||
@@ -34,11 +35,21 @@ class SkillManager:
|
||||
manager.activate(skill)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the SkillManager."""
|
||||
def __init__(self, enforcement_level: Optional[EnforcementLevel] = None):
|
||||
"""Initialize the SkillManager.
|
||||
|
||||
Args:
|
||||
enforcement_level: Capability enforcement level (defaults to environment-based)
|
||||
"""
|
||||
self._skills: Dict[str, LoadedSkill] = {}
|
||||
self._loader = SkillLoader()
|
||||
self._discovered = False
|
||||
self._validation_cache: Dict[str, ValidationResult] = {}
|
||||
|
||||
# Initialize capability validator
|
||||
if enforcement_level is None:
|
||||
enforcement_level = self._get_default_enforcement_level()
|
||||
self._validator = CapabilityValidator(enforcement_level)
|
||||
|
||||
@property
|
||||
def skills(self) -> List[LoadedSkill]:
|
||||
@@ -126,15 +137,29 @@ class SkillManager:
|
||||
def get_available_skills(self) -> List[SkillMetadata]:
|
||||
"""Get metadata for all available skills.
|
||||
|
||||
This is used for system prompt injection. Skills with
|
||||
``disable-model-invocation: true`` are omitted so the LLM never
|
||||
sees them and cannot auto-trigger them.
|
||||
This is used for system prompt injection. Skills are filtered by:
|
||||
1. disable-model-invocation: true (excluded)
|
||||
2. Capability enforcement (if strict mode, exclude UNAVAILABLE skills)
|
||||
"""
|
||||
return [
|
||||
skill.metadata
|
||||
for skill in self._skills.values()
|
||||
if not getattr(skill.properties, "disable_model_invocation", False)
|
||||
]
|
||||
available = []
|
||||
for skill in self._skills.values():
|
||||
# Skip if explicitly disabled for model invocation
|
||||
if getattr(skill.properties, "disable_model_invocation", False):
|
||||
continue
|
||||
|
||||
# Check capability requirements if enforcement is strict
|
||||
if self._validator.enforcement_level == EnforcementLevel.STRICT:
|
||||
try:
|
||||
validation = self.validate_skill_capabilities(skill.properties.name)
|
||||
if validation.state == SkillState.UNAVAILABLE:
|
||||
continue # Skip unavailable skills in strict mode
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping skill '{skill.properties.name}' due to validation error: {e}")
|
||||
continue
|
||||
|
||||
available.append(skill.metadata)
|
||||
|
||||
return available
|
||||
|
||||
def get_user_invocable_skills(self) -> List[LoadedSkill]:
|
||||
"""Return skills the *user* may invoke via slash-commands.
|
||||
@@ -566,9 +591,90 @@ author: agent
|
||||
logger.debug("Failed to clean up temp file %s", temp_path, exc_info=True)
|
||||
raise
|
||||
|
||||
def validate_skill_capabilities(self, skill_name: str, force_refresh: bool = False) -> ValidationResult:
|
||||
"""Validate a skill's capability requirements.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the skill to validate
|
||||
force_refresh: If True, bypass cache and re-validate
|
||||
|
||||
Returns:
|
||||
ValidationResult with capability status
|
||||
|
||||
Raises:
|
||||
ValueError: If skill not found
|
||||
"""
|
||||
skill = self.get_skill(skill_name)
|
||||
if skill is None:
|
||||
raise ValueError(f"Skill '{skill_name}' not found")
|
||||
|
||||
# Check cache first
|
||||
if not force_refresh and skill_name in self._validation_cache:
|
||||
return self._validation_cache[skill_name]
|
||||
|
||||
# Perform validation
|
||||
result = self._validator.validate_skill(skill.properties)
|
||||
self._validation_cache[skill_name] = result
|
||||
return result
|
||||
|
||||
def get_available_skills_by_state(self, state: SkillState) -> List[LoadedSkill]:
|
||||
"""Get skills filtered by their capability validation state.
|
||||
|
||||
Args:
|
||||
state: Desired skill state
|
||||
|
||||
Returns:
|
||||
List of skills in the specified state
|
||||
"""
|
||||
matching = []
|
||||
for skill in self._skills.values():
|
||||
try:
|
||||
result = self.validate_skill_capabilities(skill.properties.name)
|
||||
if result.state == state:
|
||||
matching.append(skill)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to validate skill '{skill.properties.name}': {e}")
|
||||
continue
|
||||
return matching
|
||||
|
||||
def get_skills_diagnostics(self) -> Dict[str, ValidationResult]:
|
||||
"""Get capability diagnostics for all skills.
|
||||
|
||||
Returns:
|
||||
Dict mapping skill names to their validation results
|
||||
"""
|
||||
diagnostics = {}
|
||||
for skill_name in self.skill_names:
|
||||
try:
|
||||
diagnostics[skill_name] = self.validate_skill_capabilities(skill_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to validate skill '{skill_name}': {e}")
|
||||
return diagnostics
|
||||
|
||||
def _get_default_enforcement_level(self) -> EnforcementLevel:
|
||||
"""Get default enforcement level from environment."""
|
||||
import os
|
||||
level_str = os.getenv('SKILL_CAPABILITY_ENFORCEMENT', 'warn').lower()
|
||||
|
||||
level_map = {
|
||||
'disabled': EnforcementLevel.DISABLED,
|
||||
'off': EnforcementLevel.DISABLED,
|
||||
'telemetry': EnforcementLevel.TELEMETRY,
|
||||
'log': EnforcementLevel.TELEMETRY,
|
||||
'warn': EnforcementLevel.WARN,
|
||||
'warning': EnforcementLevel.WARN,
|
||||
'strict': EnforcementLevel.STRICT,
|
||||
'hard': EnforcementLevel.STRICT,
|
||||
'fail': EnforcementLevel.STRICT,
|
||||
}
|
||||
|
||||
return level_map.get(level_str, EnforcementLevel.WARN)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all loaded skills."""
|
||||
self._skills.clear()
|
||||
self._validation_cache.clear()
|
||||
self._validator.clear_cache()
|
||||
self._discovered = False
|
||||
|
||||
def __len__(self) -> int:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Data models for Agent Skills."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Union
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
@@ -15,6 +16,105 @@ class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SkillState(Enum):
|
||||
"""Skill activation state based on requirement validation."""
|
||||
ACTIVE = "active" # All requirements satisfied
|
||||
DEGRADED = "degraded" # Some requirements missing (soft warn)
|
||||
UNAVAILABLE = "unavailable" # Critical requirements missing (hard fail)
|
||||
UNKNOWN = "unknown" # Requirements not yet validated
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillRequirements:
|
||||
"""Capability requirements for a skill.
|
||||
|
||||
This represents the parsed and normalized requirements from skill frontmatter.
|
||||
Supports both Hermes/OpenClaw conventions and PraisonAI-specific extensions.
|
||||
|
||||
Attributes:
|
||||
servers: Required MCP servers or service endpoints
|
||||
tools: Required tool names (from tool registry)
|
||||
env_vars: Required environment variables (use sparingly)
|
||||
openclaw_hints: OpenClaw-specific metadata (passthrough)
|
||||
"""
|
||||
servers: List[str] = field(default_factory=list)
|
||||
tools: List[str] = field(default_factory=list)
|
||||
env_vars: List[str] = field(default_factory=list)
|
||||
openclaw_hints: dict = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_frontmatter(cls, metadata: dict) -> "SkillRequirements":
|
||||
"""Parse requirements from skill frontmatter.
|
||||
|
||||
Normalizes various naming conventions:
|
||||
- requires_servers, requires_tools (Hermes/OpenClaw style)
|
||||
- allowed-tools (existing PraisonAI convention)
|
||||
- openclaw (OpenClaw-specific hints)
|
||||
|
||||
Args:
|
||||
metadata: Raw frontmatter dict
|
||||
|
||||
Returns:
|
||||
SkillRequirements instance
|
||||
"""
|
||||
servers = []
|
||||
tools = []
|
||||
env_vars = []
|
||||
openclaw_hints = {}
|
||||
|
||||
# Parse servers
|
||||
if "requires_servers" in metadata:
|
||||
servers = cls._normalize_list(metadata["requires_servers"])
|
||||
elif "requires-servers" in metadata:
|
||||
servers = cls._normalize_list(metadata["requires-servers"])
|
||||
|
||||
# Parse tools (multiple naming conventions)
|
||||
if "requires_tools" in metadata:
|
||||
tools.extend(cls._normalize_list(metadata["requires_tools"]))
|
||||
elif "requires-tools" in metadata:
|
||||
tools.extend(cls._normalize_list(metadata["requires-tools"]))
|
||||
|
||||
# Support existing allowed-tools convention for backward compatibility
|
||||
if "allowed-tools" in metadata:
|
||||
tools.extend(cls._normalize_list(metadata["allowed-tools"]))
|
||||
|
||||
# Parse environment variables
|
||||
if "requires_env" in metadata:
|
||||
env_vars = cls._normalize_list(metadata["requires_env"])
|
||||
elif "requires-env" in metadata:
|
||||
env_vars = cls._normalize_list(metadata["requires-env"])
|
||||
|
||||
# OpenClaw hints (passthrough)
|
||||
if "openclaw" in metadata:
|
||||
openclaw_hints = metadata["openclaw"] if isinstance(metadata["openclaw"], dict) else {}
|
||||
|
||||
return cls(
|
||||
servers=servers,
|
||||
tools=tools,
|
||||
env_vars=env_vars,
|
||||
openclaw_hints=openclaw_hints
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_list(value: Union[str, List[str]]) -> List[str]:
|
||||
"""Normalize string or list to list of strings."""
|
||||
if isinstance(value, str):
|
||||
# Support both comma-separated and space-separated strings
|
||||
return [item.strip() for item in value.replace(',', ' ').split() if item.strip()]
|
||||
elif isinstance(value, (list, tuple)):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
else:
|
||||
return []
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""Check if no requirements are specified."""
|
||||
return not any([self.servers, self.tools, self.env_vars, self.openclaw_hints])
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Truth value: True if any requirements are specified."""
|
||||
return not self.is_empty()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillProperties:
|
||||
"""Properties parsed from a skill's SKILL.md frontmatter.
|
||||
@@ -49,6 +149,8 @@ class SkillProperties:
|
||||
hooks: Optional[dict] = None
|
||||
paths: Optional[object] = None # list[str] glob patterns
|
||||
shell: Optional[str] = None # "bash" | "powershell"
|
||||
# Capability requirements (new)
|
||||
requirements: Optional[SkillRequirements] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary, excluding None values and empty metadata."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .models import ParseError, ValidationError, SkillProperties
|
||||
from .models import ParseError, ValidationError, SkillProperties, SkillRequirements
|
||||
|
||||
|
||||
def find_skill_md(skill_dir: Path) -> Optional[Path]:
|
||||
@@ -100,7 +100,7 @@ def read_properties(skill_dir: Path) -> SkillProperties:
|
||||
if skill_md is None:
|
||||
raise ParseError(f"SKILL.md not found in {skill_dir}")
|
||||
|
||||
content = skill_md.read_text()
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
metadata, _ = parse_frontmatter(content)
|
||||
|
||||
if "name" not in metadata:
|
||||
@@ -143,4 +143,6 @@ def read_properties(skill_dir: Path) -> SkillProperties:
|
||||
hooks=metadata.get("hooks") if isinstance(metadata.get("hooks"), dict) else None,
|
||||
paths=metadata.get("paths"),
|
||||
shell=metadata.get("shell"),
|
||||
# Parse capability requirements
|
||||
requirements=SkillRequirements.from_frontmatter(metadata),
|
||||
)
|
||||
@@ -7,7 +7,7 @@ plugin bundle, enterprise policy) without touching the core.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Protocol, Iterable, Optional, runtime_checkable
|
||||
from typing import Protocol, Iterable, Optional, runtime_checkable, List, Dict, Any
|
||||
|
||||
from .models import SkillProperties
|
||||
|
||||
@@ -45,7 +45,8 @@ class SkillMutatorProtocol(Protocol):
|
||||
"""Agent-managed skill CRUD operations.
|
||||
|
||||
Allows agents to create, edit, and manage their own skills at runtime.
|
||||
Implementations should provide safe-by-default behavior (e.g., propose mode).
|
||||
Implementations should provide safe-by-default behaviour: use ``propose=True``
|
||||
(default) to stage mutations for human approval before writing to disk.
|
||||
"""
|
||||
|
||||
def create(self, name: str, content: str, category: Optional[str] = None,
|
||||
@@ -163,3 +164,30 @@ class SkillMutatorProtocol(Protocol):
|
||||
Status message describing the action taken
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class SkillsCatalogProtocol(Protocol):
|
||||
"""Metadata listing for skills — UI catalog pages."""
|
||||
|
||||
def list_skills(self) -> List[Dict[str, Any]]:
|
||||
"""Return skill metadata dicts (name, description, location)."""
|
||||
...
|
||||
|
||||
|
||||
def list_skills_for_api() -> List[Dict[str, Any]]:
|
||||
"""Default catalog adapter using SkillManager when available."""
|
||||
try:
|
||||
from .manager import SkillManager
|
||||
|
||||
manager = SkillManager()
|
||||
return [
|
||||
{
|
||||
"name": s.name,
|
||||
"description": s.description,
|
||||
"location": getattr(s, "location", ""),
|
||||
}
|
||||
for s in manager.get_available_skills()
|
||||
]
|
||||
except Exception:
|
||||
return []
|
||||
@@ -191,9 +191,11 @@ def validate(skill_dir: Path, strict: bool = False) -> List[str]:
|
||||
return ["Missing required file: SKILL.md"]
|
||||
|
||||
try:
|
||||
content = skill_md.read_text()
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
metadata, _ = parse_frontmatter(content)
|
||||
except ParseError as e:
|
||||
return [str(e)]
|
||||
except UnicodeDecodeError as e:
|
||||
return [f"SKILL.md is not valid UTF-8: {e}"]
|
||||
|
||||
return validate_metadata(metadata, skill_dir, strict=strict)
|
||||
@@ -100,3 +100,107 @@ class InMemoryTokenUsageSink:
|
||||
def clear(self) -> None:
|
||||
"""Clear all records."""
|
||||
self.records.clear()
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class UsageQueryProtocol(Protocol):
|
||||
"""Read path for token usage — UI dashboards and analytics."""
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
"""Aggregate totals and breakdowns by model/agent."""
|
||||
...
|
||||
|
||||
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
"""Recent usage records, newest last."""
|
||||
...
|
||||
|
||||
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
|
||||
...
|
||||
|
||||
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
|
||||
...
|
||||
|
||||
|
||||
class InMemoryUsageQuery:
|
||||
"""Query adapter backed by InMemoryTokenUsageSink records."""
|
||||
|
||||
def __init__(self, sink: InMemoryTokenUsageSink):
|
||||
self._sink = sink
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
records = self._sink.records
|
||||
total_in = 0
|
||||
total_out = 0
|
||||
total_all = 0
|
||||
by_model: Dict[str, Dict[str, int]] = {}
|
||||
by_agent: Dict[str, Dict[str, int]] = {}
|
||||
|
||||
# Single-pass optimization: calculate everything in one loop
|
||||
for r in records:
|
||||
r_in = r.get("input_tokens", 0)
|
||||
r_out = r.get("output_tokens", 0)
|
||||
r_total = r.get("total_tokens", r_in + r_out)
|
||||
|
||||
total_in += r_in
|
||||
total_out += r_out
|
||||
total_all += r_total
|
||||
|
||||
model = r.get("model", "unknown")
|
||||
agent = r.get("agent_name", "unknown")
|
||||
|
||||
# Update by_model and by_agent efficiently
|
||||
for bucket, key in ((by_model, model), (by_agent, agent)):
|
||||
stats = bucket.setdefault(key, {"input_tokens": 0, "output_tokens": 0, "total_tokens": 0})
|
||||
stats["input_tokens"] += r_in
|
||||
stats["output_tokens"] += r_out
|
||||
stats["total_tokens"] += r_total
|
||||
return {
|
||||
"total_requests": len(records),
|
||||
"total_input_tokens": total_in,
|
||||
"total_output_tokens": total_out,
|
||||
"total_tokens": total_all, # Use consistent calculation
|
||||
"by_model": by_model,
|
||||
"by_agent": by_agent,
|
||||
}
|
||||
|
||||
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
return self._sink.records[-limit:]
|
||||
|
||||
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
|
||||
return self._sink.get_by_task(task_id)
|
||||
|
||||
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
|
||||
return self._sink.get_by_agent(agent_name)
|
||||
|
||||
|
||||
class TokenCollectorUsageQuery:
|
||||
"""Query adapter reading from the global TokenCollector + optional sink."""
|
||||
|
||||
def __init__(self, collector: Any):
|
||||
self._collector = collector
|
||||
|
||||
def get_summary(self) -> Dict[str, Any]:
|
||||
summary = self._collector.get_session_summary()
|
||||
return {
|
||||
"total_requests": summary.get("total_interactions", 0),
|
||||
"total_input_tokens": summary.get("total_metrics", {}).get("input_tokens", 0),
|
||||
"total_output_tokens": summary.get("total_metrics", {}).get("output_tokens", 0),
|
||||
"total_tokens": summary.get("total_tokens", 0),
|
||||
"by_model": summary.get("by_model", {}),
|
||||
"by_agent": summary.get("by_agent", {}),
|
||||
}
|
||||
|
||||
def list_recent(self, limit: int = 50) -> List[Dict[str, Any]]:
|
||||
return self._collector.get_recent_interactions(limit)
|
||||
|
||||
def get_by_task(self, task_id: str) -> List[Dict[str, Any]]:
|
||||
sink = getattr(self._collector, "_sink", None)
|
||||
if sink and hasattr(sink, "get_by_task"):
|
||||
return sink.get_by_task(task_id)
|
||||
return []
|
||||
|
||||
def get_by_agent(self, agent_name: str) -> List[Dict[str, Any]]:
|
||||
sink = getattr(self._collector, "_sink", None)
|
||||
if sink and hasattr(sink, "get_by_agent"):
|
||||
return sink.get_by_agent(agent_name)
|
||||
return []
|
||||
@@ -191,4 +191,9 @@ class TokenCollector:
|
||||
|
||||
|
||||
# Global token collector instance
|
||||
_token_collector = TokenCollector()
|
||||
_token_collector = TokenCollector()
|
||||
|
||||
|
||||
def get_token_collector() -> TokenCollector:
|
||||
"""Return the global TokenCollector singleton."""
|
||||
return _token_collector
|
||||
@@ -7,7 +7,7 @@ import logging
|
||||
from praisonaiagents._logging import get_logger
|
||||
|
||||
# Import dependencies
|
||||
from .token_collector import _token_collector, TokenMetrics
|
||||
from .token_collector import get_token_collector, TokenMetrics
|
||||
from .telemetry import get_telemetry
|
||||
|
||||
logger = get_logger(__name__)
|
||||
@@ -56,11 +56,12 @@ class TokenTelemetryBridge:
|
||||
|
||||
def export_token_metrics(self) -> Dict[str, Any]:
|
||||
"""Export token metrics for telemetry reporting."""
|
||||
if not _token_collector:
|
||||
collector = get_token_collector()
|
||||
if not collector:
|
||||
return {}
|
||||
|
||||
try:
|
||||
metrics = _token_collector.export_metrics()
|
||||
metrics = collector.export_metrics()
|
||||
|
||||
# Prepare telemetry-friendly format
|
||||
return {
|
||||
@@ -77,8 +78,7 @@ class TokenTelemetryBridge:
|
||||
|
||||
def reset_token_metrics(self):
|
||||
"""Reset token metrics collection."""
|
||||
if _token_collector:
|
||||
_token_collector.reset()
|
||||
get_token_collector().reset()
|
||||
|
||||
# Global telemetry bridge instance
|
||||
_token_telemetry_bridge = TokenTelemetryBridge()
|
||||
|
||||
@@ -14,10 +14,31 @@ import json
|
||||
from typing import Any, Dict, List, Union
|
||||
|
||||
from praisonaiagents.tools.decorator import tool
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
|
||||
def _unwrap_messages(messages: Union[List[Dict[str, Any]], Dict[str, Any], str]) -> List[Dict[str, Any]]:
|
||||
"""Minimal unwrap for common LLM arg shapes (not full UI normalisation)."""
|
||||
if isinstance(messages, str):
|
||||
try:
|
||||
messages = json.loads(messages)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"messages JSON string is not valid JSON: {exc}") from exc
|
||||
|
||||
if isinstance(messages, dict):
|
||||
if "messages" in messages:
|
||||
messages = messages["messages"]
|
||||
else:
|
||||
messages = [messages]
|
||||
|
||||
if not isinstance(messages, list):
|
||||
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
|
||||
|
||||
return messages
|
||||
|
||||
|
||||
@tool
|
||||
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str, Any]:
|
||||
def send_a2ui_messages(messages: Union[List[Dict[str, Any]], Dict[str, Any], str]) -> Dict[str, Any]:
|
||||
"""
|
||||
Send validated A2UI JSON messages to a connected A2UI renderer client.
|
||||
|
||||
@@ -32,21 +53,14 @@ def send_a2ui_messages(messages: Union[List[Dict[str, Any]], str]) -> Dict[str,
|
||||
"""
|
||||
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part
|
||||
|
||||
if isinstance(messages, str):
|
||||
try:
|
||||
messages = json.loads(messages)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(f"messages JSON string is not valid JSON: {exc}") from exc
|
||||
|
||||
if not isinstance(messages, list):
|
||||
raise ValueError("messages must be a list of A2UI message dicts or a JSON string")
|
||||
messages = _unwrap_messages(messages)
|
||||
|
||||
part = create_a2ui_part({"messages": messages})
|
||||
data = getattr(getattr(part, "root", part), "data", messages)
|
||||
metadata = getattr(getattr(part, "root", part), "metadata", {}) or {}
|
||||
|
||||
return {
|
||||
"mime_type": metadata.get("mimeType", "application/json+a2ui"),
|
||||
"mime_type": metadata.get("mimeType", A2UI_MIME_TYPE),
|
||||
"messages": messages,
|
||||
"a2ui_part": data,
|
||||
}
|
||||
@@ -15,7 +15,7 @@ Usage:
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional, Type, get_type_hints
|
||||
from typing import Any, Dict, List, Optional, Type, get_type_hints
|
||||
import inspect
|
||||
import logging
|
||||
|
||||
@@ -234,18 +234,81 @@ class BaseTool(ABC):
|
||||
if getattr(self.run, '__isabstractmethod__', False):
|
||||
errors.append("Tool must implement the 'run()' method")
|
||||
|
||||
# Check parameters schema is valid
|
||||
# Check parameters schema is valid and OpenAI-compatible
|
||||
if self.parameters:
|
||||
if not isinstance(self.parameters, dict):
|
||||
errors.append("'parameters' must be a dictionary")
|
||||
elif "type" not in self.parameters:
|
||||
errors.append("'parameters' must have a 'type' field")
|
||||
elif "properties" not in self.parameters:
|
||||
errors.append("'parameters' must have a 'properties' field for OpenAI compatibility")
|
||||
|
||||
# Validate schema structure for OpenAI compatibility
|
||||
try:
|
||||
schema = self.get_schema()
|
||||
if not isinstance(schema, dict):
|
||||
errors.append("get_schema() must return a dictionary")
|
||||
elif schema.get("type") != "function":
|
||||
errors.append("get_schema() must return schema with type='function'")
|
||||
elif "function" not in schema:
|
||||
errors.append("get_schema() must have 'function' key")
|
||||
else:
|
||||
func = schema["function"]
|
||||
if not isinstance(func.get("parameters"), dict):
|
||||
errors.append("Schema function.parameters must be a dictionary")
|
||||
elif "properties" not in func["parameters"]:
|
||||
errors.append("Schema function.parameters must have 'properties' for OpenAI compatibility")
|
||||
except Exception as e:
|
||||
errors.append(f"Schema generation failed: {e}")
|
||||
|
||||
if errors:
|
||||
raise ToolValidationError(f"Tool '{self.name}' validation failed: {'; '.join(errors)}")
|
||||
|
||||
return True
|
||||
|
||||
def validate_schema_roundtrip(self) -> bool:
|
||||
"""Validate that schema can round-trip through OpenAI-style serialization.
|
||||
|
||||
This ensures the tool schema is compatible with LLM providers that expect
|
||||
OpenAI function calling format and that parameters can be properly parsed.
|
||||
|
||||
Raises:
|
||||
ToolValidationError: If round-trip fails
|
||||
|
||||
Returns:
|
||||
True if round-trip validation passes
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
|
||||
# Get the schema
|
||||
schema = self.get_schema()
|
||||
|
||||
# Serialize to JSON (what gets sent to LLM)
|
||||
serialized = json.dumps(schema)
|
||||
|
||||
# Deserialize back
|
||||
deserialized = json.loads(serialized)
|
||||
|
||||
# Validate structure matches expectations
|
||||
if deserialized != schema:
|
||||
raise ToolValidationError(f"Schema serialization round-trip failed for tool '{self.name}'")
|
||||
|
||||
# Validate OpenAI format requirements
|
||||
func_schema = deserialized.get("function", {})
|
||||
parameters = func_schema.get("parameters", {})
|
||||
|
||||
if not isinstance(parameters.get("properties"), dict):
|
||||
raise ToolValidationError(f"Tool '{self.name}' parameters.properties must be a dict for OpenAI compatibility")
|
||||
|
||||
if "required" in parameters and not isinstance(parameters["required"], list):
|
||||
raise ToolValidationError(f"Tool '{self.name}' parameters.required must be a list")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
raise ToolValidationError(f"Schema round-trip validation failed for tool '{self.name}': {e}")
|
||||
|
||||
@classmethod
|
||||
def validate_class(cls) -> bool:
|
||||
"""Validate a tool class before instantiation.
|
||||
@@ -282,7 +345,9 @@ def validate_tool(tool: Any) -> bool:
|
||||
ToolValidationError: If validation fails
|
||||
"""
|
||||
if isinstance(tool, BaseTool):
|
||||
return tool.validate()
|
||||
tool.validate()
|
||||
tool.validate_schema_roundtrip()
|
||||
return True
|
||||
|
||||
if callable(tool):
|
||||
# For plain functions, check they have a name
|
||||
@@ -294,5 +359,67 @@ def validate_tool(tool: Any) -> bool:
|
||||
raise ToolValidationError(f"Invalid tool type: {type(tool)}")
|
||||
|
||||
|
||||
def validate_tool_schema_consistency(tools: List[Any]) -> bool:
|
||||
"""Validate a list of tools for schema consistency with OpenAI format.
|
||||
|
||||
This function ensures all tools in a list can be properly serialized
|
||||
and have consistent schema structures for use with LLM providers.
|
||||
|
||||
Args:
|
||||
tools: List of tool objects to validate
|
||||
|
||||
Returns:
|
||||
True if all tools are valid and consistent
|
||||
|
||||
Raises:
|
||||
ToolValidationError: If validation fails
|
||||
"""
|
||||
if not tools:
|
||||
return True
|
||||
|
||||
import json
|
||||
from .decorator import get_tool_schema
|
||||
schemas = []
|
||||
|
||||
for i, tool in enumerate(tools):
|
||||
try:
|
||||
# Validate individual tool
|
||||
validate_tool(tool)
|
||||
|
||||
# Get schema from different tool types
|
||||
if isinstance(tool, BaseTool):
|
||||
schema = tool.get_schema()
|
||||
elif hasattr(tool, 'get_schema') and callable(getattr(tool, 'get_schema')):
|
||||
schema = tool.get_schema()
|
||||
elif callable(tool):
|
||||
schema = get_tool_schema(tool)
|
||||
else:
|
||||
raise ToolValidationError(f"Cannot extract schema from tool at index {i}: {type(tool)}")
|
||||
|
||||
if not schema:
|
||||
raise ToolValidationError(f"Tool at index {i} returned empty schema")
|
||||
|
||||
# Validate JSON serialization
|
||||
try:
|
||||
json.dumps(schema)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ToolValidationError(f"Tool schema at index {i} is not JSON serializable: {e}")
|
||||
|
||||
schemas.append(schema)
|
||||
|
||||
except Exception as e:
|
||||
raise ToolValidationError(f"Tool validation failed at index {i}: {e}")
|
||||
|
||||
# Check for duplicate tool names
|
||||
names = set()
|
||||
for schema in schemas:
|
||||
name = schema.get("function", {}).get("name")
|
||||
if name in names:
|
||||
raise ToolValidationError(f"Duplicate tool name '{name}' found in tool list")
|
||||
names.add(name)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# For backward compatibility - tools can also just be functions
|
||||
# The @tool decorator (in decorator.py) wraps functions into BaseTool instances
|
||||
@@ -214,6 +214,13 @@ def tool(
|
||||
availability=availability
|
||||
)
|
||||
|
||||
# Validate the tool at creation time for early error detection
|
||||
try:
|
||||
tool_instance.validate()
|
||||
tool_instance.validate_schema_roundtrip()
|
||||
except Exception as e:
|
||||
logging.warning(f"Tool validation warning for {tool_instance.name}: {e}")
|
||||
|
||||
# Register with global registry if available
|
||||
try:
|
||||
from .registry import get_registry
|
||||
|
||||
@@ -0,0 +1,584 @@
|
||||
"""
|
||||
Tool Search - Progressive disclosure layer for MCP and non-core plugin tools.
|
||||
|
||||
Implements Hermes-parity bridge tools with lazy-loaded implementation:
|
||||
- tool_search(query) → matches
|
||||
- tool_describe(name) → full schema
|
||||
- tool_call(name, args) → unwrap → execute_tool(real_name)
|
||||
|
||||
Architecture invariants (ported from Hermes):
|
||||
1. Core tools never defer - PRAISONAI_CORE_TOOLS
|
||||
2. Unknown tools stay visible - never silently dropped
|
||||
3. Stateless catalog - rebuilt every assembly from current tool-defs
|
||||
4. skip_assembly for bridge dispatch - handlers read pre-assembly deferrable set
|
||||
5. Session scoping - catalog = deferrable subset of agent's own tool list
|
||||
6. Unwrap before trace/stream - hooks, approval, AG-UI show real tool name
|
||||
7. Auto threshold - deferrable schema tokens ≥ threshold_pct * context_length
|
||||
8. BM25 inlined - no new dependency; substring fallback on tool name
|
||||
"""
|
||||
|
||||
import re
|
||||
import math
|
||||
from typing import Dict, List, Optional, Union, Protocol, Set, FrozenSet, Tuple, Any
|
||||
from dataclasses import dataclass, field
|
||||
from collections import defaultdict
|
||||
|
||||
# Type definitions matching Hermes patterns
|
||||
ToolDef = Dict[str, Any] # OpenAI function schema
|
||||
ToolDefList = List[ToolDef]
|
||||
|
||||
# Core tools that never defer (mirrors Hermes _HERMES_CORE_TOOLS)
|
||||
PRAISONAI_CORE_TOOLS: FrozenSet[str] = frozenset({
|
||||
# File operations
|
||||
"read_file", "write_file", "list_files", "get_file_info",
|
||||
"copy_file", "move_file", "delete_file",
|
||||
|
||||
# Shell operations
|
||||
"execute_command", "list_processes", "kill_process", "get_system_info",
|
||||
|
||||
# Web operations
|
||||
"search_web", "web_search", "internet_search", "web_crawl", "crawl_web",
|
||||
|
||||
# Schedule operations (agent-centric scheduling)
|
||||
"schedule_add", "schedule_list", "schedule_remove",
|
||||
|
||||
# Memory operations (active memory)
|
||||
"store_memory", "search_memory",
|
||||
|
||||
# Clarify tool (human-in-the-loop clarification)
|
||||
"clarify",
|
||||
})
|
||||
|
||||
@dataclass
|
||||
class ToolSearchConfig:
|
||||
"""Configuration for Tool Search feature."""
|
||||
enabled: Union[bool, str] = "auto" # auto | on | off | True | False
|
||||
threshold_pct: float = 10.0 # Percentage of context window for deferral threshold
|
||||
search_default_limit: int = 5 # Default number of search results
|
||||
max_search_limit: int = 20 # Maximum search results allowed
|
||||
core_tools: Optional[FrozenSet[str]] = None # Override core tools set
|
||||
|
||||
@classmethod
|
||||
def from_raw(cls, raw_config: Any) -> "ToolSearchConfig":
|
||||
"""Create config from various input formats (bool, dict, ToolSearchConfig)."""
|
||||
if isinstance(raw_config, cls):
|
||||
return raw_config
|
||||
elif isinstance(raw_config, bool):
|
||||
return cls(enabled="on" if raw_config else "off")
|
||||
elif isinstance(raw_config, str):
|
||||
if raw_config.lower() in ("true", "on", "yes", "1"):
|
||||
return cls(enabled="on")
|
||||
elif raw_config.lower() in ("false", "off", "no", "0"):
|
||||
return cls(enabled="off")
|
||||
elif raw_config.lower() == "auto":
|
||||
return cls(enabled="auto")
|
||||
else:
|
||||
raise ValueError(f"Invalid tool_search string value: {raw_config}")
|
||||
elif isinstance(raw_config, dict):
|
||||
return cls(**raw_config)
|
||||
else:
|
||||
raise TypeError(f"Invalid tool_search type: {type(raw_config)}")
|
||||
|
||||
class ToolSearchProtocol(Protocol):
|
||||
"""Protocol for tool search implementations."""
|
||||
|
||||
def search_catalog(self, query: str, limit: int = 5) -> List[Dict[str, str]]:
|
||||
"""Search for tools matching the query."""
|
||||
...
|
||||
|
||||
def describe_tool(self, tool_name: str) -> Optional[Dict[str, Any]]:
|
||||
"""Get full schema for a specific tool."""
|
||||
...
|
||||
|
||||
def resolve_underlying_call(self, tool_name: str, tool_args: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
||||
"""Unwrap bridge call to real tool name and args."""
|
||||
...
|
||||
|
||||
def classify_tools(tool_defs: ToolDefList, config: ToolSearchConfig) -> Tuple[ToolDefList, ToolDefList]:
|
||||
"""
|
||||
Classify tools into core (never defer) vs deferrable.
|
||||
|
||||
Args:
|
||||
tool_defs: List of OpenAI function schemas
|
||||
config: Tool search configuration
|
||||
|
||||
Returns:
|
||||
Tuple of (core_tools, deferrable_tools)
|
||||
"""
|
||||
core_tools = []
|
||||
deferrable_tools = []
|
||||
|
||||
# Use configured core tools or default
|
||||
core_tool_names = config.core_tools or PRAISONAI_CORE_TOOLS
|
||||
|
||||
for tool_def in tool_defs:
|
||||
tool_name = tool_def.get("function", {}).get("name", "")
|
||||
|
||||
# Check if it's a core tool
|
||||
if tool_name in core_tool_names:
|
||||
core_tools.append(tool_def)
|
||||
continue
|
||||
|
||||
# Check for deferrable marker from MCP or custom tools
|
||||
if _is_tool_deferrable(tool_def):
|
||||
deferrable_tools.append(tool_def)
|
||||
else:
|
||||
# Unknown tools stay visible (invariant #2)
|
||||
core_tools.append(tool_def)
|
||||
|
||||
return core_tools, deferrable_tools
|
||||
|
||||
def _is_tool_deferrable(tool_def: ToolDef) -> bool:
|
||||
"""Check if a tool should be deferred based on markers."""
|
||||
# Check for MCP deferrable marker
|
||||
if tool_def.get("__praisonai_deferrable__", False):
|
||||
return True
|
||||
|
||||
# Check function metadata for deferrable flag
|
||||
function_def = tool_def.get("function", {})
|
||||
if function_def.get("deferrable", False):
|
||||
return True
|
||||
|
||||
# Check tool name patterns for MCP tools (prefix only, to avoid false-positives)
|
||||
tool_name = function_def.get("name", "")
|
||||
if tool_name.startswith("mcp_"):
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
def estimate_tool_schema_tokens(tool_defs: ToolDefList) -> int:
|
||||
"""
|
||||
Estimate token count for tool schemas.
|
||||
|
||||
Rough approximation: 1 token ≈ 4 characters for English text.
|
||||
Function schemas are structured JSON so may be more dense.
|
||||
"""
|
||||
if not tool_defs:
|
||||
return 0
|
||||
|
||||
# Convert to JSON and estimate
|
||||
import json
|
||||
total_chars = 0
|
||||
for tool_def in tool_defs:
|
||||
try:
|
||||
json_str = json.dumps(tool_def, separators=(',', ':'))
|
||||
total_chars += len(json_str)
|
||||
except (TypeError, ValueError):
|
||||
# Fallback estimation
|
||||
total_chars += len(str(tool_def))
|
||||
|
||||
# Conservative estimate: 1 token per 3.5 characters for JSON
|
||||
return int(total_chars / 3.5)
|
||||
|
||||
def should_defer_tools(
|
||||
deferrable_tools: ToolDefList,
|
||||
config: ToolSearchConfig,
|
||||
context_length: Optional[int] = None
|
||||
) -> bool:
|
||||
"""
|
||||
Determine if tools should be deferred based on auto threshold.
|
||||
|
||||
Args:
|
||||
deferrable_tools: List of deferrable tool schemas
|
||||
config: Tool search configuration
|
||||
context_length: Model context window size
|
||||
|
||||
Returns:
|
||||
True if tools should be deferred
|
||||
"""
|
||||
if config.enabled == "off" or config.enabled is False:
|
||||
return False
|
||||
elif config.enabled == "on" or config.enabled is True:
|
||||
return True
|
||||
elif config.enabled == "auto":
|
||||
# Auto mode: check threshold
|
||||
if not deferrable_tools:
|
||||
return False
|
||||
|
||||
deferrable_tokens = estimate_tool_schema_tokens(deferrable_tools)
|
||||
|
||||
# Use fallback if context length unknown
|
||||
context_limit = context_length or 20000
|
||||
threshold_tokens = int(context_limit * (config.threshold_pct / 100.0))
|
||||
|
||||
return deferrable_tokens >= threshold_tokens
|
||||
else:
|
||||
return False
|
||||
|
||||
class BM25ToolSearcher:
|
||||
"""
|
||||
Simple BM25 implementation for tool search.
|
||||
No external dependencies - inlined from Hermes approach.
|
||||
"""
|
||||
|
||||
def __init__(self, tool_catalog: List[Dict[str, str]]):
|
||||
"""
|
||||
Initialize with tool catalog.
|
||||
|
||||
Args:
|
||||
tool_catalog: List of dicts with 'name', 'description' keys
|
||||
"""
|
||||
self.catalog = tool_catalog
|
||||
self.term_frequencies = []
|
||||
self.doc_frequencies = defaultdict(int)
|
||||
self.total_docs = len(tool_catalog)
|
||||
|
||||
# Preprocess documents
|
||||
self._build_index()
|
||||
|
||||
def _tokenize(self, text: str) -> List[str]:
|
||||
"""Simple tokenization - lowercase, alphanumeric + underscores."""
|
||||
if not text:
|
||||
return []
|
||||
return re.findall(r'\b[a-zA-Z0-9_]+\b', text.lower())
|
||||
|
||||
def _build_index(self):
|
||||
"""Build BM25 index from catalog."""
|
||||
total_length = 0
|
||||
# Calculate term frequencies for each document
|
||||
for item in self.catalog:
|
||||
doc_text = f"{item['name']} {item['description']}"
|
||||
tokens = self._tokenize(doc_text)
|
||||
|
||||
# Term frequency for this doc
|
||||
tf = defaultdict(int)
|
||||
for token in tokens:
|
||||
tf[token] += 1
|
||||
|
||||
self.term_frequencies.append(dict(tf))
|
||||
total_length += len(tokens)
|
||||
|
||||
# Document frequency (how many docs contain each term)
|
||||
unique_tokens = set(tokens)
|
||||
for token in unique_tokens:
|
||||
self.doc_frequencies[token] += 1
|
||||
|
||||
# Cache average document length to avoid O(n) recomputation in hot path
|
||||
self._cached_avg_doc_length = total_length / len(self.catalog) if self.catalog else 0.0
|
||||
|
||||
def search(self, query: str, limit: int = 5) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Search using BM25 scoring.
|
||||
|
||||
Args:
|
||||
query: Search query string
|
||||
limit: Maximum results to return
|
||||
|
||||
Returns:
|
||||
List of matching tool items, sorted by relevance
|
||||
"""
|
||||
query_tokens = self._tokenize(query)
|
||||
if not query_tokens:
|
||||
return []
|
||||
|
||||
scores = []
|
||||
k1, b = 1.5, 0.75 # BM25 parameters
|
||||
|
||||
for item, tf in zip(self.catalog, self.term_frequencies, strict=True):
|
||||
score = 0.0
|
||||
doc_length = sum(tf.values())
|
||||
|
||||
for token in query_tokens:
|
||||
if token in tf:
|
||||
# BM25 formula
|
||||
idf = math.log((self.total_docs - self.doc_frequencies[token] + 0.5) /
|
||||
(self.doc_frequencies[token] + 0.5))
|
||||
term_freq = tf[token]
|
||||
score += idf * (term_freq * (k1 + 1)) / (
|
||||
term_freq + k1 * (1 - b + b * (doc_length / self._cached_avg_doc_length))
|
||||
)
|
||||
|
||||
if score > 0:
|
||||
scores.append((score, item))
|
||||
|
||||
# Sort by score descending and return top results
|
||||
scores.sort(key=lambda x: x[0], reverse=True)
|
||||
return [item for _, item in scores[:limit]]
|
||||
|
||||
|
||||
def search_catalog(
|
||||
deferrable_tools: ToolDefList,
|
||||
query: str,
|
||||
limit: int = 5
|
||||
) -> List[Dict[str, str]]:
|
||||
"""
|
||||
Search deferrable tools catalog using BM25.
|
||||
|
||||
Args:
|
||||
deferrable_tools: List of deferrable tool schemas
|
||||
query: Search query
|
||||
limit: Maximum results
|
||||
|
||||
Returns:
|
||||
List of matching tools with name/description
|
||||
"""
|
||||
if not deferrable_tools or not query.strip():
|
||||
return []
|
||||
|
||||
# Build catalog for searching
|
||||
catalog = []
|
||||
for tool_def in deferrable_tools:
|
||||
function_def = tool_def.get("function", {})
|
||||
name = function_def.get("name", "")
|
||||
description = function_def.get("description", "")
|
||||
|
||||
if name: # Only include tools with names
|
||||
catalog.append({
|
||||
"name": name,
|
||||
"description": description
|
||||
})
|
||||
|
||||
if not catalog:
|
||||
return []
|
||||
|
||||
# Use BM25 search if query is substantial, otherwise substring fallback
|
||||
if len(query.strip()) >= 3:
|
||||
searcher = BM25ToolSearcher(catalog)
|
||||
results = searcher.search(query, limit)
|
||||
|
||||
# If BM25 finds results, use them
|
||||
if results:
|
||||
return results
|
||||
|
||||
# Fallback: simple substring matching on tool names
|
||||
query_lower = query.lower()
|
||||
matches = []
|
||||
for item in catalog:
|
||||
if query_lower in item["name"].lower() or query_lower in item["description"].lower():
|
||||
matches.append(item)
|
||||
if len(matches) >= limit:
|
||||
break
|
||||
|
||||
return matches
|
||||
|
||||
def bridge_tool_schemas() -> ToolDefList:
|
||||
"""
|
||||
Generate the three bridge tool schemas.
|
||||
|
||||
Returns:
|
||||
List of OpenAI function schemas for bridge tools
|
||||
"""
|
||||
return [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tool_search",
|
||||
"description": "Search for available tools by name or functionality. Use this to discover what tools are available before using them.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"query": {
|
||||
"type": "string",
|
||||
"description": "Search query describing the tool functionality you need"
|
||||
},
|
||||
"limit": {
|
||||
"type": "integer",
|
||||
"description": "Maximum number of results to return",
|
||||
"default": 5,
|
||||
"minimum": 1,
|
||||
"maximum": 20
|
||||
}
|
||||
},
|
||||
"required": ["query"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tool_describe",
|
||||
"description": "Get the full schema and documentation for a specific tool. Use this after tool_search to understand how to use a tool.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_name": {
|
||||
"type": "string",
|
||||
"description": "Exact name of the tool to describe"
|
||||
}
|
||||
},
|
||||
"required": ["tool_name"]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "tool_call",
|
||||
"description": "Execute a tool with the given arguments. Use this after tool_describe to actually run the tool.",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"tool_name": {
|
||||
"type": "string",
|
||||
"description": "Exact name of the tool to execute"
|
||||
},
|
||||
"tool_args": {
|
||||
"type": "object",
|
||||
"description": "Arguments to pass to the tool",
|
||||
"additionalProperties": True
|
||||
}
|
||||
},
|
||||
"required": ["tool_name", "tool_args"]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
def assemble_tool_defs(
|
||||
tool_defs: ToolDefList,
|
||||
config: ToolSearchConfig,
|
||||
context_length: Optional[int] = None
|
||||
) -> Tuple[ToolDefList, Dict[str, Any]]:
|
||||
"""
|
||||
Main assembly function - decide whether to use bridge mode or pass-through.
|
||||
|
||||
Args:
|
||||
tool_defs: Original list of tool schemas
|
||||
config: Tool search configuration
|
||||
context_length: Model context window size
|
||||
|
||||
Returns:
|
||||
Tuple of (assembled_tool_defs, metadata)
|
||||
metadata contains: {"bridge_mode": bool, "deferred_count": int, "catalog": List}
|
||||
"""
|
||||
# Always return original tools if disabled
|
||||
if config.enabled == "off" or config.enabled is False:
|
||||
return tool_defs, {"bridge_mode": False, "deferred_count": 0, "catalog": []}
|
||||
|
||||
# Classify tools
|
||||
core_tools, deferrable_tools = classify_tools(tool_defs, config)
|
||||
|
||||
# Check if we should defer
|
||||
should_defer = should_defer_tools(deferrable_tools, config, context_length)
|
||||
|
||||
if not should_defer or not deferrable_tools:
|
||||
# Pass-through mode - return original tools
|
||||
return tool_defs, {"bridge_mode": False, "deferred_count": 0, "catalog": []}
|
||||
|
||||
# Bridge mode - replace deferrable tools with bridge schemas
|
||||
bridge_schemas = bridge_tool_schemas()
|
||||
assembled_tools = core_tools + bridge_schemas
|
||||
|
||||
# Build catalog for search
|
||||
catalog = []
|
||||
for tool_def in deferrable_tools:
|
||||
function_def = tool_def.get("function", {})
|
||||
name = function_def.get("name", "")
|
||||
description = function_def.get("description", "")
|
||||
if name:
|
||||
catalog.append({"name": name, "description": description})
|
||||
|
||||
metadata = {
|
||||
"bridge_mode": True,
|
||||
"deferred_count": len(deferrable_tools),
|
||||
"catalog": catalog,
|
||||
"deferrable_tools": deferrable_tools # Keep full schemas for describe/call
|
||||
}
|
||||
|
||||
return assembled_tools, metadata
|
||||
|
||||
def dispatch_tool_search(
|
||||
query: str,
|
||||
limit: Optional[int],
|
||||
deferrable_tools: ToolDefList,
|
||||
config: ToolSearchConfig
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle tool_search bridge call.
|
||||
|
||||
Returns:
|
||||
JSON response with search results
|
||||
"""
|
||||
# Validate and clamp limit
|
||||
search_limit = limit or config.search_default_limit
|
||||
search_limit = min(search_limit, config.max_search_limit)
|
||||
search_limit = max(search_limit, 1)
|
||||
|
||||
# Perform search
|
||||
results = search_catalog(deferrable_tools, query, search_limit)
|
||||
|
||||
return {
|
||||
"query": query,
|
||||
"results": results,
|
||||
"total_available": len(deferrable_tools)
|
||||
}
|
||||
|
||||
def dispatch_tool_describe(
|
||||
tool_name: str,
|
||||
deferrable_tools: ToolDefList
|
||||
) -> Dict[str, Any]:
|
||||
"""
|
||||
Handle tool_describe bridge call.
|
||||
|
||||
Returns:
|
||||
JSON response with full tool schema or error
|
||||
"""
|
||||
# Find the tool
|
||||
for tool_def in deferrable_tools:
|
||||
function_def = tool_def.get("function", {})
|
||||
if function_def.get("name") == tool_name:
|
||||
return {
|
||||
"tool_name": tool_name,
|
||||
"schema": tool_def,
|
||||
"found": True
|
||||
}
|
||||
|
||||
return {
|
||||
"tool_name": tool_name,
|
||||
"error": f"Tool '{tool_name}' not found in available tools",
|
||||
"found": False
|
||||
}
|
||||
|
||||
def resolve_underlying_call(tool_name: str, tool_args: Dict[str, Any]) -> Tuple[str, Dict[str, Any]]:
|
||||
"""
|
||||
Unwrap tool_call bridge to get real tool name and args.
|
||||
|
||||
Args:
|
||||
tool_name: Should be "tool_call"
|
||||
tool_args: Dict with "tool_name" and "tool_args" keys
|
||||
|
||||
Returns:
|
||||
Tuple of (real_tool_name, real_args)
|
||||
"""
|
||||
if tool_name != "tool_call":
|
||||
# Not a bridge call, return as-is
|
||||
return tool_name, tool_args
|
||||
|
||||
# Validate tool_args is a dict
|
||||
if not isinstance(tool_args, dict):
|
||||
raise TypeError(
|
||||
f"tool_call expects a dictionary for tool_args, got {type(tool_args).__name__}. "
|
||||
"Ensure the LLM output is properly formatted."
|
||||
)
|
||||
|
||||
# Extract real tool call from bridge args
|
||||
real_tool_name = tool_args.get("tool_name", "")
|
||||
real_args = tool_args.get("tool_args", {})
|
||||
|
||||
if not real_tool_name:
|
||||
raise ValueError("tool_call requires 'tool_name' parameter")
|
||||
|
||||
return real_tool_name, real_args
|
||||
|
||||
def scoped_deferrable_names(agent_tool_list: List[str], deferrable_tools: ToolDefList) -> Set[str]:
|
||||
"""
|
||||
Get names of deferrable tools that are in the agent's enabled tool list.
|
||||
Implements session scoping (invariant #5).
|
||||
|
||||
Args:
|
||||
agent_tool_list: List of tool names available to this agent
|
||||
deferrable_tools: Full list of deferrable tool schemas
|
||||
|
||||
Returns:
|
||||
Set of tool names that are both deferrable and enabled for this agent
|
||||
"""
|
||||
agent_tool_set = set(agent_tool_list)
|
||||
deferrable_names = set()
|
||||
|
||||
for tool_def in deferrable_tools:
|
||||
function_def = tool_def.get("function", {})
|
||||
tool_name = function_def.get("name", "")
|
||||
if tool_name and tool_name in agent_tool_set:
|
||||
deferrable_names.add(tool_name)
|
||||
|
||||
return deferrable_names
|
||||
@@ -253,7 +253,7 @@ class A2A:
|
||||
from praisonaiagents.ui.a2a.conversion import (
|
||||
extract_user_input,
|
||||
praisonai_to_a2a_message,
|
||||
create_artifact,
|
||||
create_response_artifact,
|
||||
)
|
||||
|
||||
# Validate params
|
||||
@@ -286,7 +286,8 @@ class A2A:
|
||||
else:
|
||||
raise RuntimeError("No agent or agents configured")
|
||||
response_msg = praisonai_to_a2a_message(str(response), task_id=task_id)
|
||||
artifact = create_artifact(str(response))
|
||||
history = getattr(self.agent, "chat_history", None) if self.agent else None
|
||||
artifact = create_response_artifact(str(response), chat_history=history)
|
||||
self.task_store.add_to_history(task_id, response_msg)
|
||||
self.task_store.add_artifact(task_id, artifact)
|
||||
self.task_store.update_status(task_id, TaskState.COMPLETED)
|
||||
@@ -322,7 +323,8 @@ class A2A:
|
||||
response_msg = praisonai_to_a2a_message(
|
||||
str(response), task_id=task.id
|
||||
)
|
||||
artifact = create_artifact(str(response))
|
||||
history = getattr(self.agent, "chat_history", None) if self.agent else None
|
||||
artifact = create_response_artifact(str(response), chat_history=history)
|
||||
|
||||
# Update task store
|
||||
self.task_store.add_to_history(task.id, response_msg)
|
||||
|
||||
@@ -16,6 +16,7 @@ from praisonaiagents.ui.a2a.types import (
|
||||
DataPart,
|
||||
Artifact,
|
||||
)
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
|
||||
def a2a_to_praisonai_messages(messages: List[Message]) -> List[Dict[str, Any]]:
|
||||
@@ -107,6 +108,75 @@ def praisonai_to_a2a_message(
|
||||
)
|
||||
|
||||
|
||||
def find_a2ui_tool_result(
|
||||
chat_history: Optional[List[Dict[str, Any]]],
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Return the last A2UI tool result dict from agent chat history, if any."""
|
||||
if not chat_history:
|
||||
return None
|
||||
for msg in reversed(chat_history):
|
||||
if msg.get("role") != "tool":
|
||||
continue
|
||||
content = msg.get("content")
|
||||
if content is None:
|
||||
continue
|
||||
data: Any = content if isinstance(content, dict) else None
|
||||
if data is None:
|
||||
try:
|
||||
data = json.loads(content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
if isinstance(data, dict) and data.get("mime_type") == A2UI_MIME_TYPE:
|
||||
return data
|
||||
return None
|
||||
|
||||
|
||||
def create_a2ui_artifact(
|
||||
result: Dict[str, Any],
|
||||
artifact_id: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> Artifact:
|
||||
"""Create an A2A Artifact with a DataPart for an A2UI tool result."""
|
||||
art_id = artifact_id or f"art-{uuid.uuid4().hex[:12]}"
|
||||
messages = result.get("messages") or []
|
||||
a2ui_data = result.get("a2ui_part")
|
||||
if not isinstance(a2ui_data, dict):
|
||||
a2ui_data = {"messages": messages}
|
||||
elif "messages" not in a2ui_data:
|
||||
a2ui_data = {**a2ui_data, "messages": messages}
|
||||
|
||||
return Artifact(
|
||||
artifact_id=art_id,
|
||||
name=name,
|
||||
description=description,
|
||||
parts=[
|
||||
DataPart(
|
||||
data=a2ui_data,
|
||||
metadata={"mimeType": result.get("mime_type", A2UI_MIME_TYPE)},
|
||||
)
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
def create_response_artifact(
|
||||
content: str,
|
||||
chat_history: Optional[List[Dict[str, Any]]] = None,
|
||||
artifact_id: Optional[str] = None,
|
||||
name: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
) -> Artifact:
|
||||
"""Build artifact from response text, preferring A2UI DataPart when present in history."""
|
||||
a2ui = find_a2ui_tool_result(chat_history)
|
||||
if a2ui:
|
||||
return create_a2ui_artifact(
|
||||
a2ui, artifact_id=artifact_id, name=name, description=description
|
||||
)
|
||||
return create_artifact(
|
||||
content, artifact_id=artifact_id, name=name, description=description
|
||||
)
|
||||
|
||||
|
||||
def create_artifact(
|
||||
content: str,
|
||||
artifact_id: Optional[str] = None,
|
||||
|
||||
@@ -12,9 +12,12 @@ from praisonaiagents.ui.a2ui.adapter import (
|
||||
is_a2ui_part,
|
||||
parse_a2ui_response,
|
||||
)
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE, A2UIToolResultProtocol
|
||||
|
||||
__all__ = [
|
||||
"A2UI",
|
||||
"A2UI_MIME_TYPE",
|
||||
"A2UIToolResultProtocol",
|
||||
"create_a2ui_part",
|
||||
"generate_a2ui_system_prompt",
|
||||
"get_schema_manager",
|
||||
|
||||
@@ -12,6 +12,7 @@ from typing import Any, AsyncIterator, Dict, Iterator, List, Optional, Set
|
||||
|
||||
from praisonaiagents.ui.agui.types import (
|
||||
BaseEvent,
|
||||
CustomEvent,
|
||||
TextMessageStartEvent,
|
||||
TextMessageContentEvent,
|
||||
TextMessageEndEvent,
|
||||
@@ -26,6 +27,7 @@ from praisonaiagents.ui.agui.types import (
|
||||
StepFinishedEvent,
|
||||
StateSnapshotEvent,
|
||||
)
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -292,13 +294,41 @@ def stream_event_to_agui_events(
|
||||
|
||||
if event.type == StreamEventType.TOOL_CALL_RESULT and event.tool_call:
|
||||
tool_call_id = event.tool_call.get("id") or str(uuid.uuid4())
|
||||
content = event.content if event.content is not None else str(event.tool_call.get("result", ""))
|
||||
raw_result = event.tool_call.get("result")
|
||||
content = event.content if event.content is not None else str(raw_result or "")
|
||||
buffer.end_tool_call(tool_call_id)
|
||||
return [create_tool_result_event(tool_call_id, str(content), message_id)]
|
||||
events: List[BaseEvent] = [
|
||||
create_tool_result_event(tool_call_id, str(content), message_id)
|
||||
]
|
||||
if isinstance(raw_result, dict) and raw_result.get("mime_type") == A2UI_MIME_TYPE:
|
||||
events.append(
|
||||
CustomEvent(
|
||||
name="a2ui",
|
||||
value={
|
||||
"mime_type": A2UI_MIME_TYPE,
|
||||
"messages": raw_result.get("messages", []),
|
||||
"surface_id": _infer_a2ui_surface_id(raw_result),
|
||||
},
|
||||
)
|
||||
)
|
||||
return events
|
||||
|
||||
return []
|
||||
|
||||
|
||||
def _infer_a2ui_surface_id(result: Dict[str, Any]) -> str:
|
||||
"""Infer surface id from send_a2ui_messages result (inline, no UI-layer deps)."""
|
||||
for msg in result.get("messages") or []:
|
||||
if isinstance(msg, dict):
|
||||
create = msg.get("createSurface")
|
||||
if isinstance(create, dict):
|
||||
sid = create.get("surfaceId") or create.get("id")
|
||||
if sid:
|
||||
return str(sid)
|
||||
sid = result.get("surface_id") or result.get("surfaceId")
|
||||
return str(sid) if sid else "main"
|
||||
|
||||
|
||||
async def async_stream_agent_response(
|
||||
agent,
|
||||
user_input: str,
|
||||
|
||||
@@ -133,6 +133,13 @@ class StateSnapshotEvent(BaseEvent):
|
||||
snapshot: Dict[str, Any]
|
||||
|
||||
|
||||
class CustomEvent(BaseEvent):
|
||||
"""Custom extension event (e.g. structured A2UI payloads)."""
|
||||
type: Literal["CUSTOM"] = "CUSTOM"
|
||||
name: str
|
||||
value: Any
|
||||
|
||||
|
||||
# Message Types
|
||||
class FunctionCall(BaseModel):
|
||||
"""Function call in a tool call."""
|
||||
@@ -175,6 +182,7 @@ Event = Union[
|
||||
ToolCallEndEvent,
|
||||
ToolCallResultEvent,
|
||||
StateSnapshotEvent,
|
||||
CustomEvent,
|
||||
RunStartedEvent,
|
||||
RunFinishedEvent,
|
||||
RunErrorEvent,
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"""
|
||||
UI protocol types — lightweight contracts for agent-to-UI integration.
|
||||
|
||||
Zero runtime cost: TypedDict and constants only. No optional dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Dict, List, TypedDict, Union
|
||||
|
||||
# Standard MIME type for A2UI payloads (Google A2UI / A2A transport).
|
||||
A2UI_MIME_TYPE = "application/json+a2ui"
|
||||
|
||||
|
||||
class A2UIToolResultProtocol(TypedDict, total=False):
|
||||
"""
|
||||
Output shape from ``send_a2ui_messages`` — the integrator contract for any UI.
|
||||
|
||||
Example:
|
||||
if result.get("mime_type") == A2UI_MIME_TYPE:
|
||||
render(result["messages"])
|
||||
"""
|
||||
|
||||
mime_type: str
|
||||
messages: List[Dict[str, Any]]
|
||||
a2ui_part: Union[Dict[str, Any], List[Any]]
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.43"
|
||||
version = "1.6.52"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -233,6 +233,47 @@ class TestA2AJsonRpc:
|
||||
assert result["status"]["state"] == "completed"
|
||||
assert result["artifacts"] is not None
|
||||
assert len(result["artifacts"]) > 0
|
||||
|
||||
def test_post_a2a_message_send_a2ui_artifact(self):
|
||||
"""message/send with A2UI tool result in chat_history → DataPart artifact."""
|
||||
import json
|
||||
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
app, a2a = _make_app()
|
||||
a2ui_payload = {
|
||||
"mime_type": A2UI_MIME_TYPE,
|
||||
"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}],
|
||||
"a2ui_part": {"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}]},
|
||||
}
|
||||
a2a.agent.chat = MagicMock(return_value="Surface ready.")
|
||||
a2a.agent.chat_history = [
|
||||
{"role": "tool", "content": json.dumps(a2ui_payload)},
|
||||
]
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post("/a2a", json={
|
||||
"jsonrpc": "2.0",
|
||||
"method": "message/send",
|
||||
"id": "a2ui-1",
|
||||
"params": {
|
||||
"message": {
|
||||
"messageId": "msg-a2ui",
|
||||
"role": "user",
|
||||
"parts": [{"text": "Show UI"}],
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()["result"]
|
||||
artifact = result["artifacts"][0]
|
||||
parts = artifact["parts"]
|
||||
data_parts = [p for p in parts if "data" in p]
|
||||
assert data_parts, "Expected DataPart in artifact"
|
||||
assert data_parts[0]["metadata"]["mimeType"] == A2UI_MIME_TYPE
|
||||
|
||||
def test_post_a2a_message_send_creates_task_in_store(self):
|
||||
"""message/send should create a task in TaskStore."""
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for A2UI artifact conversion in A2A transport."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
|
||||
SAMPLE_A2UI_RESULT = {
|
||||
"mime_type": A2UI_MIME_TYPE,
|
||||
"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}],
|
||||
"a2ui_part": {"messages": [{"createSurface": {"surfaceId": "main", "catalogId": "basic"}}]},
|
||||
}
|
||||
|
||||
|
||||
class TestFindA2uiToolResult:
|
||||
def test_finds_last_a2ui_tool_message(self):
|
||||
from praisonaiagents.ui.a2a.conversion import find_a2ui_tool_result
|
||||
|
||||
history = [
|
||||
{"role": "user", "content": "show ui"},
|
||||
{"role": "tool", "content": json.dumps({"mime_type": "text/plain", "data": "x"})},
|
||||
{"role": "tool", "content": json.dumps(SAMPLE_A2UI_RESULT)},
|
||||
]
|
||||
result = find_a2ui_tool_result(history)
|
||||
assert result is not None
|
||||
assert result["mime_type"] == A2UI_MIME_TYPE
|
||||
|
||||
def test_returns_none_without_a2ui(self):
|
||||
from praisonaiagents.ui.a2a.conversion import find_a2ui_tool_result
|
||||
|
||||
assert find_a2ui_tool_result([]) is None
|
||||
assert find_a2ui_tool_result([{"role": "tool", "content": "plain text"}]) is None
|
||||
|
||||
|
||||
class TestCreateA2uiArtifact:
|
||||
def test_creates_datapart_with_mime(self):
|
||||
from praisonaiagents.ui.a2a.conversion import create_a2ui_artifact
|
||||
from praisonaiagents.ui.a2a.types import DataPart
|
||||
|
||||
artifact = create_a2ui_artifact(SAMPLE_A2UI_RESULT, name="ui-surface")
|
||||
assert artifact.name == "ui-surface"
|
||||
assert len(artifact.parts) == 1
|
||||
part = artifact.parts[0]
|
||||
assert isinstance(part, DataPart)
|
||||
assert part.metadata.get("mimeType") == A2UI_MIME_TYPE
|
||||
assert "messages" in part.data
|
||||
|
||||
|
||||
class TestCreateResponseArtifact:
|
||||
def test_prefers_a2ui_from_history(self):
|
||||
from praisonaiagents.ui.a2a.conversion import create_response_artifact
|
||||
from praisonaiagents.ui.a2a.types import DataPart
|
||||
|
||||
history = [{"role": "tool", "content": json.dumps(SAMPLE_A2UI_RESULT)}]
|
||||
artifact = create_response_artifact("Done.", chat_history=history)
|
||||
assert isinstance(artifact.parts[0], DataPart)
|
||||
|
||||
def test_falls_back_to_text(self):
|
||||
from praisonaiagents.ui.a2a.conversion import create_response_artifact
|
||||
from praisonaiagents.ui.a2a.types import TextPart
|
||||
|
||||
artifact = create_response_artifact("Hello", chat_history=[])
|
||||
assert isinstance(artifact.parts[0], TextPart)
|
||||
assert artifact.parts[0].text == "Hello"
|
||||
@@ -0,0 +1,51 @@
|
||||
"""Tests for send_a2ui_messages minimal arg unwrap."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonaiagents.tools.a2ui_tools import _unwrap_messages
|
||||
|
||||
try:
|
||||
import a2ui # noqa: F401
|
||||
|
||||
HAS_A2UI_SDK = True
|
||||
except ImportError:
|
||||
HAS_A2UI_SDK = False
|
||||
|
||||
SAMPLE_MSG = {"createSurface": {"surfaceId": "main", "catalogId": "basic"}}
|
||||
|
||||
|
||||
class TestUnwrapMessages:
|
||||
def test_list_input(self):
|
||||
assert _unwrap_messages([SAMPLE_MSG]) == [SAMPLE_MSG]
|
||||
|
||||
def test_json_string_list(self):
|
||||
assert _unwrap_messages(json.dumps([SAMPLE_MSG])) == [SAMPLE_MSG]
|
||||
|
||||
def test_wrapped_messages_dict(self):
|
||||
assert _unwrap_messages({"messages": [SAMPLE_MSG]}) == [SAMPLE_MSG]
|
||||
|
||||
def test_json_string_wrapped_object(self):
|
||||
assert _unwrap_messages(json.dumps({"messages": [SAMPLE_MSG]})) == [SAMPLE_MSG]
|
||||
|
||||
def test_single_message_dict(self):
|
||||
assert _unwrap_messages(SAMPLE_MSG) == [SAMPLE_MSG]
|
||||
|
||||
def test_invalid_json_raises(self):
|
||||
with pytest.raises(ValueError, match="not valid JSON"):
|
||||
_unwrap_messages("{not json")
|
||||
|
||||
def test_invalid_shape_raises(self):
|
||||
with pytest.raises(ValueError, match="must be"):
|
||||
_unwrap_messages(123)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_A2UI_SDK, reason="a2ui-agent-sdk not installed")
|
||||
class TestSendA2uiMessagesIntegration:
|
||||
def test_full_tool_call(self):
|
||||
from praisonaiagents.tools.a2ui_tools import send_a2ui_messages
|
||||
|
||||
result = send_a2ui_messages(messages=[SAMPLE_MSG])
|
||||
assert result["mime_type"] == "application/json+a2ui"
|
||||
assert len(result["messages"]) == 1
|
||||
@@ -34,3 +34,27 @@ class TestA2UITool:
|
||||
)
|
||||
assert result["mime_type"] == "application/json+a2ui"
|
||||
assert len(result["messages"]) == 1
|
||||
|
||||
|
||||
class TestA2UIAdapterSmoke:
|
||||
def test_is_a2ui_part_with_sdk(self):
|
||||
pytest.importorskip("a2ui", reason="a2ui-agent-sdk not installed")
|
||||
from praisonaiagents.ui.a2ui.adapter import create_a2ui_part, is_a2ui_part
|
||||
|
||||
part = create_a2ui_part({"messages": [{"createSurface": {"surfaceId": "m"}}]})
|
||||
assert is_a2ui_part(part) is True
|
||||
|
||||
def test_missing_sdk_import_error_message(self):
|
||||
from unittest.mock import patch
|
||||
|
||||
from praisonaiagents.ui.a2ui import adapter
|
||||
|
||||
def _raise(_name):
|
||||
raise ImportError(
|
||||
"A2UI support requires the optional dependency. "
|
||||
"Install with: pip install praisonaiagents[a2ui]"
|
||||
)
|
||||
|
||||
with patch.object(adapter, "_import", side_effect=_raise):
|
||||
with pytest.raises(ImportError, match="praisonaiagents\\[a2ui\\]"):
|
||||
adapter.create_a2ui_part({"messages": []})
|
||||
@@ -0,0 +1,27 @@
|
||||
"""Tests for UI protocol types (zero-dep contracts)."""
|
||||
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE, A2UIToolResultProtocol
|
||||
|
||||
|
||||
class TestA2UIProtocols:
|
||||
def test_mime_type_constant(self):
|
||||
assert A2UI_MIME_TYPE == "application/json+a2ui"
|
||||
|
||||
def test_tool_result_protocol_shape(self):
|
||||
result: A2UIToolResultProtocol = {
|
||||
"mime_type": A2UI_MIME_TYPE,
|
||||
"messages": [{"createSurface": {"surfaceId": "main"}}],
|
||||
"a2ui_part": {"messages": []},
|
||||
}
|
||||
assert result["mime_type"] == A2UI_MIME_TYPE
|
||||
|
||||
def test_exported_from_a2ui_package(self):
|
||||
from praisonaiagents.ui.a2ui import A2UI_MIME_TYPE, A2UIToolResultProtocol
|
||||
|
||||
assert A2UI_MIME_TYPE == "application/json+a2ui"
|
||||
assert A2UIToolResultProtocol is not None
|
||||
|
||||
def test_top_level_lazy_a2ui_export(self):
|
||||
from praisonaiagents import A2UI
|
||||
|
||||
assert A2UI is not None
|
||||
@@ -129,6 +129,29 @@ class TestStreamEventBridge:
|
||||
assert agui_events[0].type == "TOOL_CALL_START"
|
||||
assert agui_events[0].tool_call_name == "search"
|
||||
|
||||
def test_tool_call_result_a2ui_emits_custom_event(self):
|
||||
from praisonaiagents.streaming.events import StreamEvent, StreamEventType
|
||||
from praisonaiagents.ui.agui.streaming import EventBuffer, stream_event_to_agui_events
|
||||
from praisonaiagents.ui.protocols import A2UI_MIME_TYPE
|
||||
|
||||
buffer = EventBuffer()
|
||||
a2ui_result = {
|
||||
"mime_type": A2UI_MIME_TYPE,
|
||||
"messages": [{"createSurface": {"surfaceId": "panel-1"}}],
|
||||
}
|
||||
event = StreamEvent(
|
||||
type=StreamEventType.TOOL_CALL_RESULT,
|
||||
tool_call={"id": "tc-a2ui", "name": "send_a2ui_messages", "result": a2ui_result},
|
||||
)
|
||||
agui_events = stream_event_to_agui_events(event, "msg-1", buffer)
|
||||
|
||||
assert len(agui_events) == 2
|
||||
assert agui_events[0].type == "TOOL_CALL_RESULT"
|
||||
assert agui_events[1].type == "CUSTOM"
|
||||
assert agui_events[1].name == "a2ui"
|
||||
assert agui_events[1].value["surface_id"] == "panel-1"
|
||||
assert agui_events[1].value["messages"] == a2ui_result["messages"]
|
||||
|
||||
|
||||
class TestRunLifecycleEvents:
|
||||
"""Test run lifecycle events."""
|
||||
|
||||
@@ -349,6 +349,175 @@ class TestDbSessionAdapter:
|
||||
assert meta["agent_id"] == "abc"
|
||||
assert meta["total_input_tokens"] == 42
|
||||
|
||||
def test_update_session_metadata_accepts_keyword_fields(self):
|
||||
"""update_session_metadata matches DefaultSessionStore **fields API."""
|
||||
from praisonai.integrations.db_session_adapter import DbSessionAdapter
|
||||
|
||||
mock_db = MagicMock()
|
||||
adapter = DbSessionAdapter(mock_db)
|
||||
adapter.update_session_metadata(
|
||||
"sess1", model="gpt-4o-mini", total_tokens=99, cost=0.01
|
||||
)
|
||||
meta = adapter.get_metadata("sess1")
|
||||
assert meta["model"] == "gpt-4o-mini"
|
||||
assert meta["total_tokens"] == 99
|
||||
assert meta["cost"] == 0.01
|
||||
|
||||
def test_clear_session_purges_db_history(self):
|
||||
"""clear_session must not leave stale messages reloadable from the DB."""
|
||||
from praisonai.integrations.db_session_adapter import DbSessionAdapter
|
||||
from praisonaiagents.db.protocol import DbMessage
|
||||
|
||||
persisted: list = []
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
def _on_start(agent_name, session_id):
|
||||
return list(persisted)
|
||||
|
||||
def _on_user(sid, content, **kw):
|
||||
persisted.append(DbMessage(role="user", content=content))
|
||||
|
||||
def _on_assistant(sid, content, **kw):
|
||||
persisted.append(DbMessage(role="assistant", content=content))
|
||||
|
||||
mock_db.on_agent_start.side_effect = _on_start
|
||||
mock_db.on_user_message.side_effect = _on_user
|
||||
mock_db.on_agent_message.side_effect = _on_assistant
|
||||
|
||||
conv = MagicMock()
|
||||
conv.delete_messages.side_effect = lambda sid, ids: persisted.clear()
|
||||
mock_db.conversation = conv
|
||||
|
||||
adapter = DbSessionAdapter(mock_db)
|
||||
sid = "bot-trim-session"
|
||||
adapter.add_message(sid, "user", "old")
|
||||
adapter.add_message(sid, "assistant", "old-reply")
|
||||
assert len(persisted) == 2
|
||||
|
||||
adapter.clear_session(sid)
|
||||
conv.delete_messages.assert_called_once_with(sid, None)
|
||||
assert persisted == []
|
||||
assert adapter.get_chat_history(sid) == []
|
||||
|
||||
adapter.add_message(sid, "user", "new-only")
|
||||
assert len(adapter.get_chat_history(sid)) == 1
|
||||
assert len(persisted) == 1
|
||||
|
||||
adapter2 = DbSessionAdapter(mock_db)
|
||||
assert adapter2.get_chat_history(sid) == [{"role": "user", "content": "new-only"}]
|
||||
|
||||
def test_metadata_survives_adapter_restart(self):
|
||||
"""set_metadata must persist to the conversation store for resume after restart."""
|
||||
from praisonai.integrations.db_session_adapter import DbSessionAdapter
|
||||
from praisonai.persistence.conversation.base import ConversationSession
|
||||
|
||||
sessions: dict = {}
|
||||
|
||||
def _get_session(sid):
|
||||
return sessions.get(sid)
|
||||
|
||||
def _create_session(session):
|
||||
sessions[session.session_id] = session
|
||||
|
||||
def _update_session(session):
|
||||
sessions[session.session_id] = session
|
||||
|
||||
conv = MagicMock()
|
||||
conv.get_session.side_effect = _get_session
|
||||
conv.create_session.side_effect = _create_session
|
||||
conv.update_session.side_effect = _update_session
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.on_agent_start.return_value = []
|
||||
mock_db.conversation = None # Prevent auto-creation of conversation attr
|
||||
mock_db._conversation_store = conv
|
||||
|
||||
sid = "meta-restart-session"
|
||||
adapter1 = DbSessionAdapter(mock_db)
|
||||
adapter1.set_metadata(
|
||||
sid,
|
||||
{
|
||||
"agent_id": "agent-xyz",
|
||||
"total_input_tokens": 99,
|
||||
"session_history": [{"turn": 1}],
|
||||
},
|
||||
)
|
||||
|
||||
adapter2 = DbSessionAdapter(mock_db)
|
||||
meta = adapter2.get_metadata(sid)
|
||||
assert meta["agent_id"] == "agent-xyz"
|
||||
assert meta["total_input_tokens"] == 99
|
||||
assert meta["session_history"] == [{"turn": 1}]
|
||||
|
||||
def test_managed_agent_metadata_persistence_e2e(self):
|
||||
"""Integration test: ManagedAgent metadata survives restart via real adapter."""
|
||||
import tempfile
|
||||
import os
|
||||
from praisonaiagents.managed import LocalManagedAgent
|
||||
from praisonai.integrations.db_session_adapter import DbSessionAdapter
|
||||
|
||||
# Create a real SQLite database for this test
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
db_path = tmp.name
|
||||
|
||||
try:
|
||||
# Mock PraisonDB that uses file-based conversation store
|
||||
mock_db = MagicMock()
|
||||
mock_db.on_agent_start.return_value = []
|
||||
|
||||
# Real conversation store backed by file
|
||||
from praisonai.persistence.conversation.base import ConversationSession
|
||||
from praisonai.persistence.conversation.file_store import FileConversationStore
|
||||
|
||||
conv_store = FileConversationStore(store_path=db_path + "_conv.json")
|
||||
mock_db.conversation = None
|
||||
mock_db._conversation_store = conv_store
|
||||
|
||||
# Create and run agent with metadata
|
||||
adapter = DbSessionAdapter(mock_db)
|
||||
agent = LocalManagedAgent(
|
||||
name="test-agent",
|
||||
instructions="You are a helpful assistant.",
|
||||
session_store=adapter,
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Start agent and collect session metadata
|
||||
session_id = "integration-test-session"
|
||||
adapter._ensure_session(session_id, agent.name)
|
||||
|
||||
# Set some metadata that should persist
|
||||
test_metadata = {
|
||||
"agent_id": "test-agent-123",
|
||||
"total_input_tokens": 150,
|
||||
"session_history": [{"turn": 1, "action": "started"}],
|
||||
"compute_instance": "test-instance-1"
|
||||
}
|
||||
adapter.set_metadata(session_id, test_metadata)
|
||||
|
||||
# Simulate process restart with new adapter and agent
|
||||
adapter2 = DbSessionAdapter(mock_db)
|
||||
agent2 = LocalManagedAgent(
|
||||
name="test-agent",
|
||||
instructions="You are a helpful assistant.",
|
||||
session_store=adapter2,
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Metadata should be restored from persistence
|
||||
restored_meta = adapter2.get_metadata(session_id)
|
||||
assert restored_meta["agent_id"] == "test-agent-123"
|
||||
assert restored_meta["total_input_tokens"] == 150
|
||||
assert restored_meta["session_history"] == [{"turn": 1, "action": "started"}]
|
||||
assert restored_meta["compute_instance"] == "test-instance-1"
|
||||
|
||||
finally:
|
||||
# Clean up temp files
|
||||
for path in [db_path, db_path + "_conv.json"]:
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7. ManagedAgent factory wiring
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Test tool schema parity and OpenAI compatibility.
|
||||
|
||||
This module tests the fixes for issue #1696 to ensure BaseTool ↔ OpenAI-compatible
|
||||
tool schema parity in chat/agent dispatch.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
from praisonaiagents.tools.base import BaseTool, ToolValidationError, validate_tool_schema_consistency
|
||||
from praisonaiagents.tools.decorator import tool, FunctionTool
|
||||
|
||||
|
||||
class MockTool(BaseTool):
|
||||
"""A simple mock tool for testing."""
|
||||
|
||||
name = "mock_tool"
|
||||
description = "A mock tool for testing"
|
||||
|
||||
def run(self, query: str, count: int = 1) -> str:
|
||||
return f"Mock result for {query} (count: {count})"
|
||||
|
||||
|
||||
class TestBasicToolSchemaValidation:
|
||||
"""Test basic tool schema validation and OpenAI compatibility."""
|
||||
|
||||
def test_base_tool_schema_structure(self):
|
||||
"""Test that BaseTool generates correct OpenAI schema structure."""
|
||||
tool = MockTool()
|
||||
schema = tool.get_schema()
|
||||
|
||||
# Validate top-level structure
|
||||
assert isinstance(schema, dict)
|
||||
assert schema["type"] == "function"
|
||||
assert "function" in schema
|
||||
|
||||
# Validate function structure
|
||||
func = schema["function"]
|
||||
assert func["name"] == "mock_tool"
|
||||
assert func["description"] == "A mock tool for testing"
|
||||
assert "parameters" in func
|
||||
|
||||
# Validate parameters structure
|
||||
params = func["parameters"]
|
||||
assert params["type"] == "object"
|
||||
assert "properties" in params
|
||||
assert "required" in params
|
||||
|
||||
# Validate specific properties
|
||||
props = params["properties"]
|
||||
assert "query" in props
|
||||
assert "count" in props
|
||||
assert props["query"]["type"] == "string"
|
||||
assert props["count"]["type"] == "integer"
|
||||
|
||||
# Validate required fields
|
||||
assert "query" in params["required"]
|
||||
assert "count" not in params["required"] # Has default value
|
||||
|
||||
def test_tool_schema_json_serialization(self):
|
||||
"""Test that tool schemas can be JSON serialized (round-trip test)."""
|
||||
tool = MockTool()
|
||||
schema = tool.get_schema()
|
||||
|
||||
# Should serialize without errors
|
||||
serialized = json.dumps(schema)
|
||||
assert isinstance(serialized, str)
|
||||
|
||||
# Should deserialize back to same structure
|
||||
deserialized = json.loads(serialized)
|
||||
assert deserialized == schema
|
||||
|
||||
def test_tool_validation_passes(self):
|
||||
"""Test that valid tools pass validation."""
|
||||
tool = MockTool()
|
||||
|
||||
# Basic validation should pass
|
||||
assert tool.validate() == True
|
||||
|
||||
# Round-trip validation should pass
|
||||
assert tool.validate_schema_roundtrip() == True
|
||||
|
||||
def test_function_tool_schema_parity(self):
|
||||
"""Test that @tool decorated functions have same schema structure."""
|
||||
@tool
|
||||
def test_function(query: str, count: int = 1) -> str:
|
||||
"""A test function for schema validation."""
|
||||
return f"Result for {query} (count: {count})"
|
||||
|
||||
schema = test_function.get_schema()
|
||||
|
||||
# Should have same structure as BaseTool
|
||||
assert schema["type"] == "function"
|
||||
func = schema["function"]
|
||||
assert func["name"] == "test_function"
|
||||
assert "parameters" in func
|
||||
|
||||
params = func["parameters"]
|
||||
assert params["type"] == "object"
|
||||
assert "properties" in params
|
||||
assert "required" in params
|
||||
|
||||
|
||||
class TestToolSchemaValidationErrors:
|
||||
"""Test that validation catches schema errors."""
|
||||
|
||||
def test_invalid_tool_missing_properties(self):
|
||||
"""Test validation fails for tools with invalid parameters."""
|
||||
|
||||
class BadTool(BaseTool):
|
||||
name = "bad_tool"
|
||||
description = "Bad tool"
|
||||
parameters = {"type": "object"} # Missing properties
|
||||
|
||||
def run(self, query: str) -> str:
|
||||
return query
|
||||
|
||||
tool = BadTool()
|
||||
with pytest.raises(ToolValidationError, match="must have a 'properties' field"):
|
||||
tool.validate()
|
||||
|
||||
def test_invalid_tool_missing_type(self):
|
||||
"""Test validation fails for tools with missing type."""
|
||||
|
||||
class BadTool(BaseTool):
|
||||
name = "bad_tool"
|
||||
description = "Bad tool"
|
||||
parameters = {"properties": {}} # Missing type
|
||||
|
||||
def run(self, query: str) -> str:
|
||||
return query
|
||||
|
||||
tool = BadTool()
|
||||
with pytest.raises(ToolValidationError, match="must have a 'type' field"):
|
||||
tool.validate()
|
||||
|
||||
|
||||
class TestToolListValidation:
|
||||
"""Test validation of tool lists for consistency."""
|
||||
|
||||
def test_valid_tool_list(self):
|
||||
"""Test that valid tool lists pass consistency checks."""
|
||||
@tool
|
||||
def tool_one(x: str) -> str:
|
||||
"""First tool."""
|
||||
return x
|
||||
|
||||
@tool
|
||||
def tool_two(y: int) -> int:
|
||||
"""Second tool."""
|
||||
return y
|
||||
|
||||
tool_three = MockTool()
|
||||
|
||||
tools = [tool_one, tool_two, tool_three]
|
||||
|
||||
# Should pass validation
|
||||
assert validate_tool_schema_consistency(tools) == True
|
||||
|
||||
def test_duplicate_tool_names_error(self):
|
||||
"""Test that duplicate tool names are caught."""
|
||||
@tool(name="duplicate_name")
|
||||
def tool_one(x: str) -> str:
|
||||
return x
|
||||
|
||||
@tool(name="duplicate_name")
|
||||
def tool_two(y: int) -> int:
|
||||
return y
|
||||
|
||||
tools = [tool_one, tool_two]
|
||||
|
||||
with pytest.raises(ToolValidationError, match="Duplicate tool name"):
|
||||
validate_tool_schema_consistency(tools)
|
||||
|
||||
def test_empty_tool_list(self):
|
||||
"""Test that empty tool lists are valid."""
|
||||
assert validate_tool_schema_consistency([]) == True
|
||||
|
||||
|
||||
class TestRealAgenticScenario:
|
||||
"""Test real agentic scenarios to ensure end-to-end compatibility."""
|
||||
|
||||
def test_tool_schema_agent_integration(self):
|
||||
"""Test that tools work with agent in real scenario."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
@tool
|
||||
def echo_tool(message: str) -> str:
|
||||
"""Echo the input message."""
|
||||
return f"Echo: {message}"
|
||||
|
||||
# Create agent with tool - should not raise validation errors
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
instructions="You are a helpful assistant",
|
||||
tools=[echo_tool]
|
||||
)
|
||||
|
||||
# Schema should be valid for LLM use
|
||||
schema = echo_tool.get_schema()
|
||||
assert schema["type"] == "function"
|
||||
|
||||
# Should be JSON serializable
|
||||
json.dumps(schema)
|
||||
|
||||
# Agent should format tools properly
|
||||
formatted_tools = agent._format_tools_for_completion(agent.tools)
|
||||
assert len(formatted_tools) == 1
|
||||
assert formatted_tools[0]["type"] == "function"
|
||||
|
||||
print("✅ Real agentic test passed: Tool schema integration works end-to-end")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for Agent.__deepcopy__ and Agent.clone_for_channel() (issue #1746).
|
||||
|
||||
Validates:
|
||||
- copy.deepcopy(agent) works without threading.RLock errors
|
||||
- clone_for_channel() returns an independent instance
|
||||
- Cloned agent has its own lock objects (not shared)
|
||||
- Key configuration attributes are preserved in the clone
|
||||
- Multiple clones are isolated from each other
|
||||
"""
|
||||
import copy
|
||||
import threading
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAgentDeepCopy:
|
||||
"""Test Agent.__deepcopy__ and clone_for_channel()."""
|
||||
|
||||
def _make_agent(self, **kwargs):
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
return Agent(name="TestAgent", instructions="test", **kwargs)
|
||||
|
||||
def test_deepcopy_does_not_raise(self):
|
||||
"""copy.deepcopy(agent) must not raise TypeError for RLock."""
|
||||
agent = self._make_agent()
|
||||
cloned = copy.deepcopy(agent)
|
||||
assert cloned is not agent
|
||||
|
||||
def test_clone_for_channel_returns_new_instance(self):
|
||||
agent = self._make_agent()
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned is not agent
|
||||
|
||||
def test_clone_has_independent_cache_lock(self):
|
||||
"""__cache_lock must be a new RLock, not shared with original."""
|
||||
agent = self._make_agent()
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned._Agent__cache_lock is not agent._Agent__cache_lock
|
||||
assert isinstance(cloned._Agent__cache_lock, type(threading.RLock()))
|
||||
|
||||
def test_clone_has_independent_cost_lock(self):
|
||||
"""_cost_lock must be a new Lock, not shared with original."""
|
||||
agent = self._make_agent()
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned._cost_lock is not agent._cost_lock
|
||||
|
||||
def test_clone_preserves_name(self):
|
||||
agent = self._make_agent(name="MyAgent")
|
||||
assert agent.clone_for_channel().name == "MyAgent"
|
||||
|
||||
def test_clone_preserves_instructions(self):
|
||||
agent = self._make_agent(instructions="Do the thing")
|
||||
assert agent.clone_for_channel().instructions == "Do the thing"
|
||||
|
||||
def test_clone_preserves_role_goal_backstory(self):
|
||||
agent = self._make_agent(
|
||||
role="Analyst",
|
||||
goal="Analyse",
|
||||
backstory="Expert analyst",
|
||||
)
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned.role == "Analyst"
|
||||
assert cloned.goal == "Analyse"
|
||||
assert cloned.backstory == "Expert analyst"
|
||||
|
||||
def test_clone_preserves_llm(self):
|
||||
agent = self._make_agent(llm="gpt-4o-mini")
|
||||
assert agent.clone_for_channel().llm == "gpt-4o-mini"
|
||||
|
||||
def test_multiple_clones_are_isolated(self):
|
||||
"""Regression: creating two clones (simulating 2nd+ gateway channel) must work."""
|
||||
agent = self._make_agent()
|
||||
clone1 = agent.clone_for_channel()
|
||||
clone2 = agent.clone_for_channel()
|
||||
assert clone1 is not clone2
|
||||
assert clone1._Agent__cache_lock is not clone2._Agent__cache_lock
|
||||
|
||||
def test_deepcopy_multiple_times(self):
|
||||
"""Successive deepcopies must all succeed (no cumulative state corruption)."""
|
||||
agent = self._make_agent()
|
||||
for _ in range(3):
|
||||
cloned = copy.deepcopy(agent)
|
||||
assert cloned is not agent
|
||||
@@ -0,0 +1,48 @@
|
||||
import ast
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def _chat_mixin_path() -> Path:
|
||||
for candidate in Path(__file__).resolve().parents:
|
||||
chat_mixin = candidate / "praisonaiagents" / "agent" / "chat_mixin.py"
|
||||
if chat_mixin.exists():
|
||||
return chat_mixin
|
||||
raise FileNotFoundError("Could not locate praisonaiagents/agent/chat_mixin.py")
|
||||
|
||||
|
||||
def get_chat_mixin_stream_parameter_default(function_name: str):
|
||||
chat_mixin = _chat_mixin_path()
|
||||
tree = ast.parse(chat_mixin.read_text(encoding="utf-8"))
|
||||
|
||||
for node in tree.body:
|
||||
if isinstance(node, ast.ClassDef) and node.name == "ChatMixin":
|
||||
for item in node.body:
|
||||
if isinstance(item, ast.FunctionDef) and item.name == function_name:
|
||||
args = item.args.args
|
||||
defaults = item.args.defaults
|
||||
first_default_index = len(args) - len(defaults)
|
||||
for idx, arg in enumerate(args):
|
||||
if arg.arg == "stream":
|
||||
default_idx = idx - first_default_index
|
||||
if default_idx < 0:
|
||||
raise AssertionError(
|
||||
f"Function '{function_name}' has no default for stream"
|
||||
)
|
||||
default = defaults[default_idx]
|
||||
if isinstance(default, ast.Constant):
|
||||
return default.value
|
||||
raise AssertionError(
|
||||
f"Function '{function_name}' stream default is non-constant"
|
||||
)
|
||||
raise AssertionError(f"Function '{function_name}' or stream parameter not found")
|
||||
|
||||
|
||||
def test_chat_completion_stream_default_is_none():
|
||||
assert get_chat_mixin_stream_parameter_default("_chat_completion") is None
|
||||
|
||||
|
||||
def test_execute_unified_chat_completion_stream_default_is_none():
|
||||
assert (
|
||||
get_chat_mixin_stream_parameter_default("_execute_unified_chat_completion")
|
||||
is None
|
||||
)
|
||||
@@ -0,0 +1,45 @@
|
||||
from types import SimpleNamespace
|
||||
import sys
|
||||
|
||||
|
||||
def test_start_verbose_composed_callbacks_forward_all_arguments(monkeypatch):
|
||||
from praisonaiagents import Agent, AgentTeam
|
||||
|
||||
on_start_calls = []
|
||||
on_complete_calls = []
|
||||
debug_messages = []
|
||||
|
||||
def on_task_start(task, task_id):
|
||||
on_start_calls.append((task, task_id))
|
||||
|
||||
def on_task_complete(task, task_output):
|
||||
on_complete_calls.append((task, task_output))
|
||||
|
||||
team = AgentTeam(
|
||||
agents=[
|
||||
Agent(name="Researcher", instructions="Research"),
|
||||
Agent(name="Writer", instructions="Write"),
|
||||
],
|
||||
hooks={
|
||||
"on_task_start": on_task_start,
|
||||
"on_task_complete": on_task_complete,
|
||||
},
|
||||
)
|
||||
|
||||
def fake_run_all_tasks():
|
||||
task_id, task = next(iter(team.tasks.items()))
|
||||
task_output = SimpleNamespace(raw="done")
|
||||
team.on_task_start(task, task_id)
|
||||
team.on_task_complete(task, task_output)
|
||||
|
||||
monkeypatch.setattr(team, "run_all_tasks", fake_run_all_tasks)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr("praisonaiagents.agents.agents.logging.debug", lambda msg: debug_messages.append(str(msg)))
|
||||
|
||||
team.start(output="verbose")
|
||||
|
||||
assert len(on_start_calls) == 1
|
||||
assert len(on_complete_calls) == 1
|
||||
assert on_start_calls[0][1] == next(iter(team.tasks.keys()))
|
||||
assert on_complete_calls[0][1].raw == "done"
|
||||
assert all("Error in verbose task" not in message for message in debug_messages)
|
||||
@@ -5,6 +5,8 @@ Tests Mem0Adapter lazy loading and normalization.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -174,3 +176,54 @@ class TestProtocolCompliance:
|
||||
|
||||
for method in required_methods:
|
||||
assert hasattr(Mem0Adapter, method), f"Missing method: {method}"
|
||||
|
||||
|
||||
class TestChromaKnowledgeAdapterWhereFilters:
|
||||
"""Tests for ChromaKnowledgeAdapter where-filter formatting."""
|
||||
|
||||
def _make_adapter(self):
|
||||
from praisonaiagents.knowledge.adapters.factories import ChromaKnowledgeAdapter
|
||||
|
||||
adapter = ChromaKnowledgeAdapter.__new__(ChromaKnowledgeAdapter)
|
||||
adapter.collection = MagicMock()
|
||||
adapter.collection.query.return_value = {
|
||||
"ids": [[]],
|
||||
"documents": [[]],
|
||||
"metadatas": [[]],
|
||||
"distances": [[]],
|
||||
}
|
||||
return adapter
|
||||
|
||||
def test_search_wraps_multiple_where_filters_with_and(self, monkeypatch):
|
||||
embedding_module = importlib.import_module("praisonaiagents.embedding")
|
||||
|
||||
adapter = self._make_adapter()
|
||||
monkeypatch.setattr(
|
||||
embedding_module,
|
||||
"embedding",
|
||||
lambda *args, **kwargs: SimpleNamespace(embeddings=[[0.1, 0.2]]),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
adapter.search("q", user_id="u1", agent_id="a1", run_id="r1")
|
||||
|
||||
where = adapter.collection.query.call_args.kwargs["where"]
|
||||
assert where == {
|
||||
"$and": [{"user_id": "u1"}, {"agent_id": "a1"}, {"run_id": "r1"}]
|
||||
}
|
||||
|
||||
def test_search_keeps_single_where_filter_unwrapped(self, monkeypatch):
|
||||
embedding_module = importlib.import_module("praisonaiagents.embedding")
|
||||
|
||||
adapter = self._make_adapter()
|
||||
monkeypatch.setattr(
|
||||
embedding_module,
|
||||
"embedding",
|
||||
lambda *args, **kwargs: SimpleNamespace(embeddings=[[0.1, 0.2]]),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
adapter.search("q", user_id="u1")
|
||||
|
||||
where = adapter.collection.query.call_args.kwargs["where"]
|
||||
assert where == {"user_id": "u1"}
|
||||
@@ -223,3 +223,188 @@ class TestDefaultContextBuilder:
|
||||
|
||||
context = builder.build(results)
|
||||
assert "\n\n" in context
|
||||
|
||||
|
||||
class TestExtractValue:
|
||||
"""Tests for the _extract_value helper function."""
|
||||
|
||||
def test_extract_value_from_dict(self):
|
||||
"""Test extracting value from dictionary."""
|
||||
from praisonaiagents.rag.context import _extract_value
|
||||
|
||||
item = {"text": "test content", "metadata": {"source": "test.pdf"}}
|
||||
|
||||
assert _extract_value(item, "text") == "test content"
|
||||
assert _extract_value(item, "metadata")["source"] == "test.pdf"
|
||||
assert _extract_value(item, "nonexistent", "default") == "default"
|
||||
|
||||
def test_extract_value_from_searchresultitem(self):
|
||||
"""Test extracting value from SearchResultItem object."""
|
||||
from praisonaiagents.rag.context import _extract_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(
|
||||
text="test content",
|
||||
source="test.pdf",
|
||||
filename="test.pdf",
|
||||
metadata={"extra": "data"}
|
||||
)
|
||||
|
||||
assert _extract_value(item, "text") == "test content"
|
||||
assert _extract_value(item, "source") == "test.pdf"
|
||||
assert _extract_value(item, "filename") == "test.pdf"
|
||||
assert _extract_value(item, "metadata")["extra"] == "data"
|
||||
assert _extract_value(item, "nonexistent", "default") == "default"
|
||||
|
||||
|
||||
class TestExtractMetadataValue:
|
||||
"""Tests for the _extract_metadata_value helper function."""
|
||||
|
||||
def test_extract_metadata_value_from_metadata(self):
|
||||
"""Test extracting value from metadata dict."""
|
||||
from praisonaiagents.rag.context import _extract_metadata_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(text="content", source="fallback.pdf")
|
||||
metadata = {"source": "metadata.pdf"}
|
||||
|
||||
# Should prefer metadata over top-level
|
||||
result = _extract_metadata_value(item, metadata, "source", "")
|
||||
assert result == "metadata.pdf"
|
||||
|
||||
def test_extract_metadata_value_fallback_to_toplevel(self):
|
||||
"""Test fallback to top-level when metadata doesn't have key."""
|
||||
from praisonaiagents.rag.context import _extract_metadata_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(text="content", source="toplevel.pdf")
|
||||
metadata = {} # Empty metadata
|
||||
|
||||
# Should fall back to top-level source
|
||||
result = _extract_metadata_value(item, metadata, "source", "")
|
||||
assert result == "toplevel.pdf"
|
||||
|
||||
def test_extract_metadata_value_with_default(self):
|
||||
"""Test fallback to default when neither metadata nor top-level have key."""
|
||||
from praisonaiagents.rag.context import _extract_metadata_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(text="content") # No source
|
||||
metadata = {} # No source in metadata
|
||||
|
||||
# Should use default
|
||||
result = _extract_metadata_value(item, metadata, "source", "default.pdf")
|
||||
assert result == "default.pdf"
|
||||
|
||||
|
||||
class TestSearchResultItemSupport:
|
||||
"""Tests for SearchResultItem object support in context functions."""
|
||||
|
||||
def test_deduplicate_chunks_with_searchresultitem(self):
|
||||
"""Test deduplication with SearchResultItem objects."""
|
||||
from praisonaiagents.rag.context import deduplicate_chunks
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
SearchResultItem(text="Same content", source="a.pdf"),
|
||||
SearchResultItem(text="Same content", source="a.pdf"), # Duplicate
|
||||
SearchResultItem(text="Different content", source="b.pdf"),
|
||||
]
|
||||
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2 # One duplicate should be removed
|
||||
|
||||
def test_deduplicate_chunks_mixed_formats(self):
|
||||
"""Test deduplication with mixed dict and SearchResultItem formats."""
|
||||
from praisonaiagents.rag.context import deduplicate_chunks
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
{"text": "Content A", "metadata": {"source": "a.pdf"}},
|
||||
SearchResultItem(text="Content A", source="a.pdf"), # Should be deduped
|
||||
SearchResultItem(text="Content B", source="b.pdf"),
|
||||
]
|
||||
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2 # One duplicate should be removed
|
||||
|
||||
def test_deduplicate_with_top_level_source(self):
|
||||
"""Test deduplication considers top-level source from SearchResultItem."""
|
||||
from praisonaiagents.rag.context import deduplicate_chunks
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
SearchResultItem(text="Same text", source="source1.pdf"),
|
||||
SearchResultItem(text="Same text", source="source2.pdf"),
|
||||
]
|
||||
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2 # Different sources should not be deduped
|
||||
|
||||
def test_build_context_with_searchresultitem(self):
|
||||
"""Test building context with SearchResultItem objects."""
|
||||
from praisonaiagents.rag.context import build_context
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
SearchResultItem(
|
||||
text="First content",
|
||||
source="source1.pdf",
|
||||
filename="file1.pdf"
|
||||
),
|
||||
SearchResultItem(
|
||||
text="Second content",
|
||||
source="source2.pdf",
|
||||
filename="file2.pdf"
|
||||
),
|
||||
]
|
||||
|
||||
context, used = build_context(results)
|
||||
|
||||
assert "First content" in context
|
||||
assert "Second content" in context
|
||||
assert "file1.pdf" in context # Should use filename
|
||||
assert "file2.pdf" in context
|
||||
assert len(used) == 2
|
||||
|
||||
def test_build_context_source_fallback(self):
|
||||
"""Test that build_context falls back to top-level source when metadata is empty."""
|
||||
from praisonaiagents.rag.context import build_context
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
# SearchResultItem with source but empty metadata
|
||||
results = [
|
||||
SearchResultItem(
|
||||
text="Content with source",
|
||||
source="fallback.pdf",
|
||||
metadata={} # Empty metadata, should fall back to top-level source
|
||||
)
|
||||
]
|
||||
|
||||
context, used = build_context(results, include_source=True)
|
||||
|
||||
assert "Content with source" in context
|
||||
assert "fallback.pdf" in context # Should use top-level source
|
||||
assert len(used) == 1
|
||||
|
||||
def test_build_context_mixed_formats_with_sources(self):
|
||||
"""Test building context with mixed dict/SearchResultItem formats."""
|
||||
from praisonaiagents.rag.context import build_context
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
{"text": "Dict content", "metadata": {"filename": "dict.pdf"}},
|
||||
SearchResultItem(
|
||||
text="Object content",
|
||||
filename="object.pdf",
|
||||
metadata={}
|
||||
),
|
||||
]
|
||||
|
||||
context, used = build_context(results, include_source=True)
|
||||
|
||||
assert "Dict content" in context
|
||||
assert "Object content" in context
|
||||
assert "dict.pdf" in context
|
||||
assert "object.pdf" in context # Should use top-level filename
|
||||
assert len(used) == 2
|
||||
@@ -8,6 +8,7 @@ from praisonaiagents.rag.context import (
|
||||
deduplicate_chunks,
|
||||
build_context,
|
||||
)
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
|
||||
class TestDeduplicateChunksNormalization:
|
||||
@@ -122,3 +123,25 @@ class TestEdgeCases:
|
||||
context, used = build_context(results)
|
||||
assert len(used) == 1
|
||||
assert "valid content" in context
|
||||
|
||||
|
||||
class TestSearchResultItemCompatibility:
|
||||
"""Tests for SearchResultItem object compatibility."""
|
||||
|
||||
def test_deduplicate_uses_source_attribute_fallback(self):
|
||||
"""Different source attributes should produce unique chunks."""
|
||||
results = [
|
||||
SearchResultItem(text="same content", source="a.pdf"),
|
||||
SearchResultItem(text="same content", source="b.pdf"),
|
||||
]
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2
|
||||
|
||||
def test_build_context_uses_filename_attribute_fallback(self):
|
||||
"""Source label should use object filename/source attributes."""
|
||||
results = [
|
||||
SearchResultItem(text="chunk", filename="doc.md"),
|
||||
]
|
||||
context, used = build_context(results, include_source=True)
|
||||
assert len(used) == 1
|
||||
assert "doc.md" in context
|
||||
@@ -103,6 +103,73 @@ class TestHierarchicalSessionStore:
|
||||
"""Clean up test fixtures."""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_add_message_preserves_concurrent_writes(self):
|
||||
"""Concurrent add_message must not lose messages (same as DefaultSessionStore)."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
reader = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
def test_get_extended_session_sees_writes_from_other_store(self):
|
||||
"""Extended reads must reload from disk, not stale _extended_cache."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
reader = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_extended_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
session = reader.get_extended_session("session-1")
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[1].content == "second"
|
||||
|
||||
def test_stale_cache_write_preserves_concurrent_updates(self):
|
||||
"""Metadata writes must not truncate messages written by other processes."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
reader = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
|
||||
# Create session and warm reader's cache
|
||||
writer.create_session("session-1", title="Original")
|
||||
reader.get_extended_session("session-1") # Warms cache
|
||||
|
||||
# Writer adds messages, reader has stale cache
|
||||
writer.add_user_message("session-1", "first message")
|
||||
writer.add_assistant_message("session-1", "first response")
|
||||
|
||||
# Reader performs metadata-only write with stale cache
|
||||
reader.set_title("session-1", "Updated Title")
|
||||
|
||||
# Verify messages are preserved
|
||||
session = writer.get_extended_session("session-1")
|
||||
assert session.title == "Updated Title"
|
||||
assert len(session.messages) == 2
|
||||
assert session.messages[0].content == "first message"
|
||||
assert session.messages[1].content == "first response"
|
||||
|
||||
def test_update_session_metadata_preserves_extended_fields(self):
|
||||
"""Metadata updates must not strip parent_id, snapshots, etc."""
|
||||
session_id = self.store.create_session(title="Parent")
|
||||
self.store.add_message(session_id, "user", "hello")
|
||||
parent_id = self.store.create_session(title="Child", parent_id=session_id)
|
||||
snapshot_id = self.store.create_snapshot(session_id, label="checkpoint")
|
||||
|
||||
assert self.store.update_session_metadata(session_id, model="gpt-4o-mini")
|
||||
|
||||
session = self.store.get_extended_session(session_id)
|
||||
assert session.metadata.get("model") == "gpt-4o-mini"
|
||||
assert session.title == "Parent"
|
||||
assert parent_id in session.children_ids
|
||||
assert any(s.id == snapshot_id for s in session.snapshots)
|
||||
|
||||
def test_create_session(self):
|
||||
"""Test creating a session."""
|
||||
session_id = self.store.create_session(title="Test Session")
|
||||
@@ -295,6 +362,94 @@ class TestHierarchicalSessionStore:
|
||||
new_id = self.store.import_session(exported, new_session_id=custom_id)
|
||||
|
||||
assert new_id == custom_id
|
||||
|
||||
def test_set_title_does_not_drop_messages_after_external_write(self):
|
||||
"""
|
||||
Regression test for the stale cache bug.
|
||||
|
||||
Reproduces the scenario where:
|
||||
1. Process A loads session (warms cache)
|
||||
2. Process B writes new messages
|
||||
3. Process A calls set_title() → should NOT drop Process B's messages
|
||||
"""
|
||||
import json
|
||||
|
||||
# Create session with initial messages
|
||||
session_id = self.store.create_session(title="Test Session")
|
||||
self.store.add_message(session_id, "user", "Message 1")
|
||||
self.store.add_message(session_id, "assistant", "Response 1")
|
||||
|
||||
# Process A: Load session (warms cache)
|
||||
session_a = self.store.get_extended_session(session_id)
|
||||
assert len(session_a.messages) == 2
|
||||
|
||||
# Process B: Simulate external write by directly modifying file
|
||||
# This mimics another process/store instance writing to the same session
|
||||
filepath = self.store._get_session_path(session_id)
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
# Add messages from "Process B"
|
||||
data["messages"].extend([
|
||||
{"role": "user", "content": "Message 2", "timestamp": time.time(), "metadata": {}},
|
||||
{"role": "assistant", "content": "Response 2", "timestamp": time.time(), "metadata": {}}
|
||||
])
|
||||
data["updated_at"] = time.time()
|
||||
|
||||
# Write the updated data (simulating external process write)
|
||||
with open(filepath, "w", encoding="utf-8") as f:
|
||||
json.dump(data, f, indent=2)
|
||||
|
||||
# Brief sleep to ensure file mtime is different
|
||||
time.sleep(0.1)
|
||||
|
||||
# Process A: Call set_title() - this should detect the external write
|
||||
# and reload fresh data instead of using stale cache
|
||||
result = self.store.set_title(session_id, "Updated Title")
|
||||
assert result is True
|
||||
|
||||
# Verify no messages were lost - should have all 4 messages
|
||||
final_session = self.store.get_extended_session(session_id)
|
||||
assert len(final_session.messages) == 4, f"Expected 4 messages, got {len(final_session.messages)}"
|
||||
assert final_session.title == "Updated Title"
|
||||
assert final_session.messages[0].content == "Message 1"
|
||||
assert final_session.messages[1].content == "Response 1"
|
||||
assert final_session.messages[2].content == "Message 2"
|
||||
assert final_session.messages[3].content == "Response 2"
|
||||
|
||||
def test_cache_performance_with_unchanged_files(self):
|
||||
"""
|
||||
Test that performance optimization works - reads from cache when file hasn't changed.
|
||||
"""
|
||||
session_id = self.store.create_session(title="Cache Test")
|
||||
self.store.add_message(session_id, "user", "Test message")
|
||||
|
||||
# First read - loads from disk and caches
|
||||
session1 = self.store.get_extended_session(session_id)
|
||||
assert len(session1.messages) == 1
|
||||
|
||||
# Second read should use cache (file hasn't changed)
|
||||
# We can't easily test this directly, but we can verify the cache is valid
|
||||
assert self.store._is_cache_valid(session_id) is True
|
||||
|
||||
session2 = self.store.get_extended_session(session_id)
|
||||
assert len(session2.messages) == 1
|
||||
assert session2 is session1 # Should be same cached object
|
||||
|
||||
def test_force_reload_bypasses_cache(self):
|
||||
"""Test that force_reload=True always loads from disk."""
|
||||
session_id = self.store.create_session(title="Force Reload Test")
|
||||
self.store.add_message(session_id, "user", "Message 1")
|
||||
|
||||
# Load and cache
|
||||
session1 = self.store._load_extended_session(session_id, force_reload=False)
|
||||
|
||||
# Force reload should bypass cache
|
||||
session2 = self.store._load_extended_session(session_id, force_reload=True)
|
||||
|
||||
# Both should have same data but force_reload ensures fresh read
|
||||
assert len(session1.messages) == len(session2.messages)
|
||||
assert session1.session_id == session2.session_id
|
||||
|
||||
|
||||
class TestGlobalHierarchicalStore:
|
||||
|
||||
@@ -9,8 +9,11 @@ import os
|
||||
import tempfile
|
||||
import threading
|
||||
import time
|
||||
import builtins
|
||||
import importlib
|
||||
import pytest
|
||||
from concurrent.futures import ThreadPoolExecutor
|
||||
from unittest.mock import patch
|
||||
|
||||
from praisonaiagents.session.store import (
|
||||
DefaultSessionStore,
|
||||
@@ -160,6 +163,26 @@ class TestFileLock:
|
||||
if start_idx < other_start < end_idx:
|
||||
pytest.fail(f"Lock interleaving detected: {results}")
|
||||
|
||||
def test_import_without_fcntl(self):
|
||||
"""Test module import succeeds when fcntl is unavailable."""
|
||||
import praisonaiagents.session.store as store_module
|
||||
import importlib.util
|
||||
original_import = builtins.__import__
|
||||
|
||||
def import_without_fcntl(name, *args, **kwargs):
|
||||
if name == "fcntl":
|
||||
raise ImportError("fcntl unavailable")
|
||||
return original_import(name, *args, **kwargs)
|
||||
|
||||
try:
|
||||
with patch("builtins.__import__", side_effect=import_without_fcntl):
|
||||
reloaded_module = importlib.reload(store_module)
|
||||
assert reloaded_module._HAS_FCNTL is False
|
||||
finally:
|
||||
restored_module = importlib.reload(store_module)
|
||||
|
||||
assert restored_module._HAS_FCNTL is (importlib.util.find_spec("fcntl") is not None)
|
||||
|
||||
|
||||
class TestDefaultSessionStore:
|
||||
"""Tests for DefaultSessionStore."""
|
||||
@@ -282,6 +305,116 @@ class TestDefaultSessionStore:
|
||||
session = temp_store.get_session("session-1")
|
||||
assert session.agent_name == "TestBot"
|
||||
assert session.user_id == "user-123"
|
||||
|
||||
def test_update_session_metadata_preserves_messages(self, temp_store):
|
||||
"""Metadata updates must not drop messages added by another store instance."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
assert reader.update_session_metadata(
|
||||
"session-1", model="gpt-4o-mini", total_tokens=42
|
||||
)
|
||||
|
||||
writer.invalidate_cache("session-1")
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
session = writer.get_session("session-1")
|
||||
assert session.metadata.get("model") == "gpt-4o-mini"
|
||||
assert session.metadata.get("total_tokens") == 42
|
||||
|
||||
def test_set_agent_info_preserves_messages(self, temp_store):
|
||||
"""Agent info updates must not drop messages added by another store instance."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
assert reader.set_agent_info("session-1", agent_name="TestBot", user_id="u-1")
|
||||
|
||||
writer.invalidate_cache("session-1")
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
session = writer.get_session("session-1")
|
||||
assert session.agent_name == "TestBot"
|
||||
assert session.user_id == "u-1"
|
||||
|
||||
def test_set_chat_history_replaces_under_lock(self, temp_store):
|
||||
"""History replace is a single locked write (not clear + N adds)."""
|
||||
temp_store.add_user_message("session-1", "old")
|
||||
assert temp_store.set_chat_history(
|
||||
"session-1",
|
||||
[
|
||||
{"role": "user", "content": "new"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
],
|
||||
)
|
||||
assert temp_store.get_chat_history("session-1") == [
|
||||
{"role": "user", "content": "new"},
|
||||
{"role": "assistant", "content": "reply"},
|
||||
]
|
||||
|
||||
def test_get_chat_history_sees_writes_from_other_store(self, temp_store):
|
||||
"""Reads must reload from disk, not a stale in-memory cache."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
history = reader.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
def test_clear_session_preserves_new_messages(self, temp_store):
|
||||
"""Clear must reload from disk so concurrent adds are not lost."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
assert reader.clear_session("session-1")
|
||||
|
||||
writer.invalidate_cache("session-1")
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 0
|
||||
|
||||
def test_set_gateway_info_preserves_messages(self, temp_store):
|
||||
"""Gateway info updates must not drop messages added by another store instance."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
assert reader.set_gateway_info("session-1", gateway_session_id="gw-123", agent_id="agent-456")
|
||||
|
||||
writer.invalidate_cache("session-1")
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
session = writer.get_session("session-1")
|
||||
assert session.gateway_session_id == "gw-123"
|
||||
assert session.agent_id == "agent-456"
|
||||
|
||||
def test_concurrent_writes(self, temp_store):
|
||||
"""Test concurrent writes to same session."""
|
||||
@@ -313,7 +446,7 @@ class TestDefaultSessionStore:
|
||||
assert history == []
|
||||
|
||||
def test_invalidate_cache(self, temp_store):
|
||||
"""Test cache invalidation."""
|
||||
"""Test that reads always see latest disk state."""
|
||||
temp_store.add_user_message("session-1", "Hello")
|
||||
|
||||
# Modify file directly
|
||||
@@ -324,14 +457,28 @@ class TestDefaultSessionStore:
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
# Without invalidation, cache returns old data
|
||||
# Reads always reload from disk (no stale cache)
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 1 # Cached
|
||||
|
||||
# After invalidation, new data is loaded
|
||||
assert len(history) == 2
|
||||
|
||||
# invalidate_cache still clears in-memory state when a file is missing
|
||||
temp_store.invalidate_cache("session-1")
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
|
||||
def test_get_chat_history_sees_other_store_instance(self):
|
||||
"""Another store on the same session_dir must see new messages without invalidate_cache."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
history = reader.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
def test_list_sessions_with_none_updated_at(self, temp_store):
|
||||
"""Test list_sessions handles None updated_at values without crashing.
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Tests for skill capability validation."""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from praisonaiagents.skills.models import SkillProperties, SkillRequirements, SkillState
|
||||
from praisonaiagents.skills.capability_validator import (
|
||||
CapabilityValidator,
|
||||
EnforcementLevel,
|
||||
ValidationResult
|
||||
)
|
||||
|
||||
|
||||
class TestSkillRequirements:
|
||||
"""Test SkillRequirements parsing and normalization."""
|
||||
|
||||
def test_from_frontmatter_empty(self):
|
||||
"""Test parsing empty frontmatter."""
|
||||
requirements = SkillRequirements.from_frontmatter({})
|
||||
assert requirements.is_empty()
|
||||
assert not requirements # Test __bool__
|
||||
|
||||
def test_from_frontmatter_tools_list(self):
|
||||
"""Test parsing tools from list."""
|
||||
metadata = {"requires_tools": ["web_search", "file_write"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.tools == ["web_search", "file_write"]
|
||||
assert requirements.servers == []
|
||||
assert not requirements.is_empty()
|
||||
|
||||
def test_from_frontmatter_tools_string(self):
|
||||
"""Test parsing tools from string."""
|
||||
metadata = {"requires-tools": "web_search file_write"}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.tools == ["web_search", "file_write"]
|
||||
|
||||
def test_from_frontmatter_backward_compatibility(self):
|
||||
"""Test backward compatibility with allowed-tools."""
|
||||
metadata = {"allowed-tools": ["existing_tool"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.tools == ["existing_tool"]
|
||||
|
||||
def test_from_frontmatter_servers(self):
|
||||
"""Test parsing server requirements."""
|
||||
metadata = {"requires_servers": ["mcp:filesystem", "http:internal"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.servers == ["mcp:filesystem", "http:internal"]
|
||||
|
||||
def test_from_frontmatter_env_vars(self):
|
||||
"""Test parsing environment variable requirements."""
|
||||
metadata = {"requires_env": ["API_KEY", "SECRET_TOKEN"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.env_vars == ["API_KEY", "SECRET_TOKEN"]
|
||||
|
||||
def test_from_frontmatter_openclaw_hints(self):
|
||||
"""Test parsing OpenClaw hints."""
|
||||
metadata = {"openclaw": {"version": "1.0", "compatibility": "hermes"}}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.openclaw_hints == {"version": "1.0", "compatibility": "hermes"}
|
||||
|
||||
def test_normalize_list(self):
|
||||
"""Test list normalization utility."""
|
||||
assert SkillRequirements._normalize_list("a b c") == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list("a,b,c") == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list("a, b c") == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list(["a", "b", "c"]) == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list("") == []
|
||||
assert SkillRequirements._normalize_list([]) == []
|
||||
assert SkillRequirements._normalize_list(123) == []
|
||||
|
||||
|
||||
class TestCapabilityValidator:
|
||||
"""Test capability validation logic."""
|
||||
|
||||
def test_enforcement_levels(self):
|
||||
"""Test different enforcement levels."""
|
||||
assert EnforcementLevel.DISABLED.value == "disabled"
|
||||
assert EnforcementLevel.WARN.value == "warn"
|
||||
assert EnforcementLevel.STRICT.value == "strict"
|
||||
|
||||
def test_validator_initialization(self):
|
||||
"""Test validator initialization."""
|
||||
validator = CapabilityValidator()
|
||||
assert validator.enforcement_level == EnforcementLevel.WARN
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.STRICT)
|
||||
assert validator.enforcement_level == EnforcementLevel.STRICT
|
||||
|
||||
def test_validate_skill_no_requirements(self):
|
||||
"""Test validation of skill with no requirements."""
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=None
|
||||
)
|
||||
|
||||
validator = CapabilityValidator()
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.skill_name == "test-skill"
|
||||
assert result.state == SkillState.ACTIVE
|
||||
assert result.is_fully_satisfied
|
||||
assert not result.has_critical_missing
|
||||
|
||||
def test_validate_skill_empty_requirements(self):
|
||||
"""Test validation of skill with empty requirements."""
|
||||
requirements = SkillRequirements()
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator()
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.state == SkillState.ACTIVE
|
||||
assert result.is_fully_satisfied
|
||||
|
||||
@patch.dict(os.environ, {"TEST_VAR": "value"})
|
||||
def test_validate_skill_satisfied_requirements(self):
|
||||
"""Test validation with all requirements satisfied."""
|
||||
requirements = SkillRequirements(
|
||||
tools=["available_tool"],
|
||||
servers=["available_server"],
|
||||
env_vars=["TEST_VAR"]
|
||||
)
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
available_tools = {"available_tool"}
|
||||
available_servers = {"available_server"}
|
||||
|
||||
validator = CapabilityValidator()
|
||||
result = validator.validate_skill(
|
||||
skill,
|
||||
available_tools=available_tools,
|
||||
available_servers=available_servers
|
||||
)
|
||||
|
||||
assert result.state == SkillState.ACTIVE
|
||||
assert result.satisfied_tools == ["available_tool"]
|
||||
assert result.satisfied_servers == ["available_server"]
|
||||
assert result.satisfied_env_vars == ["TEST_VAR"]
|
||||
assert result.is_fully_satisfied
|
||||
|
||||
def test_validate_skill_missing_tools_warn_mode(self):
|
||||
"""Test validation with missing tools in warn mode."""
|
||||
requirements = SkillRequirements(tools=["missing_tool"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.WARN)
|
||||
result = validator.validate_skill(
|
||||
skill,
|
||||
available_tools=set(),
|
||||
available_servers=set()
|
||||
)
|
||||
|
||||
assert result.state == SkillState.DEGRADED
|
||||
assert result.missing_tools == ["missing_tool"]
|
||||
assert not result.is_fully_satisfied
|
||||
assert result.has_critical_missing
|
||||
assert len(result.warnings) == 1
|
||||
assert "missing_tool" in result.warnings[0]
|
||||
|
||||
def test_validate_skill_missing_tools_strict_mode(self):
|
||||
"""Test validation with missing tools in strict mode."""
|
||||
requirements = SkillRequirements(tools=["missing_tool"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.STRICT)
|
||||
result = validator.validate_skill(
|
||||
skill,
|
||||
available_tools=set(),
|
||||
available_servers=set()
|
||||
)
|
||||
|
||||
assert result.state == SkillState.UNAVAILABLE
|
||||
assert result.missing_tools == ["missing_tool"]
|
||||
assert not result.is_fully_satisfied
|
||||
assert result.has_critical_missing
|
||||
assert len(result.errors) == 1
|
||||
assert "missing_tool" in result.errors[0]
|
||||
|
||||
def test_validate_skill_missing_env_vars_warn(self):
|
||||
"""Test validation with missing environment variables in warn mode."""
|
||||
requirements = SkillRequirements(env_vars=["MISSING_VAR"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.WARN)
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.state == SkillState.DEGRADED
|
||||
assert result.missing_env_vars == ["MISSING_VAR"]
|
||||
assert len(result.warnings) == 1
|
||||
assert len(result.errors) == 0
|
||||
|
||||
def test_validate_skill_missing_env_vars_strict(self):
|
||||
"""Test validation with missing environment variables in strict mode."""
|
||||
requirements = SkillRequirements(env_vars=["MISSING_VAR"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.STRICT)
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.state == SkillState.UNAVAILABLE
|
||||
assert result.missing_env_vars == ["MISSING_VAR"]
|
||||
assert len(result.warnings) == 0
|
||||
assert len(result.errors) == 1
|
||||
|
||||
def test_validation_result_to_dict(self):
|
||||
"""Test ValidationResult serialization."""
|
||||
result = ValidationResult(
|
||||
skill_name="test-skill",
|
||||
state=SkillState.DEGRADED,
|
||||
satisfied_tools=["tool1"],
|
||||
missing_tools=["tool2"],
|
||||
satisfied_servers=[],
|
||||
missing_servers=[],
|
||||
satisfied_env_vars=[],
|
||||
missing_env_vars=["VAR1"],
|
||||
warnings=["Missing tool2"],
|
||||
errors=[]
|
||||
)
|
||||
|
||||
data = result.to_dict()
|
||||
assert data["skill_name"] == "test-skill"
|
||||
assert data["state"] == "degraded"
|
||||
assert data["satisfied_tools"] == ["tool1"]
|
||||
assert data["missing_tools"] == ["tool2"]
|
||||
assert data["warnings"] == ["Missing tool2"]
|
||||
assert data["is_fully_satisfied"] == False
|
||||
assert data["has_critical_missing"] == True
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for the full capability validation system."""
|
||||
|
||||
def test_skill_parsing_with_requirements(self):
|
||||
"""Test that skills parse requirements correctly."""
|
||||
from praisonaiagents.skills.parser import read_properties
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
skill_dir = Path(temp_dir) / "test-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text('''---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
requires_tools:
|
||||
- web_search
|
||||
- file_write
|
||||
requires_servers:
|
||||
- mcp:filesystem
|
||||
requires_env:
|
||||
- API_KEY
|
||||
openclaw:
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Test Skill
|
||||
|
||||
This is a test skill.
|
||||
''')
|
||||
|
||||
properties = read_properties(skill_dir)
|
||||
assert properties.requirements is not None
|
||||
assert properties.requirements.tools == ["web_search", "file_write"]
|
||||
assert properties.requirements.servers == ["mcp:filesystem"]
|
||||
assert properties.requirements.env_vars == ["API_KEY"]
|
||||
assert properties.requirements.openclaw_hints == {"version": "1.0"}
|
||||
|
||||
def test_skill_manager_enforcement(self):
|
||||
"""Test that SkillManager respects enforcement levels."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
# Test that manager initializes with default enforcement
|
||||
manager = SkillManager()
|
||||
assert manager._validator.enforcement_level in [
|
||||
EnforcementLevel.WARN,
|
||||
EnforcementLevel.DISABLED,
|
||||
EnforcementLevel.STRICT,
|
||||
EnforcementLevel.TELEMETRY
|
||||
]
|
||||
|
||||
# Test explicit enforcement level
|
||||
strict_manager = SkillManager(EnforcementLevel.STRICT)
|
||||
assert strict_manager._validator.enforcement_level == EnforcementLevel.STRICT
|
||||
@@ -241,3 +241,89 @@ Body.
|
||||
loader.activate(skill)
|
||||
|
||||
assert skill.is_activated is True
|
||||
|
||||
def test_load_utf8_content(self):
|
||||
"""Test loading UTF-8 encoded skill content.
|
||||
|
||||
Regression test for Windows encoding issues (Issue #1695).
|
||||
Tests UTF-8 handling in SKILL.md body, scripts, and references.
|
||||
"""
|
||||
from praisonaiagents.skills.loader import SkillLoader
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "utf8-loader-test"
|
||||
skill_dir.mkdir()
|
||||
|
||||
# Create SKILL.md with UTF-8 characters
|
||||
skill_content = """---
|
||||
name: utf8-loader-test
|
||||
description: Loader test with UTF-8 — em dashes — and more
|
||||
---
|
||||
|
||||
# UTF-8 Instructions
|
||||
|
||||
This skill handles UTF-8 content:
|
||||
- Smart quotes: "hello" and 'world'
|
||||
- Em dash: —
|
||||
- Arrows: → ← ↑ ↓
|
||||
- Accented: café, naïve, résumé
|
||||
- Emoji: 🔥 📝 ✨
|
||||
"""
|
||||
|
||||
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
|
||||
f.write(skill_content)
|
||||
|
||||
# Create scripts directory with UTF-8 script
|
||||
scripts_dir = skill_dir / "scripts"
|
||||
scripts_dir.mkdir()
|
||||
|
||||
script_content = """#!/bin/bash
|
||||
# Script with UTF-8: café processing → résumé generation
|
||||
echo "Processing naïve input with émphasis"
|
||||
"""
|
||||
|
||||
with open(scripts_dir / "process.sh", 'w', encoding='utf-8') as f:
|
||||
f.write(script_content)
|
||||
|
||||
# Create references directory with UTF-8 content
|
||||
refs_dir = skill_dir / "references"
|
||||
refs_dir.mkdir()
|
||||
|
||||
ref_content = """Reference with UTF-8:
|
||||
Café automation guide — step by step
|
||||
Arrow symbols: → ← ↑ ↓
|
||||
Special chars: "smart quotes" and émphasis
|
||||
"""
|
||||
|
||||
with open(refs_dir / "readme.txt", 'w', encoding='utf-8') as f:
|
||||
f.write(ref_content)
|
||||
|
||||
# Test loading and activation
|
||||
loader = SkillLoader()
|
||||
skill = loader.load_metadata(str(skill_dir))
|
||||
|
||||
assert skill is not None
|
||||
assert "—" in skill.properties.description # UTF-8 in metadata
|
||||
|
||||
# Test activation (loads SKILL.md body)
|
||||
success = loader.activate(skill)
|
||||
assert success is True
|
||||
assert "—" in skill.instructions # UTF-8 in instructions
|
||||
assert "café" in skill.instructions
|
||||
assert "🔥" in skill.instructions # Emoji
|
||||
|
||||
# Test script loading
|
||||
scripts = loader.load_scripts(skill)
|
||||
assert "process.sh" in scripts
|
||||
script_text = scripts["process.sh"]
|
||||
assert "café" in script_text
|
||||
assert "résumé" in script_text
|
||||
assert "naïve" in script_text
|
||||
|
||||
# Test references loading
|
||||
refs = loader.load_references(skill)
|
||||
assert "readme.txt" in refs
|
||||
ref_text = refs["readme.txt"]
|
||||
assert "Café" in ref_text
|
||||
assert "—" in ref_text
|
||||
assert "émphasis" in ref_text
|
||||
@@ -286,3 +286,54 @@ Body.
|
||||
assert False, "Should have raised ValidationError"
|
||||
except ValidationError as e:
|
||||
assert "description" in str(e).lower()
|
||||
|
||||
def test_read_properties_utf8_encoding(self):
|
||||
"""Test reading UTF-8 encoded SKILL.md files.
|
||||
|
||||
Regression test for Windows encoding issues (Issue #1695).
|
||||
Verifies that skills with non-ASCII characters load correctly.
|
||||
"""
|
||||
from praisonaiagents.skills.parser import read_properties
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "utf8-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
# Create SKILL.md with various UTF-8 characters
|
||||
utf8_content = """---
|
||||
name: utf8-test
|
||||
description: UTF-8 encoding test — quality — verification
|
||||
license: MIT
|
||||
compatibility: Supports café files → résumé processing
|
||||
metadata:
|
||||
author: "José María"
|
||||
notes: "Handles naïve input with émphasis"
|
||||
---
|
||||
|
||||
# UTF-8 Test Skill
|
||||
|
||||
This skill demonstrates UTF-8 character handling:
|
||||
- Em dash: —
|
||||
- Smart quotes: "hello" and 'world'
|
||||
- Arrow: →
|
||||
- Accented chars: café, naïve, résumé
|
||||
- Emoji: 🔥 📝 ✨
|
||||
|
||||
Works with internationalization!
|
||||
"""
|
||||
|
||||
# Explicitly write as UTF-8 to simulate real-world scenario
|
||||
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
|
||||
f.write(utf8_content)
|
||||
|
||||
# Test that parsing works without UnicodeDecodeError
|
||||
props = read_properties(skill_dir)
|
||||
|
||||
# Verify UTF-8 characters are preserved correctly
|
||||
assert props.name == "utf8-test"
|
||||
assert "—" in props.description # em dash
|
||||
assert "café" in props.compatibility # accented character
|
||||
assert "résumé" in props.compatibility # accented character
|
||||
assert "José María" in props.metadata["author"] # accented characters
|
||||
assert "naïve" in props.metadata["notes"] # accented character
|
||||
assert "émphasis" in props.metadata["notes"] # accented character
|
||||
@@ -295,3 +295,58 @@ description: A test skill for validation
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("directory" in e.lower() for e in errors)
|
||||
|
||||
def test_validate_utf8_skill(self):
|
||||
"""Test validation of UTF-8 encoded skill.
|
||||
|
||||
Regression test for Windows encoding issues (Issue #1695).
|
||||
Ensures validator can handle UTF-8 characters without errors.
|
||||
"""
|
||||
from praisonaiagents.skills.validator import validate
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "utf8-validation-test"
|
||||
skill_dir.mkdir()
|
||||
|
||||
# Create SKILL.md with UTF-8 content
|
||||
utf8_content = """---
|
||||
name: utf8-validation-test
|
||||
description: Validation test with UTF-8 — em dashes — and accents
|
||||
license: MIT
|
||||
compatibility: Works with café files → résumé output
|
||||
---
|
||||
|
||||
# UTF-8 Validation Test
|
||||
|
||||
Testing validation with various UTF-8 characters.
|
||||
"""
|
||||
|
||||
# Write as UTF-8 explicitly
|
||||
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
|
||||
f.write(utf8_content)
|
||||
|
||||
# Validate should not produce errors for UTF-8 content
|
||||
errors = validate(skill_dir)
|
||||
|
||||
assert errors == [], f"Validation should pass for UTF-8 content, got errors: {errors}"
|
||||
|
||||
def test_validate_non_utf8_skill_returns_error(self):
|
||||
"""Test validation returns a structured error for non-UTF-8 SKILL.md."""
|
||||
from praisonaiagents.skills.validator import validate
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "latin1-validation-test"
|
||||
skill_dir.mkdir()
|
||||
|
||||
latin1_content = """---
|
||||
name: latin1-validation-test
|
||||
description: Café skill
|
||||
---
|
||||
"""
|
||||
with open(skill_dir / "SKILL.md", "wb") as f:
|
||||
f.write(latin1_content.encode("cp1252"))
|
||||
|
||||
errors = validate(skill_dir)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("utf-8" in e.lower() for e in errors)
|
||||
@@ -225,3 +225,19 @@ class TestToolCallIdPropagation:
|
||||
)
|
||||
|
||||
assert event.tool_call["id"] == "call_abc123xyz"
|
||||
|
||||
|
||||
class TestToolExecutionMixinStreamEmitterLookup:
|
||||
def test_mixin_resolves_name_mangled_stream_emitter_without_agent_class_name(self):
|
||||
from praisonaiagents.agent.tool_execution import ToolExecutionMixin
|
||||
from praisonaiagents.streaming.events import StreamEventEmitter
|
||||
|
||||
class CustomAgent(ToolExecutionMixin):
|
||||
def __init__(self):
|
||||
self.__stream_emitter = StreamEventEmitter()
|
||||
|
||||
agent = CustomAgent()
|
||||
emitter = agent._get_existing_stream_emitter()
|
||||
|
||||
assert emitter is not None
|
||||
assert isinstance(emitter, StreamEventEmitter)
|
||||
@@ -134,3 +134,37 @@ class TestTokenCollectorSinkIntegration:
|
||||
)
|
||||
assert sink.records[0]["task_id"] == "task-abc"
|
||||
assert sink.records[0]["agent_name"] == "researcher"
|
||||
|
||||
|
||||
class TestInMemoryUsageQuery:
|
||||
"""Test query adapter backed by in-memory sink records."""
|
||||
|
||||
def test_get_summary_uses_total_tokens_when_present(self):
|
||||
from praisonaiagents.telemetry.protocols import InMemoryTokenUsageSink, InMemoryUsageQuery
|
||||
|
||||
sink = InMemoryTokenUsageSink()
|
||||
sink.records.extend([
|
||||
{
|
||||
"task_id": "t1",
|
||||
"agent_name": "a1",
|
||||
"model": "m1",
|
||||
"input_tokens": 10,
|
||||
"output_tokens": 5,
|
||||
"total_tokens": 50,
|
||||
},
|
||||
{
|
||||
"task_id": "t2",
|
||||
"agent_name": "a1",
|
||||
"model": "m2",
|
||||
"input_tokens": 2,
|
||||
"output_tokens": 3,
|
||||
},
|
||||
])
|
||||
|
||||
summary = InMemoryUsageQuery(sink).get_summary()
|
||||
|
||||
assert summary["total_input_tokens"] == 12
|
||||
assert summary["total_output_tokens"] == 8
|
||||
assert summary["total_tokens"] == 55
|
||||
assert summary["by_model"]["m1"]["total_tokens"] == 50
|
||||
assert summary["by_model"]["m2"]["total_tokens"] == 5
|
||||
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Unit tests for kanban protocols and hook events.
|
||||
|
||||
Tests the protocol contracts and hook events without any heavy
|
||||
implementations like SQLite or LLM calls.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from typing import runtime_checkable
|
||||
|
||||
from praisonaiagents.kanban.protocols import (
|
||||
KanbanStoreProtocol,
|
||||
KanbanTaskProtocol,
|
||||
KanbanCommentingProtocol,
|
||||
KanbanLinkingProtocol,
|
||||
VALID_KANBAN_STATUSES,
|
||||
)
|
||||
from praisonaiagents.hooks.types import (
|
||||
HookEvent,
|
||||
KanbanHookInput,
|
||||
)
|
||||
|
||||
|
||||
class TestKanbanProtocol:
|
||||
"""Test KanbanStoreProtocol runtime checking and constants."""
|
||||
|
||||
def test_kanban_store_protocol_is_runtime_checkable(self):
|
||||
"""Test that KanbanStoreProtocol is @runtime_checkable."""
|
||||
assert runtime_checkable(KanbanStoreProtocol)
|
||||
|
||||
def test_valid_kanban_statuses_includes_ui_columns(self):
|
||||
"""Test that VALID_KANBAN_STATUSES includes all PraisonAIUI columns."""
|
||||
expected_statuses = {
|
||||
"triage",
|
||||
"todo",
|
||||
"ready",
|
||||
"running",
|
||||
"blocked",
|
||||
"review",
|
||||
"done",
|
||||
"archived"
|
||||
}
|
||||
assert VALID_KANBAN_STATUSES == expected_statuses
|
||||
|
||||
def test_valid_kanban_statuses_is_frozen(self):
|
||||
"""Test that VALID_KANBAN_STATUSES is immutable."""
|
||||
assert isinstance(VALID_KANBAN_STATUSES, frozenset)
|
||||
|
||||
def test_mock_store_passes_isinstance_check(self):
|
||||
"""Test that a mock store implementing the core protocol passes isinstance check."""
|
||||
|
||||
class MockKanbanStore:
|
||||
"""Mock implementation for testing - only core methods required."""
|
||||
|
||||
def get_board(self, *, board="default", tenant=None, include_archived=False):
|
||||
return {"board": board, "tasks": []}
|
||||
|
||||
def get_task(self, task_id: str):
|
||||
return {"id": task_id, "title": "Test Task"}
|
||||
|
||||
def create_task(self, data: dict):
|
||||
return {"id": "task_123", **data}
|
||||
|
||||
def update_task(self, task_id: str, data: dict):
|
||||
return {"id": task_id, **data}
|
||||
|
||||
def move_task(self, task_id: str, status: str):
|
||||
return {"id": task_id, "status": status}
|
||||
|
||||
def bulk_update(self, task_ids: list[str], status: str):
|
||||
return {"updated": len(task_ids), "status": status}
|
||||
|
||||
def delete_task(self, task_id: str):
|
||||
return True
|
||||
|
||||
def list_events(self, since: float = 0.0, board: str = "default"):
|
||||
return []
|
||||
|
||||
def health(self):
|
||||
return {"status": "healthy"}
|
||||
|
||||
mock_store = MockKanbanStore()
|
||||
assert isinstance(mock_store, KanbanStoreProtocol)
|
||||
|
||||
def test_extension_protocol_checks(self):
|
||||
"""Test that extension protocols work independently."""
|
||||
|
||||
class CommentingStore:
|
||||
"""Store that implements commenting functionality."""
|
||||
def add_comment(self, task_id: str, text: str, author: str | None = None):
|
||||
return {"id": "comment_1", "text": text, "author": author}
|
||||
|
||||
class LinkingStore:
|
||||
"""Store that implements linking functionality."""
|
||||
def link_tasks(self, parent_id: str, child_id: str):
|
||||
return True
|
||||
def unlink_tasks(self, parent_id: str, child_id: str):
|
||||
return True
|
||||
|
||||
class FullFeaturedStore:
|
||||
"""Store that implements all protocols."""
|
||||
# Core protocol
|
||||
def get_board(self, *, board="default", tenant=None, include_archived=False):
|
||||
return {"board": board, "tasks": []}
|
||||
def get_task(self, task_id: str):
|
||||
return {"id": task_id}
|
||||
def create_task(self, data: dict):
|
||||
return {"id": "task_123", **data}
|
||||
def update_task(self, task_id: str, data: dict):
|
||||
return {"id": task_id, **data}
|
||||
def move_task(self, task_id: str, status: str):
|
||||
return {"id": task_id, "status": status}
|
||||
def bulk_update(self, task_ids: list[str], status: str):
|
||||
return {"updated": len(task_ids)}
|
||||
def delete_task(self, task_id: str):
|
||||
return True
|
||||
def list_events(self, since: float = 0.0, board: str = "default"):
|
||||
return []
|
||||
def health(self):
|
||||
return {"status": "healthy"}
|
||||
# Extension protocols
|
||||
def add_comment(self, task_id: str, text: str, author: str | None = None):
|
||||
return {"id": "comment_1", "text": text}
|
||||
def link_tasks(self, parent_id: str, child_id: str):
|
||||
return True
|
||||
def unlink_tasks(self, parent_id: str, child_id: str):
|
||||
return True
|
||||
|
||||
commenting_store = CommentingStore()
|
||||
linking_store = LinkingStore()
|
||||
full_store = FullFeaturedStore()
|
||||
|
||||
# Test individual extension protocols
|
||||
assert isinstance(commenting_store, KanbanCommentingProtocol)
|
||||
assert isinstance(linking_store, KanbanLinkingProtocol)
|
||||
|
||||
# Test that extension stores don't implement core protocol
|
||||
assert not isinstance(commenting_store, KanbanStoreProtocol)
|
||||
assert not isinstance(linking_store, KanbanStoreProtocol)
|
||||
|
||||
# Test full featured store implements all protocols
|
||||
assert isinstance(full_store, KanbanStoreProtocol)
|
||||
assert isinstance(full_store, KanbanCommentingProtocol)
|
||||
assert isinstance(full_store, KanbanLinkingProtocol)
|
||||
|
||||
def test_kanban_task_protocol_shape(self):
|
||||
"""Test KanbanTaskProtocol typed dict structure."""
|
||||
# This tests that the TypedDict can be used for type hints
|
||||
# The shape is validated at runtime by mypy/type checkers
|
||||
task: KanbanTaskProtocol = {
|
||||
"id": "task_123",
|
||||
"title": "Test Task",
|
||||
"body": "Task description",
|
||||
"status": "todo",
|
||||
"assignee": "[email protected]",
|
||||
"priority": "high",
|
||||
"tenant": "test_tenant",
|
||||
"board": "default",
|
||||
"created_at": 1640995200.0,
|
||||
"updated_at": 1640995200.0,
|
||||
}
|
||||
|
||||
# Basic validation that we can access the fields
|
||||
assert task["id"] == "task_123"
|
||||
assert task["title"] == "Test Task"
|
||||
assert task["status"] == "todo"
|
||||
|
||||
|
||||
class TestKanbanHookEvents:
|
||||
"""Test kanban hook events and payload types."""
|
||||
|
||||
def test_kanban_hook_events_in_enum(self):
|
||||
"""Test that all kanban hook events are registered in HookEvent."""
|
||||
expected_events = [
|
||||
HookEvent.KANBAN_TASK_CREATED,
|
||||
HookEvent.KANBAN_TASK_CLAIMED,
|
||||
HookEvent.KANBAN_TASK_MOVED,
|
||||
HookEvent.KANBAN_TASK_DONE,
|
||||
HookEvent.KANBAN_TASK_BLOCKED,
|
||||
HookEvent.KANBAN_TASK_FAILED,
|
||||
]
|
||||
|
||||
for event in expected_events:
|
||||
assert event in HookEvent
|
||||
|
||||
def test_kanban_hook_events_serializable(self):
|
||||
"""Test that kanban hook events can be serialized."""
|
||||
events = [
|
||||
HookEvent.KANBAN_TASK_CREATED,
|
||||
HookEvent.KANBAN_TASK_CLAIMED,
|
||||
HookEvent.KANBAN_TASK_MOVED,
|
||||
HookEvent.KANBAN_TASK_DONE,
|
||||
HookEvent.KANBAN_TASK_BLOCKED,
|
||||
HookEvent.KANBAN_TASK_FAILED,
|
||||
]
|
||||
|
||||
for event in events:
|
||||
# Should be able to serialize to JSON and reconstruct enum value
|
||||
serialized = json.dumps({"event": event.value})
|
||||
restored = HookEvent(json.loads(serialized)["event"])
|
||||
assert restored == event
|
||||
|
||||
def test_kanban_hook_input_creation(self):
|
||||
"""Test KanbanHookInput dataclass creation and fields."""
|
||||
hook_input = KanbanHookInput(
|
||||
session_id="session_123",
|
||||
cwd="/home/user",
|
||||
event_name="kanban_task_created",
|
||||
timestamp="2023-01-01T00:00:00Z",
|
||||
task_id="task_456",
|
||||
board="project_board",
|
||||
status="todo",
|
||||
assignee="[email protected]",
|
||||
from_status=None,
|
||||
to_status="running"
|
||||
)
|
||||
|
||||
assert hook_input.session_id == "session_123"
|
||||
assert hook_input.task_id == "task_456"
|
||||
assert hook_input.board == "project_board"
|
||||
assert hook_input.status == "todo"
|
||||
assert hook_input.assignee == "[email protected]"
|
||||
assert hook_input.from_status is None
|
||||
assert hook_input.to_status == "running"
|
||||
|
||||
def test_kanban_hook_input_defaults(self):
|
||||
"""Test KanbanHookInput with default values."""
|
||||
hook_input = KanbanHookInput(
|
||||
session_id="session_123",
|
||||
cwd="/home/user",
|
||||
event_name="kanban_task_created",
|
||||
timestamp="2023-01-01T00:00:00Z"
|
||||
)
|
||||
|
||||
# Check defaults
|
||||
assert hook_input.task_id == ""
|
||||
assert hook_input.board == "default"
|
||||
assert hook_input.status == ""
|
||||
assert hook_input.assignee is None
|
||||
assert hook_input.from_status is None
|
||||
assert hook_input.to_status is None
|
||||
|
||||
def test_kanban_hook_input_to_dict(self):
|
||||
"""Test KanbanHookInput serialization to dict."""
|
||||
hook_input = KanbanHookInput(
|
||||
session_id="session_123",
|
||||
cwd="/home/user",
|
||||
event_name="kanban_task_moved",
|
||||
timestamp="2023-01-01T00:00:00Z",
|
||||
task_id="task_456",
|
||||
board="project_board",
|
||||
from_status="todo",
|
||||
to_status="running"
|
||||
)
|
||||
|
||||
result = hook_input.to_dict()
|
||||
|
||||
assert result["session_id"] == "session_123"
|
||||
assert result["event_name"] == "kanban_task_moved"
|
||||
assert result["task_id"] == "task_456"
|
||||
assert result["board"] == "project_board"
|
||||
assert result["from_status"] == "todo"
|
||||
assert result["to_status"] == "running"
|
||||
|
||||
|
||||
class TestProtocolIntegration:
|
||||
"""Integration tests for protocol usage patterns."""
|
||||
|
||||
def test_protocol_import_structure(self):
|
||||
"""Test that protocols can be imported from expected paths."""
|
||||
# Test direct imports
|
||||
from praisonaiagents.kanban.protocols import KanbanStoreProtocol
|
||||
from praisonaiagents.hooks.types import KanbanHookInput
|
||||
|
||||
assert KanbanStoreProtocol is not None
|
||||
assert KanbanHookInput is not None
|
||||
|
||||
def test_lazy_import_from_package_root(self):
|
||||
"""Test that kanban protocols can be imported from package root."""
|
||||
from praisonaiagents.kanban import (
|
||||
KanbanStoreProtocol,
|
||||
KanbanTaskProtocol,
|
||||
KanbanCommentingProtocol,
|
||||
KanbanLinkingProtocol,
|
||||
VALID_KANBAN_STATUSES,
|
||||
)
|
||||
|
||||
assert KanbanStoreProtocol is not None
|
||||
assert KanbanTaskProtocol is not None
|
||||
assert KanbanCommentingProtocol is not None
|
||||
assert KanbanLinkingProtocol is not None
|
||||
assert VALID_KANBAN_STATUSES is not None
|
||||
@@ -0,0 +1,125 @@
|
||||
"""
|
||||
Unit tests for MCP loader functionality.
|
||||
|
||||
Tests the smoke functionality for the new MCP client lifecycle
|
||||
features implemented in this issue.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import Mock, MagicMock
|
||||
from praisonaiagents.memory.mcp_config import MCPConfig
|
||||
|
||||
|
||||
def test_mcp_config_to_mcp_instance_import_fix():
|
||||
"""Test that MCPConfig.to_mcp_instance() imports MCP correctly (A1)."""
|
||||
# Create a minimal config
|
||||
config = MCPConfig(
|
||||
name="test",
|
||||
command="echo",
|
||||
args=["hello"],
|
||||
enabled=True
|
||||
)
|
||||
|
||||
# This should not raise ImportError anymore
|
||||
# Note: It might return None if MCP package is not installed, but the import should work
|
||||
try:
|
||||
result = config.to_mcp_instance()
|
||||
# If we get here without ImportError from wrong path, the fix worked
|
||||
assert True
|
||||
except ImportError as e:
|
||||
if "praisonaiagents.tools.mcp" in str(e):
|
||||
pytest.fail("Import still using wrong path - A1 fix failed")
|
||||
else:
|
||||
# This is expected if mcp package is not installed
|
||||
assert "praisonaiagents[mcp]" in str(e)
|
||||
|
||||
|
||||
def test_load_mcp_tools_import():
|
||||
"""Test that load_mcp_tools can be imported (B2)."""
|
||||
from praisonaiagents.mcp import load_mcp_tools
|
||||
assert callable(load_mcp_tools)
|
||||
|
||||
|
||||
def test_mcp_client_protocol_import():
|
||||
"""Test that MCPClientProtocol can be imported (B1)."""
|
||||
from praisonaiagents.mcp import MCPClientProtocol
|
||||
# Should be a protocol/type
|
||||
assert MCPClientProtocol is not None
|
||||
|
||||
|
||||
def test_mcp_filter_parameters():
|
||||
"""Test that MCP class accepts filter parameters (B3)."""
|
||||
try:
|
||||
from praisonaiagents.mcp import MCP
|
||||
# This should not raise TypeError for unknown parameters
|
||||
# Note: May raise ImportError if mcp package not installed
|
||||
mcp = MCP("echo hello", allowed_tools=["test"], disabled_tools=["bad"])
|
||||
assert mcp.allowed_tools == ["test"]
|
||||
assert mcp.disabled_tools == ["bad"]
|
||||
except ImportError as e:
|
||||
if "praisonaiagents[mcp]" in str(e):
|
||||
# Expected if MCP package not installed
|
||||
pytest.skip("MCP package not installed")
|
||||
else:
|
||||
raise
|
||||
|
||||
|
||||
def test_filter_tool_functions_exist():
|
||||
"""Test that filter functions exist and work (B3)."""
|
||||
from praisonaiagents.mcp.mcp_utils import filter_tools_by_allowlist, filter_disabled_tools
|
||||
|
||||
tools = [
|
||||
{"name": "tool1", "description": "First tool"},
|
||||
{"name": "tool2", "description": "Second tool"},
|
||||
{"name": "tool3", "description": "Third tool"},
|
||||
]
|
||||
|
||||
# Test allowlist filtering
|
||||
filtered = filter_tools_by_allowlist(tools, ["tool1", "tool3"])
|
||||
assert len(filtered) == 2
|
||||
assert filtered[0]["name"] == "tool1"
|
||||
assert filtered[1]["name"] == "tool3"
|
||||
|
||||
# Test disabled filtering
|
||||
filtered = filter_disabled_tools(tools, ["tool2"])
|
||||
assert len(filtered) == 2
|
||||
assert all(t["name"] != "tool2" for t in filtered)
|
||||
|
||||
# Test precedence: include wins over exclude
|
||||
# This is handled at MCP class level, but filters should work independently
|
||||
|
||||
|
||||
def test_prefix_collision_handling():
|
||||
"""Test tool name prefix handling (B4)."""
|
||||
# The prefix logic is in the loader
|
||||
from praisonaiagents.mcp.loader import load_mcp_tools
|
||||
|
||||
# Test that sanitization logic exists (basic check)
|
||||
# Full test would require mock MCP configs
|
||||
assert callable(load_mcp_tools)
|
||||
|
||||
|
||||
def test_safe_env_build():
|
||||
"""Test safe environment building (B5)."""
|
||||
try:
|
||||
from praisonaiagents.mcp import MCP
|
||||
|
||||
# Create MCP instance to test env building
|
||||
# This tests that _build_safe_env method exists
|
||||
mcp = MCP("echo test")
|
||||
assert hasattr(mcp, '_build_safe_env')
|
||||
|
||||
# Test safe env function
|
||||
safe_env = mcp._build_safe_env({"CUSTOM_VAR": "test"})
|
||||
assert isinstance(safe_env, dict)
|
||||
assert "CUSTOM_VAR" in safe_env
|
||||
assert safe_env["CUSTOM_VAR"] == "test"
|
||||
# Should have safe baseline
|
||||
assert "PATH" in safe_env
|
||||
assert "HOME" in safe_env
|
||||
|
||||
except ImportError as e:
|
||||
if "praisonaiagents[mcp]" in str(e):
|
||||
pytest.skip("MCP package not installed")
|
||||
else:
|
||||
raise
|
||||
@@ -0,0 +1,394 @@
|
||||
"""
|
||||
Unit tests for Tool Search functionality.
|
||||
|
||||
Tests core tool search components including:
|
||||
- ToolSearchConfig
|
||||
- Tool classification
|
||||
- BM25 search
|
||||
- Bridge tool schemas
|
||||
- Assembly logic
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from praisonaiagents.tools.tool_search import (
|
||||
ToolSearchConfig, classify_tools, search_catalog, bridge_tool_schemas,
|
||||
assemble_tool_defs, dispatch_tool_search, dispatch_tool_describe,
|
||||
resolve_underlying_call, PRAISONAI_CORE_TOOLS,
|
||||
estimate_tool_schema_tokens, should_defer_tools
|
||||
)
|
||||
|
||||
class TestToolSearchConfig:
|
||||
"""Test ToolSearchConfig creation and validation."""
|
||||
|
||||
def test_default_config(self):
|
||||
"""Test default configuration."""
|
||||
config = ToolSearchConfig()
|
||||
assert config.enabled == "auto"
|
||||
assert config.threshold_pct == 10.0
|
||||
assert config.search_default_limit == 5
|
||||
assert config.max_search_limit == 20
|
||||
assert config.core_tools is None
|
||||
|
||||
def test_from_raw_bool(self):
|
||||
"""Test creating config from boolean values."""
|
||||
config_true = ToolSearchConfig.from_raw(True)
|
||||
assert config_true.enabled == "on"
|
||||
|
||||
config_false = ToolSearchConfig.from_raw(False)
|
||||
assert config_false.enabled == "off"
|
||||
|
||||
def test_from_raw_string(self):
|
||||
"""Test creating config from string values."""
|
||||
config_auto = ToolSearchConfig.from_raw("auto")
|
||||
assert config_auto.enabled == "auto"
|
||||
|
||||
config_on = ToolSearchConfig.from_raw("on")
|
||||
assert config_on.enabled == "on"
|
||||
|
||||
config_off = ToolSearchConfig.from_raw("off")
|
||||
assert config_off.enabled == "off"
|
||||
|
||||
def test_from_raw_dict(self):
|
||||
"""Test creating config from dictionary."""
|
||||
config = ToolSearchConfig.from_raw({
|
||||
"enabled": "on",
|
||||
"threshold_pct": 15.0,
|
||||
"search_default_limit": 10
|
||||
})
|
||||
assert config.enabled == "on"
|
||||
assert config.threshold_pct == 15.0
|
||||
assert config.search_default_limit == 10
|
||||
|
||||
def test_from_raw_invalid(self):
|
||||
"""Test error handling for invalid inputs."""
|
||||
with pytest.raises(ValueError):
|
||||
ToolSearchConfig.from_raw("invalid")
|
||||
|
||||
with pytest.raises(TypeError):
|
||||
ToolSearchConfig.from_raw(123)
|
||||
|
||||
class TestToolClassification:
|
||||
"""Test tool classification into core vs deferrable."""
|
||||
|
||||
def test_core_tool_classification(self):
|
||||
"""Test that core tools are never deferred."""
|
||||
tool_defs = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "description": "Read a file"}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "search_web", "description": "Search the web"}
|
||||
}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig()
|
||||
core_tools, deferrable_tools = classify_tools(tool_defs, config)
|
||||
|
||||
assert len(core_tools) == 2
|
||||
assert len(deferrable_tools) == 0
|
||||
assert core_tools == tool_defs
|
||||
|
||||
def test_mcp_tool_classification(self):
|
||||
"""Test that MCP tools are marked as deferrable."""
|
||||
tool_defs = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "mcp_weather", "description": "Get weather"},
|
||||
"__praisonai_deferrable__": True
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "read_file", "description": "Read a file"}
|
||||
}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig()
|
||||
core_tools, deferrable_tools = classify_tools(tool_defs, config)
|
||||
|
||||
assert len(core_tools) == 1
|
||||
assert len(deferrable_tools) == 1
|
||||
assert core_tools[0]["function"]["name"] == "read_file"
|
||||
assert deferrable_tools[0]["function"]["name"] == "mcp_weather"
|
||||
|
||||
def test_unknown_tool_stays_visible(self):
|
||||
"""Test that unknown tools stay visible (design invariant #2)."""
|
||||
tool_defs = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "unknown_tool", "description": "Unknown tool"}
|
||||
}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig()
|
||||
core_tools, deferrable_tools = classify_tools(tool_defs, config)
|
||||
|
||||
# Unknown tools should stay in core (visible)
|
||||
assert len(core_tools) == 1
|
||||
assert len(deferrable_tools) == 0
|
||||
assert core_tools[0]["function"]["name"] == "unknown_tool"
|
||||
|
||||
class TestBM25Search:
|
||||
"""Test BM25 search functionality."""
|
||||
|
||||
def test_empty_search(self):
|
||||
"""Test search with empty inputs."""
|
||||
result = search_catalog([], "test query")
|
||||
assert result == []
|
||||
|
||||
result = search_catalog([{"type": "function", "function": {"name": "test"}}], "")
|
||||
assert result == []
|
||||
|
||||
def test_simple_search(self):
|
||||
"""Test basic search functionality."""
|
||||
deferrable_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "weather_tool",
|
||||
"description": "Get current weather information"
|
||||
}
|
||||
},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "stock_tool",
|
||||
"description": "Get stock market data"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
results = search_catalog(deferrable_tools, "weather", limit=10)
|
||||
assert len(results) >= 1
|
||||
assert any("weather_tool" in r["name"] for r in results)
|
||||
|
||||
results = search_catalog(deferrable_tools, "stock", limit=10)
|
||||
assert len(results) >= 1
|
||||
assert any("stock_tool" in r["name"] for r in results)
|
||||
|
||||
class TestBridgeTools:
|
||||
"""Test bridge tool schemas and assembly."""
|
||||
|
||||
def test_bridge_schemas(self):
|
||||
"""Test bridge tool schema generation."""
|
||||
schemas = bridge_tool_schemas()
|
||||
assert len(schemas) == 3
|
||||
|
||||
tool_names = {schema["function"]["name"] for schema in schemas}
|
||||
assert tool_names == {"tool_search", "tool_describe", "tool_call"}
|
||||
|
||||
# Validate schema structure
|
||||
for schema in schemas:
|
||||
assert schema["type"] == "function"
|
||||
assert "function" in schema
|
||||
assert "name" in schema["function"]
|
||||
assert "description" in schema["function"]
|
||||
assert "parameters" in schema["function"]
|
||||
|
||||
def test_assemble_disabled(self):
|
||||
"""Test assembly when tool search is disabled."""
|
||||
tool_defs = [
|
||||
{"type": "function", "function": {"name": "test_tool", "description": "Test"}}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig(enabled="off")
|
||||
assembled, metadata = assemble_tool_defs(tool_defs, config)
|
||||
|
||||
assert assembled == tool_defs
|
||||
assert metadata["bridge_mode"] is False
|
||||
assert metadata["deferred_count"] == 0
|
||||
|
||||
def test_assemble_no_deferrable_tools(self):
|
||||
"""Test assembly when no tools are deferrable."""
|
||||
tool_defs = [
|
||||
{"type": "function", "function": {"name": "read_file", "description": "Read file"}}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig(enabled="on")
|
||||
assembled, metadata = assemble_tool_defs(tool_defs, config)
|
||||
|
||||
# Should return original tools since nothing to defer
|
||||
assert assembled == tool_defs
|
||||
assert metadata["bridge_mode"] is False
|
||||
|
||||
def test_assemble_with_deferrable_tools(self):
|
||||
"""Test assembly with deferrable tools."""
|
||||
tool_defs = [
|
||||
{"type": "function", "function": {"name": "read_file", "description": "Read file"}},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "mcp_weather", "description": "Get weather"},
|
||||
"__praisonai_deferrable__": True
|
||||
}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig(enabled="on")
|
||||
assembled, metadata = assemble_tool_defs(tool_defs, config)
|
||||
|
||||
# Should have core tool + 3 bridge tools
|
||||
assert len(assembled) == 4
|
||||
assert metadata["bridge_mode"] is True
|
||||
assert metadata["deferred_count"] == 1
|
||||
assert len(metadata["catalog"]) == 1
|
||||
|
||||
# Verify core tool remains
|
||||
core_tool_names = {t["function"]["name"] for t in assembled if t["function"]["name"] != "tool_search" and t["function"]["name"] != "tool_describe" and t["function"]["name"] != "tool_call"}
|
||||
assert "read_file" in core_tool_names
|
||||
|
||||
class TestBridgeDispatchers:
|
||||
"""Test bridge tool dispatch handlers."""
|
||||
|
||||
def test_tool_search_dispatch(self):
|
||||
"""Test tool_search bridge dispatch."""
|
||||
deferrable_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "weather_tool",
|
||||
"description": "Get weather information"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
config = ToolSearchConfig()
|
||||
result = dispatch_tool_search("weather", 5, deferrable_tools, config)
|
||||
|
||||
assert "query" in result
|
||||
assert "results" in result
|
||||
assert "total_available" in result
|
||||
assert result["query"] == "weather"
|
||||
assert result["total_available"] == 1
|
||||
|
||||
def test_tool_describe_dispatch(self):
|
||||
"""Test tool_describe bridge dispatch."""
|
||||
deferrable_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "weather_tool",
|
||||
"description": "Get weather information"
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
result = dispatch_tool_describe("weather_tool", deferrable_tools)
|
||||
|
||||
assert "tool_name" in result
|
||||
assert "found" in result
|
||||
assert result["found"] is True
|
||||
assert result["tool_name"] == "weather_tool"
|
||||
assert "schema" in result
|
||||
|
||||
def test_tool_describe_not_found(self):
|
||||
"""Test tool_describe for non-existent tool."""
|
||||
result = dispatch_tool_describe("nonexistent_tool", [])
|
||||
|
||||
assert "tool_name" in result
|
||||
assert "found" in result
|
||||
assert result["found"] is False
|
||||
assert "error" in result
|
||||
|
||||
class TestToolCallUnwrapping:
|
||||
"""Test tool_call bridge unwrapping."""
|
||||
|
||||
def test_resolve_underlying_call(self):
|
||||
"""Test unwrapping tool_call bridge."""
|
||||
# Normal tool call - should pass through
|
||||
name, args = resolve_underlying_call("normal_tool", {"arg1": "value1"})
|
||||
assert name == "normal_tool"
|
||||
assert args == {"arg1": "value1"}
|
||||
|
||||
# Bridge tool call - should unwrap
|
||||
bridge_args = {
|
||||
"tool_name": "real_tool",
|
||||
"tool_args": {"param1": "test"}
|
||||
}
|
||||
name, args = resolve_underlying_call("tool_call", bridge_args)
|
||||
assert name == "real_tool"
|
||||
assert args == {"param1": "test"}
|
||||
|
||||
def test_resolve_missing_tool_name(self):
|
||||
"""Test error handling for missing tool_name."""
|
||||
with pytest.raises(ValueError, match="tool_call requires 'tool_name' parameter"):
|
||||
resolve_underlying_call("tool_call", {"tool_args": {}})
|
||||
|
||||
class TestTokenEstimation:
|
||||
"""Test token estimation and threshold logic."""
|
||||
|
||||
def test_estimate_tool_schema_tokens(self):
|
||||
"""Test token estimation for tool schemas."""
|
||||
tool_defs = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "test_tool",
|
||||
"description": "A test tool with parameters",
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {"param1": {"type": "string"}}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
|
||||
tokens = estimate_tool_schema_tokens(tool_defs)
|
||||
assert tokens > 0
|
||||
assert isinstance(tokens, int)
|
||||
|
||||
def test_should_defer_tools(self):
|
||||
"""Test deferral threshold logic."""
|
||||
# Create some dummy deferrable tools
|
||||
deferrable_tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": f"tool_{i}", "description": "Test tool"}
|
||||
}
|
||||
for i in range(10)
|
||||
]
|
||||
|
||||
# Test with disabled config
|
||||
config_off = ToolSearchConfig(enabled="off")
|
||||
assert should_defer_tools(deferrable_tools, config_off, 20000) is False
|
||||
|
||||
# Test with on config
|
||||
config_on = ToolSearchConfig(enabled="on")
|
||||
assert should_defer_tools(deferrable_tools, config_on, 20000) is True
|
||||
|
||||
# Test auto mode with high threshold (shouldn't defer)
|
||||
config_auto_high = ToolSearchConfig(enabled="auto", threshold_pct=90)
|
||||
assert should_defer_tools(deferrable_tools, config_auto_high, 20000) is False
|
||||
|
||||
# Test auto mode with low threshold (should defer)
|
||||
config_auto_low = ToolSearchConfig(enabled="auto", threshold_pct=1)
|
||||
assert should_defer_tools(deferrable_tools, config_auto_low, 20000) is True
|
||||
|
||||
class TestCoreToolsInvariant:
|
||||
"""Test that core tools never defer."""
|
||||
|
||||
def test_core_tools_constant(self):
|
||||
"""Test that PRAISONAI_CORE_TOOLS is properly defined."""
|
||||
assert isinstance(PRAISONAI_CORE_TOOLS, frozenset)
|
||||
assert len(PRAISONAI_CORE_TOOLS) > 0
|
||||
|
||||
# Check some expected core tools
|
||||
expected_core = {"read_file", "execute_command", "search_web", "clarify"}
|
||||
assert expected_core.issubset(PRAISONAI_CORE_TOOLS)
|
||||
|
||||
def test_core_tools_never_deferred(self):
|
||||
"""Test that core tools are never put in deferrable category."""
|
||||
# Create tools that are marked as deferrable but are actually core tools
|
||||
tool_defs = []
|
||||
for tool_name in list(PRAISONAI_CORE_TOOLS)[:5]: # Test first 5 core tools
|
||||
tool_defs.append({
|
||||
"type": "function",
|
||||
"function": {"name": tool_name, "description": f"Test {tool_name}"},
|
||||
"__praisonai_deferrable__": True # Try to mark as deferrable
|
||||
})
|
||||
|
||||
config = ToolSearchConfig()
|
||||
core_tools, deferrable_tools = classify_tools(tool_defs, config)
|
||||
|
||||
# All should end up in core tools despite the deferrable marker
|
||||
assert len(core_tools) == 5
|
||||
assert len(deferrable_tools) == 0
|
||||
@@ -0,0 +1,114 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Simple test script to verify tool search implementation.
|
||||
"""
|
||||
|
||||
import sys
|
||||
|
||||
|
||||
def test_tool_search_basic():
|
||||
"""Test basic tool search functionality."""
|
||||
print("Testing Tool Search implementation...")
|
||||
|
||||
try:
|
||||
# Test config creation
|
||||
from praisonaiagents.tools.tool_search import ToolSearchConfig
|
||||
config = ToolSearchConfig()
|
||||
print(f"✓ ToolSearchConfig created: enabled={config.enabled}, threshold={config.threshold_pct}%")
|
||||
|
||||
# Test config from raw values
|
||||
config_true = ToolSearchConfig.from_raw(True)
|
||||
config_false = ToolSearchConfig.from_raw(False)
|
||||
print(f"✓ Config from bool: True→{config_true.enabled}, False→{config_false.enabled}")
|
||||
|
||||
# Test tool classification
|
||||
from praisonaiagents.tools.tool_search import PRAISONAI_CORE_TOOLS
|
||||
print(f"✓ Core tools defined: {len(PRAISONAI_CORE_TOOLS)} tools")
|
||||
print(f" Sample core tools: {list(PRAISONAI_CORE_TOOLS)[:5]}")
|
||||
|
||||
# Test bridge tools
|
||||
from praisonaiagents.tools.tool_search import bridge_tool_schemas
|
||||
bridge_schemas = bridge_tool_schemas()
|
||||
bridge_names = [schema["function"]["name"] for schema in bridge_schemas]
|
||||
print(f"✓ Bridge tools created: {bridge_names}")
|
||||
|
||||
# Test assembly logic
|
||||
from praisonaiagents.tools.tool_search import assemble_tool_defs
|
||||
|
||||
# Test with disabled config
|
||||
tool_defs = [
|
||||
{"type": "function", "function": {"name": "test_tool", "description": "Test"}}
|
||||
]
|
||||
assembled, metadata = assemble_tool_defs(tool_defs, ToolSearchConfig(enabled="off"))
|
||||
print(f"✓ Disabled mode: bridge={metadata['bridge_mode']}, deferred={metadata['deferred_count']}")
|
||||
|
||||
# Test with enabled but no deferrable tools
|
||||
assembled, metadata = assemble_tool_defs(tool_defs, ToolSearchConfig(enabled="on"))
|
||||
print(f"✓ No deferrable tools: bridge={metadata['bridge_mode']}, deferred={metadata['deferred_count']}")
|
||||
|
||||
# Test with deferrable tools
|
||||
deferrable_tool_defs = [
|
||||
{"type": "function", "function": {"name": "read_file", "description": "Read file"}},
|
||||
{
|
||||
"type": "function",
|
||||
"function": {"name": "mcp_weather", "description": "Get weather"},
|
||||
"__praisonai_deferrable__": True
|
||||
}
|
||||
]
|
||||
assembled, metadata = assemble_tool_defs(deferrable_tool_defs, ToolSearchConfig(enabled="on"))
|
||||
print(f"✓ With deferrable tools: bridge={metadata['bridge_mode']}, deferred={metadata['deferred_count']}")
|
||||
print(f" Assembled tools: {len(assembled)}, catalog: {len(metadata['catalog'])}")
|
||||
|
||||
# Test search functionality
|
||||
from praisonaiagents.tools.tool_search import search_catalog
|
||||
search_results = search_catalog(metadata["deferrable_tools"], "weather", limit=5)
|
||||
print(f"✓ Search 'weather': {len(search_results)} results")
|
||||
if search_results:
|
||||
print(f" First result: {search_results[0]}")
|
||||
|
||||
print("\n🎉 All basic tests passed! Tool Search implementation is working.")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
def test_agent_integration():
|
||||
"""Test Agent class integration."""
|
||||
print("\nTesting Agent integration...")
|
||||
|
||||
try:
|
||||
# Test config classes are exported
|
||||
from praisonaiagents.config import ToolSearchConfig
|
||||
print("✓ ToolSearchConfig available in config module")
|
||||
|
||||
# Test Agent parameter (basic import check)
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
print("✓ Agent class imports successfully")
|
||||
|
||||
print("✓ Agent integration looks good")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Agent integration test failed: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return False
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
print("Tool Search Implementation Test")
|
||||
print("=" * 50)
|
||||
|
||||
success1 = test_tool_search_basic()
|
||||
success2 = test_agent_integration()
|
||||
|
||||
if success1 and success2:
|
||||
print("\n✅ All tests passed!")
|
||||
sys.exit(0)
|
||||
else:
|
||||
print("\n❌ Some tests failed!")
|
||||
sys.exit(1)
|
||||
Generated
+1
-1
@@ -3125,7 +3125,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.43"
|
||||
version = "1.6.52"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
Loaded 100 of 236 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user