mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-10 17:29:39 -10:00
Compare commits
61
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7e8ca38749 | ||
|
|
5c8d1149fb | ||
|
|
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 | ||
|
|
00b40e8cc4 | ||
|
|
6dca959eda | ||
|
|
5ba8782bf7 | ||
|
|
a2402063f8 | ||
|
|
302bbcbec2 | ||
|
|
ac309e99ea | ||
|
|
727ee626c8 | ||
|
|
50a0399f1c | ||
|
|
dc75c51fea | ||
|
|
dbf8b0d6e1 | ||
|
|
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__, arun, run
|
||||
from praisonai import Agent, AgentApp, AgentOS, AnthropicManagedAgent, CloudProvider, DB, Deploy, DeployConfig, DeployType, HostedAgent, HostedAgentConfig, LocalAgent, LocalAgentConfig, LocalManagedAgent, LocalManagedConfig, ManagedAgent, ManagedConfig, PraisonAI, __version__, arun, run, run_integrated_gateway
|
||||
```
|
||||
|
||||
# CLI
|
||||
@@ -861,6 +873,7 @@ Methods:
|
||||
* <code title="cli">praisonai doctor packaging <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor performance <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor selftest <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor skills <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor tools <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai doctor troubleshoot <a href="./src/praisonai/praisonai/cli/commands/doctor.py">--help</a></code>
|
||||
* <code title="cli">praisonai endpoints list <a href="./src/praisonai/praisonai/cli/commands/endpoints.py">--help</a></code>
|
||||
@@ -892,6 +905,7 @@ Methods:
|
||||
* <code title="cli">praisonai gateway send <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway start <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway status <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway stop <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai gateway uninstall <a href="./src/praisonai/praisonai/cli/commands/gateway.py">--help</a></code>
|
||||
* <code title="cli">praisonai github triage <a href="./src/praisonai/praisonai/cli/commands/github.py">--help</a></code>
|
||||
* <code title="cli">praisonai hooks add <a href="./src/praisonai/praisonai/cli/commands/hooks.py">--help</a></code>
|
||||
@@ -1054,6 +1068,7 @@ Methods:
|
||||
* <code title="cli">praisonai serve status <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve stop <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve ui <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve ui-gateway <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai serve unified <a href="./src/praisonai/praisonai/cli/commands/serve.py">--help</a></code>
|
||||
* <code title="cli">praisonai session delete <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
* <code title="cli">praisonai session export <a href="./src/praisonai/praisonai/cli/commands/session.py">--help</a></code>
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.45" \
|
||||
"praisonai>=4.6.48" \
|
||||
"praisonai[chat]" \
|
||||
"embedchain[github,youtube]"
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.45" \
|
||||
"praisonai>=4.6.48" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[chat]" \
|
||||
"praisonai[realtime]" \
|
||||
|
||||
@@ -16,7 +16,7 @@ RUN mkdir -p /root/.praison
|
||||
# Install Python packages (using latest versions)
|
||||
RUN pip install --no-cache-dir \
|
||||
praisonai_tools \
|
||||
"praisonai>=4.6.45" \
|
||||
"praisonai>=4.6.48" \
|
||||
"praisonai[ui]" \
|
||||
"praisonai[crewai]"
|
||||
|
||||
|
||||
@@ -0,0 +1,222 @@
|
||||
# Multi-Channel Bot Deployment Guide
|
||||
|
||||
This guide explains how to set up multiple bots on the same platform (e.g., multiple Telegram bots) for different roles using PraisonAI, similar to Hermes-style AI workforces.
|
||||
|
||||
## Overview
|
||||
|
||||
PraisonAI supports running multiple bots on the same platform, each routing to different agents with specialized knowledge and capabilities. This is useful for:
|
||||
|
||||
- **Role-based bots**: `@company_cfo_bot`, `@company_ops_bot`, `@company_content_bot`
|
||||
- **Department-specific agents**: Each with different knowledge brains and tool access
|
||||
- **Workflow separation**: Different bots handle different types of requests
|
||||
|
||||
## Quick Start with Onboard Wizard
|
||||
|
||||
The easiest way to set up multiple channels is using the onboard wizard:
|
||||
|
||||
```bash
|
||||
praisonai onboard
|
||||
```
|
||||
|
||||
The wizard now supports:
|
||||
1. **Multi-channel setup**: Add multiple bots for the same platform
|
||||
2. **Role-based naming**: Automatically generates `TELEGRAM_<ROLE>_BOT_TOKEN` environment variables
|
||||
3. **Agent routing**: Each channel routes to a different agent
|
||||
|
||||
### Onboard Wizard Flow
|
||||
|
||||
1. **Choose platform**: Start with your first platform (e.g., telegram)
|
||||
2. **Add more channels**: Wizard asks "Add another bot channel?"
|
||||
3. **Specify role**: Enter role name (e.g., "cfo", "ops", "content")
|
||||
4. **Configure tokens**: Each channel gets its own environment variable
|
||||
5. **Test connections**: Wizard tests each bot separately
|
||||
|
||||
## Environment Variable Convention
|
||||
|
||||
For multiple bots on the same platform, use this naming convention:
|
||||
|
||||
| Channel | Environment Variable | Bot Purpose |
|
||||
|---------|---------------------|-------------|
|
||||
| `telegram_cfo` | `TELEGRAM_CFO_BOT_TOKEN` | CFO-related tasks |
|
||||
| `telegram_ops` | `TELEGRAM_OPS_BOT_TOKEN` | Operations tasks |
|
||||
| `telegram_content` | `TELEGRAM_CONTENT_BOT_TOKEN` | Content creation |
|
||||
| `telegram_support` | `TELEGRAM_SUPPORT_BOT_TOKEN` | Customer support |
|
||||
|
||||
### Setting Up Tokens
|
||||
|
||||
1. **Create bots in @BotFather**:
|
||||
```text
|
||||
/newbot
|
||||
Bot name: Company CFO Bot
|
||||
Username: @company_cfo_bot
|
||||
-> Copy token
|
||||
|
||||
/newbot
|
||||
Bot name: Company Ops Bot
|
||||
Username: @company_ops_bot
|
||||
-> Copy token
|
||||
```
|
||||
|
||||
2. **Set environment variables**:
|
||||
```bash
|
||||
export TELEGRAM_CFO_BOT_TOKEN="123456789:ABC..."
|
||||
export TELEGRAM_OPS_BOT_TOKEN="987654321:DEF..."
|
||||
export TELEGRAM_CONTENT_BOT_TOKEN="456789123:GHI..."
|
||||
```
|
||||
|
||||
3. **Or use `~/.praisonai/.env`**:
|
||||
```env
|
||||
TELEGRAM_CFO_BOT_TOKEN=123456789:ABC...
|
||||
TELEGRAM_OPS_BOT_TOKEN=987654321:DEF...
|
||||
TELEGRAM_CONTENT_BOT_TOKEN=456789123:GHI...
|
||||
TELEGRAM_ALLOWED_USERS=123456789,987654321
|
||||
```
|
||||
|
||||
## Example Configuration
|
||||
|
||||
See `multi-telegram-hermes-workforce.yaml` for a complete example.
|
||||
|
||||
### Key Features
|
||||
|
||||
1. **Multiple Channels**:
|
||||
```yaml
|
||||
channels:
|
||||
telegram_cfo:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_CFO_BOT_TOKEN}
|
||||
routes:
|
||||
default: cfo
|
||||
|
||||
telegram_ops:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_OPS_BOT_TOKEN}
|
||||
routes:
|
||||
default: ops
|
||||
```
|
||||
|
||||
2. **Specialized Agents**:
|
||||
```yaml
|
||||
agents:
|
||||
cfo:
|
||||
instructions: "You are a CFO agent. Help with financial analysis..."
|
||||
model: gpt-4o-mini
|
||||
|
||||
ops:
|
||||
instructions: "You are an ops agent. Help with system monitoring..."
|
||||
model: gpt-4o-mini
|
||||
```
|
||||
|
||||
## Validation with Doctor
|
||||
|
||||
Use the doctor command to validate your multi-channel setup:
|
||||
|
||||
```bash
|
||||
praisonai doctor
|
||||
```
|
||||
|
||||
The doctor checks for:
|
||||
- ✅ **Unique tokens**: Each channel uses a different bot token
|
||||
- ✅ **Naming convention**: Follows `PLATFORM_ROLE_BOT_TOKEN` pattern
|
||||
- ✅ **Missing tokens**: All referenced environment variables are set
|
||||
- ❌ **Duplicate tokens**: Same token used by multiple channels (fails)
|
||||
|
||||
## Deployment
|
||||
|
||||
### Start Gateway
|
||||
|
||||
```bash
|
||||
# Start the multi-bot gateway
|
||||
praisonai gateway start --config multi-telegram-hermes-workforce.yaml
|
||||
|
||||
# Or install as daemon
|
||||
praisonai onboard # Wizard installs daemon automatically
|
||||
praisonai gateway status
|
||||
```
|
||||
|
||||
### Verify Setup
|
||||
|
||||
1. **Check health**: `curl http://127.0.0.1:8765/health`
|
||||
2. **View dashboard**: `praisonai claw` → http://127.0.0.1:8082
|
||||
3. **Test bots**: Send messages to each bot to verify routing
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### Basic Multi-Role Setup
|
||||
```yaml
|
||||
# Single platform, multiple roles
|
||||
channels:
|
||||
telegram_support:
|
||||
token: ${TELEGRAM_SUPPORT_BOT_TOKEN}
|
||||
routes:
|
||||
default: support_agent
|
||||
|
||||
telegram_sales:
|
||||
token: ${TELEGRAM_SALES_BOT_TOKEN}
|
||||
routes:
|
||||
default: sales_agent
|
||||
```
|
||||
|
||||
### Cross-Platform Workforce
|
||||
```yaml
|
||||
# Multiple platforms, multiple roles
|
||||
channels:
|
||||
telegram_cfo:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_CFO_BOT_TOKEN}
|
||||
routes:
|
||||
default: cfo
|
||||
|
||||
discord_ops:
|
||||
platform: discord
|
||||
token: ${DISCORD_OPS_BOT_TOKEN}
|
||||
routes:
|
||||
default: ops
|
||||
|
||||
slack_content:
|
||||
platform: slack
|
||||
token: ${SLACK_CONTENT_BOT_TOKEN}
|
||||
routes:
|
||||
default: content
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Common Issues
|
||||
|
||||
1. **"Token is used by multiple channels"**
|
||||
- Each bot needs its own unique token from @BotFather
|
||||
- Check environment variables for duplicates
|
||||
|
||||
2. **"Channel skipped - no token"**
|
||||
- Set the environment variable: `export TELEGRAM_CFO_BOT_TOKEN="..."`
|
||||
- Or update `~/.praisonai/.env`
|
||||
|
||||
3. **"Bot not responding"**
|
||||
- Verify token is correct: `praisonai doctor`
|
||||
- Check bot is not already running elsewhere
|
||||
- Ensure allowlist includes your user ID
|
||||
|
||||
### Debugging Commands
|
||||
|
||||
```bash
|
||||
# Check configuration
|
||||
praisonai doctor
|
||||
|
||||
# View logs
|
||||
praisonai gateway logs
|
||||
|
||||
# Test connection
|
||||
curl -H "Authorization: Bearer $GATEWAY_AUTH_TOKEN" \
|
||||
http://127.0.0.1:8765/info
|
||||
```
|
||||
|
||||
## Migration from Single-Bot Setup
|
||||
|
||||
If you currently have a single bot setup, you can add more channels:
|
||||
|
||||
1. **Run onboard wizard**: `praisonai onboard`
|
||||
2. **Add new channels**: Wizard will preserve existing setup
|
||||
3. **Update configuration**: New channels added to existing `bot.yaml`
|
||||
4. **Restart gateway**: `praisonai gateway restart`
|
||||
|
||||
The wizard preserves backward compatibility with single-bot configurations.
|
||||
@@ -0,0 +1,66 @@
|
||||
# Multi-Telegram Channel Hermes Workforce Example
|
||||
# Generated by `praisonai onboard` with multi-channel support
|
||||
# Start with: praisonai gateway start --config multi-telegram-hermes-workforce.yaml
|
||||
|
||||
gateway:
|
||||
host: "127.0.0.1"
|
||||
port: 8765
|
||||
|
||||
# Legacy single-agent block for backward compatibility
|
||||
agent:
|
||||
name: "assistant"
|
||||
instructions: "You are a helpful AI assistant. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."
|
||||
model: "gpt-4o-mini"
|
||||
|
||||
# Multi-agent workforce with different specialized roles
|
||||
agents:
|
||||
cfo:
|
||||
instructions: "You are a CFO agent. You are a helpful AI assistant. Use your tools proactively to help with CFO-related tasks like financial analysis, budget planning, and cost optimization."
|
||||
model: gpt-4o-mini
|
||||
memory: false
|
||||
|
||||
ops:
|
||||
instructions: "You are an ops agent. You are a helpful AI assistant. Use your tools proactively to help with ops-related tasks like system monitoring, deployment, and infrastructure management."
|
||||
model: gpt-4o-mini
|
||||
memory: false
|
||||
|
||||
content:
|
||||
instructions: "You are a content agent. You are a helpful AI assistant. Use your tools proactively to help with content-related tasks like writing, editing, and content strategy."
|
||||
model: gpt-4o-mini
|
||||
memory: false
|
||||
|
||||
# Multiple Telegram channels - each with unique tokens and routing
|
||||
channels:
|
||||
telegram_cfo:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_CFO_BOT_TOKEN}
|
||||
group_policy: mention_only
|
||||
allowed_users: ${TELEGRAM_ALLOWED_USERS}
|
||||
# auto_approve_tools: true
|
||||
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
|
||||
routes:
|
||||
default: cfo
|
||||
|
||||
telegram_ops:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_OPS_BOT_TOKEN}
|
||||
group_policy: mention_only
|
||||
allowed_users: ${TELEGRAM_ALLOWED_USERS}
|
||||
# auto_approve_tools: true
|
||||
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
|
||||
routes:
|
||||
default: ops
|
||||
|
||||
telegram_content:
|
||||
platform: telegram
|
||||
token: ${TELEGRAM_CONTENT_BOT_TOKEN}
|
||||
group_policy: mention_only
|
||||
allowed_users: ${TELEGRAM_ALLOWED_USERS}
|
||||
# auto_approve_tools: true
|
||||
# Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.
|
||||
routes:
|
||||
default: content
|
||||
|
||||
# Legacy routing block
|
||||
routing:
|
||||
default: assistant
|
||||
@@ -1952,6 +1952,129 @@ Your Goal: {self.goal}
|
||||
# Sandbox configuration - initialize SandboxMixin
|
||||
super().__init__(sandbox=sandbox)
|
||||
|
||||
def __deepcopy__(self, memo: dict) -> "Agent":
|
||||
"""Custom deepcopy that creates fresh threading primitives.
|
||||
|
||||
threading.RLock (self.__cache_lock) and threading.Lock (self._cost_lock)
|
||||
cannot be deep-copied on CPython < 3.13. This hook deep-copies every
|
||||
other attribute normally and replaces the locks with new instances so
|
||||
that copy.deepcopy(agent) works in any Python version.
|
||||
"""
|
||||
import copy
|
||||
cls = self.__class__
|
||||
result = cls.__new__(cls)
|
||||
memo[id(self)] = result
|
||||
for k, v in self.__dict__.items():
|
||||
if k in ("_Agent__cache_lock",):
|
||||
object.__setattr__(result, k, threading.RLock())
|
||||
elif k == "_cost_lock":
|
||||
object.__setattr__(result, k, threading.Lock())
|
||||
else:
|
||||
object.__setattr__(result, k, copy.deepcopy(v, memo))
|
||||
return result
|
||||
|
||||
def clone_for_channel(self) -> "Agent":
|
||||
"""Return a fully independent copy of this agent for a gateway channel.
|
||||
|
||||
This method safely clones an agent for use in multi-channel scenarios
|
||||
by avoiding deepcopy issues with tools/handoffs and creating fresh
|
||||
instances of mutable objects like interrupt_controller.
|
||||
|
||||
Returns:
|
||||
Agent: A new Agent instance with copied configuration but fresh
|
||||
locks, interrupt controller, and shallow-copied tools.
|
||||
"""
|
||||
import copy
|
||||
from ..tools.base import BaseTool
|
||||
|
||||
# Build kwargs for new Agent instance, copying core attributes
|
||||
clone_kwargs = {
|
||||
# Core identity
|
||||
'name': self.name,
|
||||
'role': self.role,
|
||||
'goal': self.goal,
|
||||
'backstory': self.backstory,
|
||||
'instructions': self.instructions,
|
||||
|
||||
# LLM configuration
|
||||
'llm': self.llm,
|
||||
'base_url': getattr(self, 'base_url', None),
|
||||
'api_key': getattr(self, 'api_key', None),
|
||||
'auth': getattr(self, 'auth', None),
|
||||
|
||||
# Shallow copy tools to avoid deepcopy issues with nested objects
|
||||
'tools': list(self.tools) if self.tools else None,
|
||||
|
||||
# Skip handoffs entirely - they shouldn't be shared across channels
|
||||
# and can contain nested Agent instances that cause RLock issues
|
||||
'handoffs': None,
|
||||
|
||||
# Feature configurations - check for actual stored config objects
|
||||
'memory': getattr(self, '_memory_config', None),
|
||||
'knowledge': getattr(self, '_knowledge_config', None),
|
||||
'planning': getattr(self, '_planning_config', None),
|
||||
'reflection': getattr(self, '_reflection_config', None),
|
||||
'guardrails': getattr(self, '_guardrails_config', None),
|
||||
'web': getattr(self, '_web_config', None),
|
||||
'context': getattr(self, '_context_config', None),
|
||||
'autonomy': getattr(self, '_autonomy_config', None),
|
||||
'output': getattr(self, '_output_config', None),
|
||||
'execution': getattr(self, '_execution_config', None),
|
||||
'templates': getattr(self, '_template_config', None),
|
||||
'caching': getattr(self, '_caching_config', None),
|
||||
'hooks': getattr(self, '_hooks_config', None),
|
||||
'skills': getattr(self, '_skills_config', None),
|
||||
'approval': getattr(self, '_approval_config', None),
|
||||
'learn': getattr(self, '_learn_config', None),
|
||||
|
||||
# Tool configuration
|
||||
'tool_timeout': getattr(self, '_tool_timeout', None),
|
||||
'parallel_tool_calls': getattr(self, 'parallel_tool_calls', False),
|
||||
|
||||
# CLI backend
|
||||
'cli_backend': getattr(self, '_cli_backend', None),
|
||||
|
||||
# Create fresh interrupt controller to avoid shared state
|
||||
'interrupt_controller': None, # Let new instance create its own
|
||||
|
||||
# Sandbox config
|
||||
'sandbox': getattr(self, '_sandbox_config', None),
|
||||
}
|
||||
|
||||
# Handle deprecated parameters for backward compatibility
|
||||
import warnings
|
||||
|
||||
# Check for deprecated standalone attributes and emit warnings
|
||||
deprecated_attrs = [
|
||||
('allow_code_execution', 'execution'),
|
||||
('code_execution_mode', 'execution'),
|
||||
('auto_save', 'memory'),
|
||||
('rate_limiter', 'execution'),
|
||||
('allow_delegation', 'handoffs'),
|
||||
('verification_hooks', 'autonomy')
|
||||
]
|
||||
|
||||
for old_attr, new_param in deprecated_attrs:
|
||||
if hasattr(self, old_attr):
|
||||
value = getattr(self, old_attr)
|
||||
if value is not None and value != False: # Skip None/False defaults
|
||||
warnings.warn(
|
||||
f"Deprecated attribute '{old_attr}' found in agent clone. "
|
||||
f"Use '{new_param}=' parameter instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
# For basic compatibility, include in clone_kwargs if not already set
|
||||
if old_attr == 'allow_code_execution' and clone_kwargs['execution'] is None:
|
||||
from ..config.execution import ExecutionConfig
|
||||
clone_kwargs['execution'] = ExecutionConfig(code_execution=value)
|
||||
elif old_attr == 'auto_save' and clone_kwargs['memory'] is None:
|
||||
from ..config.memory import MemoryConfig
|
||||
clone_kwargs['memory'] = MemoryConfig(auto_save=value)
|
||||
|
||||
# Create new Agent instance
|
||||
return self.__class__(**{k: v for k, v in clone_kwargs.items() if v is not None})
|
||||
|
||||
@property
|
||||
def _telemetry(self):
|
||||
"""Lazy-loaded telemetry instance for performance."""
|
||||
|
||||
@@ -1609,25 +1609,25 @@ class AgentTeam:
|
||||
original_on_task_start = self.on_task_start
|
||||
original_on_task_complete = self.on_task_complete
|
||||
|
||||
def composed_on_task_start(task):
|
||||
def composed_on_task_start(task, task_id):
|
||||
try:
|
||||
verbose_task_start_callback(task)
|
||||
verbose_task_start_callback(task, task_id)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task start callback: {e}")
|
||||
if original_on_task_start:
|
||||
try:
|
||||
original_on_task_start(task)
|
||||
original_on_task_start(task, task_id)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in original task start callback: {e}")
|
||||
|
||||
def composed_on_task_complete(task):
|
||||
def composed_on_task_complete(task, task_output):
|
||||
try:
|
||||
verbose_task_complete_callback(task)
|
||||
verbose_task_complete_callback(task, task_output)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in verbose task complete callback: {e}")
|
||||
if original_on_task_complete:
|
||||
try:
|
||||
original_on_task_complete(task)
|
||||
original_on_task_complete(task, task_output)
|
||||
except Exception as e:
|
||||
logging.debug(f"Error in original task complete callback: {e}")
|
||||
|
||||
|
||||
@@ -42,6 +42,7 @@ class MemoryBackend(str, Enum):
|
||||
FILE = "file"
|
||||
SQLITE = "sqlite"
|
||||
REDIS = "redis"
|
||||
VALKEY = "valkey"
|
||||
POSTGRES = "postgres"
|
||||
MEM0 = "mem0"
|
||||
MONGODB = "mongodb"
|
||||
|
||||
@@ -68,6 +68,14 @@ class HookEvent(str, Enum):
|
||||
SCHEDULE_REMOVE = "schedule_remove"
|
||||
SCHEDULE_TRIGGER = "schedule_trigger"
|
||||
|
||||
# Kanban task lifecycle (wrapper dispatcher + tools emit these)
|
||||
KANBAN_TASK_CREATED = "kanban_task_created"
|
||||
KANBAN_TASK_CLAIMED = "kanban_task_claimed"
|
||||
KANBAN_TASK_MOVED = "kanban_task_moved"
|
||||
KANBAN_TASK_DONE = "kanban_task_done"
|
||||
KANBAN_TASK_BLOCKED = "kanban_task_blocked"
|
||||
KANBAN_TASK_FAILED = "kanban_task_failed"
|
||||
|
||||
# Claude Code parity events
|
||||
USER_PROMPT_SUBMIT = "user_prompt_submit" # When user submits a prompt
|
||||
NOTIFICATION = "notification" # When notification is sent
|
||||
@@ -101,6 +109,34 @@ class HookInput:
|
||||
}
|
||||
|
||||
|
||||
@dataclass
|
||||
class KanbanHookInput(HookInput):
|
||||
"""Hook input for kanban task lifecycle events.
|
||||
|
||||
Used by wrapper dispatcher and tools when emitting kanban events.
|
||||
Observability adapters can subscribe to these via the hook registry.
|
||||
"""
|
||||
task_id: str = ""
|
||||
board: str = "default"
|
||||
status: str = ""
|
||||
assignee: Optional[str] = field(default=None)
|
||||
from_status: Optional[str] = field(default=None)
|
||||
to_status: Optional[str] = field(default=None)
|
||||
|
||||
def to_dict(self) -> Dict[str, Any]:
|
||||
"""Convert to dictionary for JSON serialization."""
|
||||
result = super().to_dict()
|
||||
result.update({
|
||||
"task_id": self.task_id,
|
||||
"board": self.board,
|
||||
"status": self.status,
|
||||
"assignee": self.assignee,
|
||||
"from_status": self.from_status,
|
||||
"to_status": self.to_status,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@dataclass
|
||||
class HookOutput:
|
||||
"""Base hook output - common fields for all events."""
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
"""
|
||||
Kanban protocols and types for PraisonAI Agents.
|
||||
|
||||
This module provides the protocol contracts for kanban functionality,
|
||||
allowing the wrapper (praisonai) and PraisonAIUI to share a stable
|
||||
interface without coupling the core to SQLite or other heavy implementations.
|
||||
"""
|
||||
|
||||
from praisonaiagents.kanban.protocols import (
|
||||
KanbanStoreProtocol,
|
||||
KanbanTaskProtocol,
|
||||
KanbanCommentingProtocol,
|
||||
KanbanLinkingProtocol,
|
||||
VALID_KANBAN_STATUSES,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"KanbanStoreProtocol",
|
||||
"KanbanTaskProtocol",
|
||||
"KanbanCommentingProtocol",
|
||||
"KanbanLinkingProtocol",
|
||||
"VALID_KANBAN_STATUSES",
|
||||
]
|
||||
@@ -0,0 +1,221 @@
|
||||
"""
|
||||
Kanban protocols for PraisonAI Agents.
|
||||
|
||||
Defines the protocol contracts that the wrapper (praisonai) and PraisonAIUI
|
||||
can use to implement kanban functionality with a stable interface.
|
||||
|
||||
This follows AGENTS.md §3.2 Protocol-First Design:
|
||||
- Protocols define WHAT (interface contract)
|
||||
- Implementations define HOW (concrete behavior)
|
||||
- Core SDK has protocols only, wrapper has heavy implementations
|
||||
"""
|
||||
|
||||
from typing import Protocol, TypedDict, runtime_checkable
|
||||
|
||||
|
||||
# Valid kanban statuses matching PraisonAIUI columns + archived
|
||||
VALID_KANBAN_STATUSES: frozenset[str] = frozenset([
|
||||
"triage",
|
||||
"todo",
|
||||
"ready",
|
||||
"running",
|
||||
"blocked",
|
||||
"review",
|
||||
"done",
|
||||
"archived"
|
||||
])
|
||||
|
||||
|
||||
class KanbanTaskProtocol(TypedDict, total=False):
|
||||
"""Typed dict shape for kanban task fields.
|
||||
|
||||
Defines the expected structure of task objects returned by
|
||||
KanbanStoreProtocol implementations.
|
||||
"""
|
||||
id: str
|
||||
title: str
|
||||
body: str
|
||||
status: str
|
||||
assignee: str | None
|
||||
priority: str | None
|
||||
tenant: str | None
|
||||
board: str
|
||||
created_at: float
|
||||
updated_at: float
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class KanbanStoreProtocol(Protocol):
|
||||
"""Protocol contract for kanban store implementations.
|
||||
|
||||
This protocol defines the core interface that PraisonAIUI expects
|
||||
for injected kanban stores. Wrapper implementations must
|
||||
implement all methods to be compatible.
|
||||
|
||||
Duck typing contract matches InjectedKanbanStore in PraisonAIUI.
|
||||
"""
|
||||
|
||||
def get_board(
|
||||
self,
|
||||
*,
|
||||
board: str = "default",
|
||||
tenant: str | None = None,
|
||||
include_archived: bool = False,
|
||||
) -> dict:
|
||||
"""Get board data with tasks grouped by status.
|
||||
|
||||
Args:
|
||||
board: Board name (default: "default")
|
||||
tenant: Optional tenant filter
|
||||
include_archived: Whether to include archived tasks
|
||||
|
||||
Returns:
|
||||
Board data with tasks organized by status columns
|
||||
"""
|
||||
...
|
||||
|
||||
def get_task(self, task_id: str) -> dict | None:
|
||||
"""Get a single task by ID.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
|
||||
Returns:
|
||||
Task data or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def create_task(self, data: dict) -> dict:
|
||||
"""Create a new task.
|
||||
|
||||
Args:
|
||||
data: Task creation data (title, body, status, etc.)
|
||||
|
||||
Returns:
|
||||
Created task data with assigned ID
|
||||
"""
|
||||
...
|
||||
|
||||
def update_task(self, task_id: str, data: dict) -> dict | None:
|
||||
"""Update an existing task.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
data: Fields to update
|
||||
|
||||
Returns:
|
||||
Updated task data or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def move_task(self, task_id: str, status: str) -> dict | None:
|
||||
"""Move a task to a different status.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
status: New status (must be in VALID_KANBAN_STATUSES)
|
||||
|
||||
Returns:
|
||||
Updated task data or None if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def bulk_update(self, task_ids: list[str], status: str) -> dict:
|
||||
"""Update multiple tasks to the same status.
|
||||
|
||||
Args:
|
||||
task_ids: List of task identifiers
|
||||
status: New status for all tasks
|
||||
|
||||
Returns:
|
||||
Results summary with success/failure counts
|
||||
"""
|
||||
...
|
||||
|
||||
def delete_task(self, task_id: str) -> bool:
|
||||
"""Delete a task.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
|
||||
Returns:
|
||||
True if deleted, False if not found
|
||||
"""
|
||||
...
|
||||
|
||||
def list_events(self, since: float = 0.0, board: str = "default") -> list[dict]:
|
||||
"""List kanban events since a timestamp.
|
||||
|
||||
Args:
|
||||
since: Unix timestamp to filter events from
|
||||
board: Board name to filter events
|
||||
|
||||
Returns:
|
||||
List of event data
|
||||
"""
|
||||
...
|
||||
|
||||
def health(self) -> dict:
|
||||
"""Check store health status.
|
||||
|
||||
Returns:
|
||||
Health status information
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class KanbanCommentingProtocol(Protocol):
|
||||
"""Extension protocol for kanban task commenting functionality.
|
||||
|
||||
This protocol is implemented separately from KanbanStoreProtocol to allow
|
||||
stores to optionally support commenting without breaking isinstance checks
|
||||
on the core protocol.
|
||||
"""
|
||||
|
||||
def add_comment(self, task_id: str, text: str, author: str | None = None) -> dict | None:
|
||||
"""Add a comment to a task.
|
||||
|
||||
Args:
|
||||
task_id: Task identifier
|
||||
text: Comment text
|
||||
author: Optional comment author
|
||||
|
||||
Returns:
|
||||
Comment data or None if task not found
|
||||
"""
|
||||
...
|
||||
|
||||
|
||||
@runtime_checkable
|
||||
class KanbanLinkingProtocol(Protocol):
|
||||
"""Extension protocol for kanban task linking functionality.
|
||||
|
||||
This protocol is implemented separately from KanbanStoreProtocol to allow
|
||||
stores to optionally support task relationships without breaking isinstance
|
||||
checks on the core protocol.
|
||||
"""
|
||||
|
||||
def link_tasks(self, parent_id: str, child_id: str) -> bool:
|
||||
"""Link two tasks in parent-child relationship.
|
||||
|
||||
Args:
|
||||
parent_id: Parent task identifier
|
||||
child_id: Child task identifier
|
||||
|
||||
Returns:
|
||||
True if linked successfully, False otherwise
|
||||
"""
|
||||
...
|
||||
|
||||
def unlink_tasks(self, parent_id: str, child_id: str) -> bool:
|
||||
"""Unlink parent-child task relationship.
|
||||
|
||||
Args:
|
||||
parent_id: Parent task identifier
|
||||
child_id: Child task identifier
|
||||
|
||||
Returns:
|
||||
True if unlinked successfully, False otherwise
|
||||
"""
|
||||
...
|
||||
@@ -174,7 +174,14 @@ class ChromaKnowledgeAdapter:
|
||||
|
||||
# Add where filter if any conditions specified
|
||||
if where_filter:
|
||||
query_kwargs["where"] = where_filter
|
||||
# ChromaDB validation expects exactly one condition in the where clause
|
||||
# When multiple conditions exist, use $and operator to combine them
|
||||
if len(where_filter) == 1:
|
||||
query_kwargs["where"] = where_filter
|
||||
else:
|
||||
# Convert multiple conditions to ChromaDB's $and format
|
||||
and_conditions = [{k: v} for k, v in where_filter.items()]
|
||||
query_kwargs["where"] = {"$and": and_conditions}
|
||||
|
||||
response = self.collection.query(**query_kwargs)
|
||||
|
||||
@@ -197,6 +204,10 @@ class ChromaKnowledgeAdapter:
|
||||
return SearchResult(results=items)
|
||||
|
||||
except Exception as e:
|
||||
# Log ChromaDB query errors for better debugging
|
||||
import logging
|
||||
logger = logging.getLogger(__name__)
|
||||
logger.warning(f"ChromaDB search failed: {e}")
|
||||
return SearchResult(results=[])
|
||||
|
||||
def add(self, content: Any, *, user_id: Optional[str] = None, agent_id: Optional[str] = None,
|
||||
|
||||
@@ -6,7 +6,13 @@ No heavy imports - only stdlib.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
from typing import Any, Dict, List, Optional, Set, Tuple
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Set, Tuple, Union
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from ..knowledge.models import SearchResultItem
|
||||
|
||||
|
||||
ResultItem = Union[Dict[str, Any], "SearchResultItem"]
|
||||
|
||||
|
||||
def _estimate_tokens(text: str) -> int:
|
||||
@@ -19,6 +25,39 @@ def _estimate_tokens(text: str) -> int:
|
||||
return len(text) // 4 + 1
|
||||
|
||||
|
||||
def _extract_value(item: ResultItem, key: str, default: Any = None) -> Any:
|
||||
"""
|
||||
Extract value from either dict or object (e.g., SearchResultItem).
|
||||
|
||||
Args:
|
||||
item: Dictionary or object with attributes
|
||||
key: Key/attribute name to extract
|
||||
default: Default value if key not found
|
||||
|
||||
Returns:
|
||||
Extracted value or default
|
||||
"""
|
||||
if isinstance(item, dict):
|
||||
return item.get(key, default)
|
||||
else:
|
||||
return getattr(item, key, default)
|
||||
|
||||
|
||||
def _extract_metadata_value(
|
||||
item: ResultItem,
|
||||
metadata: Dict[str, Any],
|
||||
key: str,
|
||||
default: Any = "",
|
||||
) -> Any:
|
||||
"""
|
||||
Extract metadata value with fallback to top-level item attribute.
|
||||
"""
|
||||
value = metadata.get(key)
|
||||
if value is None:
|
||||
return _extract_value(item, key, default)
|
||||
return value
|
||||
|
||||
|
||||
def _chunk_hash(text: str, source: Optional[str] = None) -> str:
|
||||
"""
|
||||
Generate stable hash for chunk deduplication.
|
||||
@@ -35,31 +74,38 @@ def _chunk_hash(text: str, source: Optional[str] = None) -> str:
|
||||
|
||||
|
||||
def deduplicate_chunks(
|
||||
results: List[Dict[str, Any]],
|
||||
results: List[ResultItem],
|
||||
similarity_threshold: float = 0.9,
|
||||
) -> List[Dict[str, Any]]:
|
||||
) -> List[ResultItem]:
|
||||
"""
|
||||
Deduplicate chunks by content hash.
|
||||
|
||||
Handles both dict format and SearchResultItem objects.
|
||||
|
||||
Args:
|
||||
results: List of search results with 'text' or 'memory' key
|
||||
results: List of search results (dicts or SearchResultItem objects)
|
||||
similarity_threshold: Not used currently (hash-based dedup)
|
||||
|
||||
Returns:
|
||||
Deduplicated list of results
|
||||
"""
|
||||
seen_hashes: Set[str] = set()
|
||||
unique_results: List[Dict[str, Any]] = []
|
||||
unique_results: List[ResultItem] = []
|
||||
|
||||
for result in results:
|
||||
# Skip None results
|
||||
if result is None:
|
||||
continue
|
||||
# Handle different result formats
|
||||
text = result.get("text") or result.get("memory", "")
|
||||
|
||||
# Handle different result formats (dict or SearchResultItem)
|
||||
text = _extract_value(result, "text") or _extract_value(result, "memory", "")
|
||||
|
||||
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
|
||||
metadata = result.get("metadata") or {}
|
||||
source = metadata.get("source", "")
|
||||
metadata = _extract_value(result, "metadata") or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
source = _extract_metadata_value(result, metadata, "source", "")
|
||||
|
||||
chunk_id = _chunk_hash(text, source)
|
||||
|
||||
@@ -106,17 +152,19 @@ def truncate_context(
|
||||
|
||||
|
||||
def build_context(
|
||||
results: List[Dict[str, Any]],
|
||||
results: List[ResultItem],
|
||||
max_tokens: int = 4000,
|
||||
deduplicate: bool = True,
|
||||
separator: str = "\n\n---\n\n",
|
||||
include_source: bool = True,
|
||||
) -> Tuple[str, List[Dict[str, Any]]]:
|
||||
) -> Tuple[str, List[ResultItem]]:
|
||||
"""
|
||||
Build context string from retrieval results.
|
||||
|
||||
Handles both dict format and SearchResultItem objects.
|
||||
|
||||
Args:
|
||||
results: List of search results
|
||||
results: List of search results (dicts or SearchResultItem objects)
|
||||
max_tokens: Maximum tokens for context
|
||||
deduplicate: Whether to deduplicate chunks
|
||||
separator: Separator between chunks
|
||||
@@ -133,7 +181,7 @@ def build_context(
|
||||
results = deduplicate_chunks(results)
|
||||
|
||||
context_parts: List[str] = []
|
||||
used_results: List[Dict[str, Any]] = []
|
||||
used_results: List[ResultItem] = []
|
||||
current_tokens = 0
|
||||
separator_tokens = _estimate_tokens(separator)
|
||||
|
||||
@@ -141,17 +189,21 @@ def build_context(
|
||||
# Skip None results
|
||||
if result is None:
|
||||
continue
|
||||
# Handle different result formats
|
||||
text = result.get("text") or result.get("memory", "")
|
||||
|
||||
# Handle different result formats (dict or SearchResultItem)
|
||||
text = _extract_value(result, "text") or _extract_value(result, "memory", "")
|
||||
if not text:
|
||||
continue
|
||||
|
||||
# Build chunk text with optional source
|
||||
if include_source:
|
||||
# CRITICAL: Handle metadata=None from mem0 - ensure always dict
|
||||
metadata = result.get("metadata") or {}
|
||||
source = metadata.get("source", "")
|
||||
filename = metadata.get("filename", "")
|
||||
metadata = _extract_value(result, "metadata") or {}
|
||||
if not isinstance(metadata, dict):
|
||||
metadata = {}
|
||||
|
||||
source = _extract_metadata_value(result, metadata, "source", "")
|
||||
filename = _extract_metadata_value(result, metadata, "filename", "")
|
||||
source_label = filename or source or f"Source {i + 1}"
|
||||
chunk_text = f"[{source_label}]\n{text}"
|
||||
else:
|
||||
@@ -194,11 +246,14 @@ class DefaultContextBuilder:
|
||||
|
||||
def build(
|
||||
self,
|
||||
results: List[Dict[str, Any]],
|
||||
results: List[ResultItem],
|
||||
max_tokens: int = 4000,
|
||||
deduplicate: bool = True,
|
||||
) -> str:
|
||||
"""Build context string from results."""
|
||||
"""Build context string from results.
|
||||
|
||||
Handles both dict format and SearchResultItem objects.
|
||||
"""
|
||||
context, _ = build_context(
|
||||
results=results,
|
||||
max_tokens=max_tokens,
|
||||
|
||||
@@ -144,6 +144,35 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self._extended_cache: Dict[str, ExtendedSessionData] = {}
|
||||
|
||||
def _load_session_from_disk(self, session_id: str, filepath: str) -> ExtendedSessionData:
|
||||
"""Load extended session JSON from disk (caller must hold FileLock)."""
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return ExtendedSessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return ExtendedSessionData(session_id=session_id)
|
||||
|
||||
def _modify_session_locked(
|
||||
self,
|
||||
session_id: str,
|
||||
mutator,
|
||||
*,
|
||||
error_label: str = "modify session",
|
||||
) -> bool:
|
||||
"""Locked read-modify-write preserving extended session fields."""
|
||||
result = super()._modify_session_locked(
|
||||
session_id, mutator, error_label=error_label
|
||||
)
|
||||
if result:
|
||||
with self._lock:
|
||||
cached = self._cache.get(session_id)
|
||||
if isinstance(cached, ExtendedSessionData):
|
||||
self._extended_cache[session_id] = cached
|
||||
return result
|
||||
|
||||
def add_message(
|
||||
self,
|
||||
@@ -157,25 +186,22 @@ class HierarchicalSessionStore(DefaultSessionStore):
|
||||
|
||||
Overrides parent to preserve extended session data.
|
||||
"""
|
||||
from .store import SessionMessage
|
||||
|
||||
# Load extended session (force reload to get latest)
|
||||
session = self._load_extended_session(session_id, force_reload=True)
|
||||
|
||||
|
||||
message = SessionMessage(
|
||||
role=role,
|
||||
content=content,
|
||||
timestamp=time.time(),
|
||||
metadata=metadata or {},
|
||||
)
|
||||
|
||||
session.messages.append(message)
|
||||
|
||||
# Trim messages if over limit
|
||||
if len(session.messages) > self.max_messages:
|
||||
session.messages = session.messages[-self.max_messages:]
|
||||
|
||||
return self._save_extended_session(session)
|
||||
|
||||
def _apply(session: SessionData) -> None:
|
||||
session.messages.append(message)
|
||||
if len(session.messages) > self.max_messages:
|
||||
session.messages = session.messages[-self.max_messages :]
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="add message to session"
|
||||
)
|
||||
|
||||
def _load_extended_session(self, session_id: str, force_reload: bool = False) -> ExtendedSessionData:
|
||||
"""Load extended session from disk."""
|
||||
|
||||
@@ -22,7 +22,7 @@ else:
|
||||
_HAS_FCNTL = False
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from ..paths import get_sessions_dir
|
||||
|
||||
@@ -141,7 +141,8 @@ class FileLock:
|
||||
self._lock_path = filepath + ".lock"
|
||||
|
||||
def __enter__(self):
|
||||
self.acquire()
|
||||
if not self.acquire():
|
||||
raise IOError(f"Failed to acquire file lock for {self.filepath} after {self.timeout}s")
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
@@ -290,6 +291,72 @@ class DefaultSessionStore:
|
||||
self._cache[session_id] = session
|
||||
|
||||
return session
|
||||
|
||||
def _load_session_from_disk(self, session_id: str, filepath: str) -> SessionData:
|
||||
"""Load session JSON from disk (caller must hold FileLock)."""
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
return SessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
pass
|
||||
return SessionData(session_id=session_id)
|
||||
|
||||
def _read_session_fresh(self, session_id: str) -> SessionData:
|
||||
"""Reload session from disk and refresh the in-process cache."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
session = self._load_session_from_disk(session_id, filepath)
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
return session
|
||||
|
||||
def _modify_session_locked(
|
||||
self,
|
||||
session_id: str,
|
||||
mutator: Callable[[SessionData], None],
|
||||
*,
|
||||
error_label: str = "modify session",
|
||||
) -> bool:
|
||||
"""Apply mutator after reloading from disk under FileLock."""
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
session = self._load_session_from_disk(session_id, filepath)
|
||||
mutator(session)
|
||||
session.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
if len(session.messages) > self.max_messages:
|
||||
session.messages = session.messages[-self.max_messages:]
|
||||
|
||||
try:
|
||||
dir_path = os.path.dirname(filepath) or "."
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=dir_path,
|
||||
delete=False,
|
||||
suffix=".tmp",
|
||||
) as f:
|
||||
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
temp_path = f.name
|
||||
|
||||
os.replace(temp_path, filepath)
|
||||
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
logger.error(f"Failed to {error_label} {session_id}: {e}")
|
||||
try:
|
||||
if "temp_path" in locals():
|
||||
os.remove(temp_path)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
return False
|
||||
|
||||
def _save_session(self, session: SessionData) -> bool:
|
||||
"""Save session to disk with atomic write."""
|
||||
@@ -439,13 +506,13 @@ class DefaultSessionStore:
|
||||
Returns:
|
||||
List of {"role": "user/assistant", "content": "..."} dicts.
|
||||
"""
|
||||
session = self._load_session(session_id)
|
||||
session = self._read_session_fresh(session_id)
|
||||
limit = max_messages or self.max_messages
|
||||
return session.get_chat_history(limit)
|
||||
|
||||
def get_session(self, session_id: str) -> SessionData:
|
||||
"""Get full session data."""
|
||||
return self._load_session(session_id)
|
||||
return self._read_session_fresh(session_id)
|
||||
|
||||
def set_agent_info(
|
||||
self,
|
||||
@@ -454,39 +521,41 @@ class DefaultSessionStore:
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Set agent info for a session."""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
|
||||
def _apply(session: SessionData) -> None:
|
||||
if agent_name:
|
||||
session.agent_name = agent_name
|
||||
if user_id:
|
||||
session.user_id = user_id
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="set agent info for session"
|
||||
)
|
||||
|
||||
def clear_session(self, session_id: str) -> bool:
|
||||
"""Clear all messages from a session."""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
session.messages.clear()
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
return self._modify_session_locked(
|
||||
session_id,
|
||||
lambda session: session.messages.clear(),
|
||||
error_label="clear session",
|
||||
)
|
||||
|
||||
def update_session_metadata(self, session_id: str, **fields: Any) -> bool:
|
||||
"""Merge run stats / metadata fields into a persisted session."""
|
||||
session = self._load_session(session_id)
|
||||
with self._lock:
|
||||
if not fields:
|
||||
return True
|
||||
|
||||
def _apply(session: SessionData) -> None:
|
||||
for key, value in fields.items():
|
||||
if value is None:
|
||||
continue
|
||||
session.metadata[key] = value
|
||||
if key in ("agent_id", "agent_name", "user_id"):
|
||||
setattr(session, key, value)
|
||||
self._cache[session_id] = session
|
||||
return self._save_session(session)
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="update session metadata"
|
||||
)
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session completely."""
|
||||
@@ -586,7 +655,7 @@ class DefaultSessionStore:
|
||||
List of SessionData objects for the specified agent
|
||||
"""
|
||||
session_ids = self.list_sessions_by_agent(agent_name, limit)
|
||||
return [self._load_session(sid) for sid in session_ids]
|
||||
return [self._read_session_fresh(sid) for sid in session_ids]
|
||||
|
||||
def get_agent_chat_history(
|
||||
self,
|
||||
@@ -640,16 +709,15 @@ class DefaultSessionStore:
|
||||
Returns:
|
||||
True if saved successfully
|
||||
"""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
def _apply(session: SessionData) -> None:
|
||||
if gateway_session_id:
|
||||
session.gateway_session_id = gateway_session_id
|
||||
if agent_id:
|
||||
session.agent_id = agent_id
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="set gateway info for session"
|
||||
)
|
||||
|
||||
def get_by_gateway_session(self, gateway_session_id: str) -> Optional[SessionData]:
|
||||
"""Get session data linked to a gateway session.
|
||||
|
||||
@@ -19,7 +19,9 @@ Usage:
|
||||
__all__ = [
|
||||
# Models
|
||||
"SkillProperties",
|
||||
"SkillMetadata",
|
||||
"SkillMetadata",
|
||||
"SkillRequirements",
|
||||
"SkillState",
|
||||
"ParseError",
|
||||
"ValidationError",
|
||||
# Parser
|
||||
@@ -54,13 +56,17 @@ __all__ = [
|
||||
"SkillActivatedEvent",
|
||||
# Budget
|
||||
"SkillPromptBudget",
|
||||
# Capability validation
|
||||
"CapabilityValidator",
|
||||
"EnforcementLevel",
|
||||
"ValidationResult",
|
||||
]
|
||||
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""Lazy load module components to avoid import overhead."""
|
||||
if name in ("SkillProperties", "SkillMetadata", "ParseError", "ValidationError"):
|
||||
from .models import SkillProperties, SkillMetadata, ParseError, ValidationError
|
||||
if name in ("SkillProperties", "SkillMetadata", "SkillRequirements", "SkillState", "ParseError", "ValidationError"):
|
||||
from .models import SkillProperties, SkillMetadata, SkillRequirements, SkillState, ParseError, ValidationError
|
||||
return locals()[name]
|
||||
|
||||
if name in ("parse_frontmatter", "find_skill_md", "read_properties"):
|
||||
@@ -130,5 +136,9 @@ def __getattr__(name: str):
|
||||
return None
|
||||
|
||||
return load_skill
|
||||
|
||||
if name in ("CapabilityValidator", "EnforcementLevel", "ValidationResult"):
|
||||
from .capability_validator import CapabilityValidator, EnforcementLevel, ValidationResult
|
||||
return locals()[name]
|
||||
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -0,0 +1,236 @@
|
||||
"""Capability validation for Agent Skills.
|
||||
|
||||
This module implements the capability gate enforcement system for skills,
|
||||
checking that required tools, servers, and environment variables are available
|
||||
before allowing skill activation.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional, Set, Tuple
|
||||
from enum import Enum
|
||||
|
||||
from .models import SkillProperties, SkillRequirements, SkillState
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class EnforcementLevel(Enum):
|
||||
"""Enforcement level for capability gates."""
|
||||
DISABLED = "disabled" # No enforcement (existing behavior)
|
||||
TELEMETRY = "telemetry" # Log only, no blocking
|
||||
WARN = "warn" # Warning but allow activation
|
||||
STRICT = "strict" # Hard failure, block activation
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
"""Result of capability validation for a skill."""
|
||||
skill_name: str
|
||||
state: SkillState
|
||||
satisfied_tools: List[str]
|
||||
missing_tools: List[str]
|
||||
satisfied_servers: List[str]
|
||||
missing_servers: List[str]
|
||||
satisfied_env_vars: List[str]
|
||||
missing_env_vars: List[str]
|
||||
warnings: List[str]
|
||||
errors: List[str]
|
||||
|
||||
@property
|
||||
def is_fully_satisfied(self) -> bool:
|
||||
"""Check if all requirements are satisfied."""
|
||||
return not any([
|
||||
self.missing_tools,
|
||||
self.missing_servers,
|
||||
self.missing_env_vars,
|
||||
self.errors
|
||||
])
|
||||
|
||||
@property
|
||||
def has_critical_missing(self) -> bool:
|
||||
"""Check if critical requirements are missing (tools, servers)."""
|
||||
return bool(self.missing_tools or self.missing_servers)
|
||||
|
||||
def to_dict(self) -> Dict:
|
||||
"""Convert to dictionary for serialization."""
|
||||
return {
|
||||
"skill_name": self.skill_name,
|
||||
"state": self.state.value,
|
||||
"satisfied_tools": self.satisfied_tools,
|
||||
"missing_tools": self.missing_tools,
|
||||
"satisfied_servers": self.satisfied_servers,
|
||||
"missing_servers": self.missing_servers,
|
||||
"satisfied_env_vars": self.satisfied_env_vars,
|
||||
"missing_env_vars": self.missing_env_vars,
|
||||
"warnings": self.warnings,
|
||||
"errors": self.errors,
|
||||
"is_fully_satisfied": self.is_fully_satisfied,
|
||||
"has_critical_missing": self.has_critical_missing
|
||||
}
|
||||
|
||||
|
||||
class CapabilityValidator:
|
||||
"""Validates skill requirements against available capabilities.
|
||||
|
||||
This is the core capability gate enforcement engine. It checks skill
|
||||
requirements against the runtime context (available tools, servers, etc.)
|
||||
and determines whether a skill should be activated based on policy.
|
||||
"""
|
||||
|
||||
def __init__(self, enforcement_level: EnforcementLevel = EnforcementLevel.WARN):
|
||||
"""Initialize the validator.
|
||||
|
||||
Args:
|
||||
enforcement_level: How strictly to enforce requirements
|
||||
"""
|
||||
self.enforcement_level = enforcement_level
|
||||
self._tool_cache: Optional[Set[str]] = None
|
||||
self._server_cache: Optional[Set[str]] = None
|
||||
|
||||
def validate_skill(
|
||||
self,
|
||||
skill: SkillProperties,
|
||||
available_tools: Optional[Set[str]] = None,
|
||||
available_servers: Optional[Set[str]] = None
|
||||
) -> ValidationResult:
|
||||
"""Validate a skill's requirements against available capabilities.
|
||||
|
||||
Args:
|
||||
skill: Skill to validate
|
||||
available_tools: Set of available tool names (auto-detected if None)
|
||||
available_servers: Set of available server names (auto-detected if None)
|
||||
|
||||
Returns:
|
||||
ValidationResult with validation status and details
|
||||
"""
|
||||
if not skill.requirements or skill.requirements.is_empty():
|
||||
# No requirements specified - skill is always available
|
||||
return ValidationResult(
|
||||
skill_name=skill.name,
|
||||
state=SkillState.ACTIVE,
|
||||
satisfied_tools=[],
|
||||
missing_tools=[],
|
||||
satisfied_servers=[],
|
||||
missing_servers=[],
|
||||
satisfied_env_vars=[],
|
||||
missing_env_vars=[],
|
||||
warnings=[],
|
||||
errors=[]
|
||||
)
|
||||
|
||||
# Get available capabilities
|
||||
if available_tools is None:
|
||||
available_tools = self._get_available_tools()
|
||||
if available_servers is None:
|
||||
available_servers = self._get_available_servers()
|
||||
|
||||
requirements = skill.requirements
|
||||
|
||||
# Validate tools
|
||||
satisfied_tools = [tool for tool in requirements.tools if tool in available_tools]
|
||||
missing_tools = [tool for tool in requirements.tools if tool not in available_tools]
|
||||
|
||||
# Validate servers
|
||||
satisfied_servers = [srv for srv in requirements.servers if srv in available_servers]
|
||||
missing_servers = [srv for srv in requirements.servers if srv not in available_servers]
|
||||
|
||||
# Validate environment variables
|
||||
satisfied_env_vars = [var for var in requirements.env_vars if os.getenv(var)]
|
||||
missing_env_vars = [var for var in requirements.env_vars if not os.getenv(var)]
|
||||
|
||||
# Determine warnings and errors
|
||||
warnings = []
|
||||
errors = []
|
||||
|
||||
if missing_tools:
|
||||
msg = f"Missing required tools: {', '.join(missing_tools)}"
|
||||
if self.enforcement_level in (EnforcementLevel.STRICT,):
|
||||
errors.append(msg)
|
||||
else:
|
||||
warnings.append(msg)
|
||||
|
||||
if missing_servers:
|
||||
msg = f"Missing required servers: {', '.join(missing_servers)}"
|
||||
if self.enforcement_level in (EnforcementLevel.STRICT,):
|
||||
errors.append(msg)
|
||||
else:
|
||||
warnings.append(msg)
|
||||
|
||||
if missing_env_vars:
|
||||
msg = f"Missing required environment variables: {', '.join(missing_env_vars)}"
|
||||
if self.enforcement_level == EnforcementLevel.STRICT:
|
||||
errors.append(msg)
|
||||
else:
|
||||
warnings.append(msg)
|
||||
|
||||
# Determine skill state
|
||||
if errors:
|
||||
state = SkillState.UNAVAILABLE
|
||||
elif warnings:
|
||||
state = SkillState.DEGRADED
|
||||
else:
|
||||
state = SkillState.ACTIVE
|
||||
|
||||
result = ValidationResult(
|
||||
skill_name=skill.name,
|
||||
state=state,
|
||||
satisfied_tools=satisfied_tools,
|
||||
missing_tools=missing_tools,
|
||||
satisfied_servers=satisfied_servers,
|
||||
missing_servers=missing_servers,
|
||||
satisfied_env_vars=satisfied_env_vars,
|
||||
missing_env_vars=missing_env_vars,
|
||||
warnings=warnings,
|
||||
errors=errors
|
||||
)
|
||||
|
||||
# Log based on enforcement level
|
||||
self._log_validation_result(result)
|
||||
|
||||
return result
|
||||
|
||||
def _get_available_tools(self) -> Set[str]:
|
||||
"""Get set of available tool names from registry."""
|
||||
if self._tool_cache is None:
|
||||
try:
|
||||
from ..tools.registry import get_registry
|
||||
registry = get_registry()
|
||||
self._tool_cache = set(registry.list_tools())
|
||||
except ImportError:
|
||||
logger.debug("Tool registry not available")
|
||||
self._tool_cache = set()
|
||||
return self._tool_cache
|
||||
|
||||
def _get_available_servers(self) -> Set[str]:
|
||||
"""Get set of available MCP server names."""
|
||||
if self._server_cache is None:
|
||||
# TODO: Implement MCP server discovery
|
||||
# For now, return empty set - this can be extended later
|
||||
self._server_cache = set()
|
||||
return self._server_cache
|
||||
|
||||
def _log_validation_result(self, result: ValidationResult) -> None:
|
||||
"""Log validation result based on enforcement level."""
|
||||
if self.enforcement_level == EnforcementLevel.DISABLED:
|
||||
return
|
||||
|
||||
skill_name = result.skill_name
|
||||
|
||||
if result.state == SkillState.UNAVAILABLE:
|
||||
if self.enforcement_level == EnforcementLevel.STRICT:
|
||||
logger.error(f"Skill '{skill_name}' blocked: {'; '.join(result.errors)}")
|
||||
else:
|
||||
logger.warning(f"Skill '{skill_name}' has critical missing dependencies: {'; '.join(result.errors)}")
|
||||
|
||||
elif result.state == SkillState.DEGRADED:
|
||||
logger.warning(f"Skill '{skill_name}' partially available: {'; '.join(result.warnings)}")
|
||||
|
||||
elif result.state == SkillState.ACTIVE:
|
||||
logger.debug(f"Skill '{skill_name}' requirements satisfied")
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear cached capability information."""
|
||||
self._tool_cache = None
|
||||
self._server_cache = None
|
||||
@@ -108,7 +108,7 @@ class SkillLoader:
|
||||
return False
|
||||
|
||||
try:
|
||||
content = skill_md.read_text()
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
_, body = parse_frontmatter(content)
|
||||
skill.instructions = body
|
||||
return True
|
||||
@@ -136,7 +136,7 @@ class SkillLoader:
|
||||
for script_file in scripts_dir.iterdir():
|
||||
if script_file.is_file():
|
||||
try:
|
||||
scripts[script_file.name] = script_file.read_text()
|
||||
scripts[script_file.name] = script_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
except PermissionError:
|
||||
@@ -166,7 +166,7 @@ class SkillLoader:
|
||||
for ref_file in refs_dir.iterdir():
|
||||
if ref_file.is_file():
|
||||
try:
|
||||
refs[ref_file.name] = ref_file.read_text()
|
||||
refs[ref_file.name] = ref_file.read_text(encoding="utf-8")
|
||||
except Exception:
|
||||
continue
|
||||
except PermissionError:
|
||||
|
||||
@@ -5,12 +5,13 @@ from typing import List, Optional, Dict
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .models import SkillMetadata
|
||||
from .models import SkillMetadata, SkillState
|
||||
from .discovery import discover_skills, get_default_skill_dirs
|
||||
from .loader import SkillLoader, LoadedSkill
|
||||
from .prompt import generate_skills_xml
|
||||
from .substitution import render_skill_body
|
||||
from .shell_render import render_shell_blocks
|
||||
from .capability_validator import CapabilityValidator, EnforcementLevel, ValidationResult
|
||||
|
||||
|
||||
class SkillManager:
|
||||
@@ -34,11 +35,21 @@ class SkillManager:
|
||||
manager.activate(skill)
|
||||
"""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the SkillManager."""
|
||||
def __init__(self, enforcement_level: Optional[EnforcementLevel] = None):
|
||||
"""Initialize the SkillManager.
|
||||
|
||||
Args:
|
||||
enforcement_level: Capability enforcement level (defaults to environment-based)
|
||||
"""
|
||||
self._skills: Dict[str, LoadedSkill] = {}
|
||||
self._loader = SkillLoader()
|
||||
self._discovered = False
|
||||
self._validation_cache: Dict[str, ValidationResult] = {}
|
||||
|
||||
# Initialize capability validator
|
||||
if enforcement_level is None:
|
||||
enforcement_level = self._get_default_enforcement_level()
|
||||
self._validator = CapabilityValidator(enforcement_level)
|
||||
|
||||
@property
|
||||
def skills(self) -> List[LoadedSkill]:
|
||||
@@ -126,15 +137,29 @@ class SkillManager:
|
||||
def get_available_skills(self) -> List[SkillMetadata]:
|
||||
"""Get metadata for all available skills.
|
||||
|
||||
This is used for system prompt injection. Skills with
|
||||
``disable-model-invocation: true`` are omitted so the LLM never
|
||||
sees them and cannot auto-trigger them.
|
||||
This is used for system prompt injection. Skills are filtered by:
|
||||
1. disable-model-invocation: true (excluded)
|
||||
2. Capability enforcement (if strict mode, exclude UNAVAILABLE skills)
|
||||
"""
|
||||
return [
|
||||
skill.metadata
|
||||
for skill in self._skills.values()
|
||||
if not getattr(skill.properties, "disable_model_invocation", False)
|
||||
]
|
||||
available = []
|
||||
for skill in self._skills.values():
|
||||
# Skip if explicitly disabled for model invocation
|
||||
if getattr(skill.properties, "disable_model_invocation", False):
|
||||
continue
|
||||
|
||||
# Check capability requirements if enforcement is strict
|
||||
if self._validator.enforcement_level == EnforcementLevel.STRICT:
|
||||
try:
|
||||
validation = self.validate_skill_capabilities(skill.properties.name)
|
||||
if validation.state == SkillState.UNAVAILABLE:
|
||||
continue # Skip unavailable skills in strict mode
|
||||
except Exception as e:
|
||||
logger.warning(f"Skipping skill '{skill.properties.name}' due to validation error: {e}")
|
||||
continue
|
||||
|
||||
available.append(skill.metadata)
|
||||
|
||||
return available
|
||||
|
||||
def get_user_invocable_skills(self) -> List[LoadedSkill]:
|
||||
"""Return skills the *user* may invoke via slash-commands.
|
||||
@@ -566,9 +591,90 @@ author: agent
|
||||
logger.debug("Failed to clean up temp file %s", temp_path, exc_info=True)
|
||||
raise
|
||||
|
||||
def validate_skill_capabilities(self, skill_name: str, force_refresh: bool = False) -> ValidationResult:
|
||||
"""Validate a skill's capability requirements.
|
||||
|
||||
Args:
|
||||
skill_name: Name of the skill to validate
|
||||
force_refresh: If True, bypass cache and re-validate
|
||||
|
||||
Returns:
|
||||
ValidationResult with capability status
|
||||
|
||||
Raises:
|
||||
ValueError: If skill not found
|
||||
"""
|
||||
skill = self.get_skill(skill_name)
|
||||
if skill is None:
|
||||
raise ValueError(f"Skill '{skill_name}' not found")
|
||||
|
||||
# Check cache first
|
||||
if not force_refresh and skill_name in self._validation_cache:
|
||||
return self._validation_cache[skill_name]
|
||||
|
||||
# Perform validation
|
||||
result = self._validator.validate_skill(skill.properties)
|
||||
self._validation_cache[skill_name] = result
|
||||
return result
|
||||
|
||||
def get_available_skills_by_state(self, state: SkillState) -> List[LoadedSkill]:
|
||||
"""Get skills filtered by their capability validation state.
|
||||
|
||||
Args:
|
||||
state: Desired skill state
|
||||
|
||||
Returns:
|
||||
List of skills in the specified state
|
||||
"""
|
||||
matching = []
|
||||
for skill in self._skills.values():
|
||||
try:
|
||||
result = self.validate_skill_capabilities(skill.properties.name)
|
||||
if result.state == state:
|
||||
matching.append(skill)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to validate skill '{skill.properties.name}': {e}")
|
||||
continue
|
||||
return matching
|
||||
|
||||
def get_skills_diagnostics(self) -> Dict[str, ValidationResult]:
|
||||
"""Get capability diagnostics for all skills.
|
||||
|
||||
Returns:
|
||||
Dict mapping skill names to their validation results
|
||||
"""
|
||||
diagnostics = {}
|
||||
for skill_name in self.skill_names:
|
||||
try:
|
||||
diagnostics[skill_name] = self.validate_skill_capabilities(skill_name)
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to validate skill '{skill_name}': {e}")
|
||||
return diagnostics
|
||||
|
||||
def _get_default_enforcement_level(self) -> EnforcementLevel:
|
||||
"""Get default enforcement level from environment."""
|
||||
import os
|
||||
level_str = os.getenv('SKILL_CAPABILITY_ENFORCEMENT', 'warn').lower()
|
||||
|
||||
level_map = {
|
||||
'disabled': EnforcementLevel.DISABLED,
|
||||
'off': EnforcementLevel.DISABLED,
|
||||
'telemetry': EnforcementLevel.TELEMETRY,
|
||||
'log': EnforcementLevel.TELEMETRY,
|
||||
'warn': EnforcementLevel.WARN,
|
||||
'warning': EnforcementLevel.WARN,
|
||||
'strict': EnforcementLevel.STRICT,
|
||||
'hard': EnforcementLevel.STRICT,
|
||||
'fail': EnforcementLevel.STRICT,
|
||||
}
|
||||
|
||||
return level_map.get(level_str, EnforcementLevel.WARN)
|
||||
|
||||
def clear(self) -> None:
|
||||
"""Clear all loaded skills."""
|
||||
self._skills.clear()
|
||||
self._validation_cache.clear()
|
||||
self._validator.clear_cache()
|
||||
self._discovered = False
|
||||
|
||||
def __len__(self) -> int:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
"""Data models for Agent Skills."""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Optional
|
||||
from typing import Optional, List, Union
|
||||
from pathlib import Path
|
||||
from enum import Enum
|
||||
|
||||
|
||||
class ParseError(Exception):
|
||||
@@ -15,6 +16,105 @@ class ValidationError(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class SkillState(Enum):
|
||||
"""Skill activation state based on requirement validation."""
|
||||
ACTIVE = "active" # All requirements satisfied
|
||||
DEGRADED = "degraded" # Some requirements missing (soft warn)
|
||||
UNAVAILABLE = "unavailable" # Critical requirements missing (hard fail)
|
||||
UNKNOWN = "unknown" # Requirements not yet validated
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillRequirements:
|
||||
"""Capability requirements for a skill.
|
||||
|
||||
This represents the parsed and normalized requirements from skill frontmatter.
|
||||
Supports both Hermes/OpenClaw conventions and PraisonAI-specific extensions.
|
||||
|
||||
Attributes:
|
||||
servers: Required MCP servers or service endpoints
|
||||
tools: Required tool names (from tool registry)
|
||||
env_vars: Required environment variables (use sparingly)
|
||||
openclaw_hints: OpenClaw-specific metadata (passthrough)
|
||||
"""
|
||||
servers: List[str] = field(default_factory=list)
|
||||
tools: List[str] = field(default_factory=list)
|
||||
env_vars: List[str] = field(default_factory=list)
|
||||
openclaw_hints: dict = field(default_factory=dict)
|
||||
|
||||
@classmethod
|
||||
def from_frontmatter(cls, metadata: dict) -> "SkillRequirements":
|
||||
"""Parse requirements from skill frontmatter.
|
||||
|
||||
Normalizes various naming conventions:
|
||||
- requires_servers, requires_tools (Hermes/OpenClaw style)
|
||||
- allowed-tools (existing PraisonAI convention)
|
||||
- openclaw (OpenClaw-specific hints)
|
||||
|
||||
Args:
|
||||
metadata: Raw frontmatter dict
|
||||
|
||||
Returns:
|
||||
SkillRequirements instance
|
||||
"""
|
||||
servers = []
|
||||
tools = []
|
||||
env_vars = []
|
||||
openclaw_hints = {}
|
||||
|
||||
# Parse servers
|
||||
if "requires_servers" in metadata:
|
||||
servers = cls._normalize_list(metadata["requires_servers"])
|
||||
elif "requires-servers" in metadata:
|
||||
servers = cls._normalize_list(metadata["requires-servers"])
|
||||
|
||||
# Parse tools (multiple naming conventions)
|
||||
if "requires_tools" in metadata:
|
||||
tools.extend(cls._normalize_list(metadata["requires_tools"]))
|
||||
elif "requires-tools" in metadata:
|
||||
tools.extend(cls._normalize_list(metadata["requires-tools"]))
|
||||
|
||||
# Support existing allowed-tools convention for backward compatibility
|
||||
if "allowed-tools" in metadata:
|
||||
tools.extend(cls._normalize_list(metadata["allowed-tools"]))
|
||||
|
||||
# Parse environment variables
|
||||
if "requires_env" in metadata:
|
||||
env_vars = cls._normalize_list(metadata["requires_env"])
|
||||
elif "requires-env" in metadata:
|
||||
env_vars = cls._normalize_list(metadata["requires-env"])
|
||||
|
||||
# OpenClaw hints (passthrough)
|
||||
if "openclaw" in metadata:
|
||||
openclaw_hints = metadata["openclaw"] if isinstance(metadata["openclaw"], dict) else {}
|
||||
|
||||
return cls(
|
||||
servers=servers,
|
||||
tools=tools,
|
||||
env_vars=env_vars,
|
||||
openclaw_hints=openclaw_hints
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _normalize_list(value: Union[str, List[str]]) -> List[str]:
|
||||
"""Normalize string or list to list of strings."""
|
||||
if isinstance(value, str):
|
||||
# Support both comma-separated and space-separated strings
|
||||
return [item.strip() for item in value.replace(',', ' ').split() if item.strip()]
|
||||
elif isinstance(value, (list, tuple)):
|
||||
return [str(item).strip() for item in value if str(item).strip()]
|
||||
else:
|
||||
return []
|
||||
|
||||
def is_empty(self) -> bool:
|
||||
"""Check if no requirements are specified."""
|
||||
return not any([self.servers, self.tools, self.env_vars, self.openclaw_hints])
|
||||
|
||||
def __bool__(self) -> bool:
|
||||
"""Truth value: True if any requirements are specified."""
|
||||
return not self.is_empty()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SkillProperties:
|
||||
"""Properties parsed from a skill's SKILL.md frontmatter.
|
||||
@@ -49,6 +149,8 @@ class SkillProperties:
|
||||
hooks: Optional[dict] = None
|
||||
paths: Optional[object] = None # list[str] glob patterns
|
||||
shell: Optional[str] = None # "bash" | "powershell"
|
||||
# Capability requirements (new)
|
||||
requirements: Optional[SkillRequirements] = None
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
"""Convert to dictionary, excluding None values and empty metadata."""
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
from .models import ParseError, ValidationError, SkillProperties
|
||||
from .models import ParseError, ValidationError, SkillProperties, SkillRequirements
|
||||
|
||||
|
||||
def find_skill_md(skill_dir: Path) -> Optional[Path]:
|
||||
@@ -100,7 +100,7 @@ def read_properties(skill_dir: Path) -> SkillProperties:
|
||||
if skill_md is None:
|
||||
raise ParseError(f"SKILL.md not found in {skill_dir}")
|
||||
|
||||
content = skill_md.read_text()
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
metadata, _ = parse_frontmatter(content)
|
||||
|
||||
if "name" not in metadata:
|
||||
@@ -143,4 +143,6 @@ def read_properties(skill_dir: Path) -> SkillProperties:
|
||||
hooks=metadata.get("hooks") if isinstance(metadata.get("hooks"), dict) else None,
|
||||
paths=metadata.get("paths"),
|
||||
shell=metadata.get("shell"),
|
||||
# Parse capability requirements
|
||||
requirements=SkillRequirements.from_frontmatter(metadata),
|
||||
)
|
||||
@@ -191,9 +191,11 @@ def validate(skill_dir: Path, strict: bool = False) -> List[str]:
|
||||
return ["Missing required file: SKILL.md"]
|
||||
|
||||
try:
|
||||
content = skill_md.read_text()
|
||||
content = skill_md.read_text(encoding="utf-8")
|
||||
metadata, _ = parse_frontmatter(content)
|
||||
except ParseError as e:
|
||||
return [str(e)]
|
||||
except UnicodeDecodeError as e:
|
||||
return [f"SKILL.md is not valid UTF-8: {e}"]
|
||||
|
||||
return validate_metadata(metadata, skill_dir, strict=strict)
|
||||
@@ -346,13 +346,15 @@ class _LazyStorageModule:
|
||||
|
||||
_BACKENDS = {
|
||||
"RedisBackend": "praisonai.storage",
|
||||
"MongoDBBackend": "praisonai.storage",
|
||||
"MongoDBBackend": "praisonai.storage",
|
||||
"PostgreSQLBackend": "praisonai.storage",
|
||||
"DynamoDBBackend": "praisonai.storage",
|
||||
"ValkeyBackend": "praisonai.storage",
|
||||
"ValkeySearchBackend": "praisonai.storage",
|
||||
# Legacy names for compatibility
|
||||
"RedisStorageAdapter": "praisonai.storage",
|
||||
"MongoDBStorageAdapter": "praisonai.storage",
|
||||
"PostgreSQLStorageAdapter": "praisonai.storage",
|
||||
"PostgreSQLStorageAdapter": "praisonai.storage",
|
||||
"DynamoDBStorageAdapter": "praisonai.storage",
|
||||
}
|
||||
|
||||
@@ -390,7 +392,7 @@ def get_backend(
|
||||
Factory function to get a storage backend.
|
||||
|
||||
Args:
|
||||
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb")
|
||||
backend_type: Type of backend ("file", "sqlite", "redis", "mongodb", "postgresql", "dynamodb", "valkey")
|
||||
**kwargs: Backend-specific arguments
|
||||
|
||||
Returns:
|
||||
@@ -431,9 +433,13 @@ def get_backend(
|
||||
# Lazy load from wrapper
|
||||
backend_class = getattr(_heavy_backends, "DynamoDBBackend")
|
||||
return backend_class(**kwargs)
|
||||
elif backend_type == "valkey":
|
||||
# Lazy load from wrapper
|
||||
backend_class = getattr(_heavy_backends, "ValkeyBackend")
|
||||
return backend_class(**kwargs)
|
||||
else:
|
||||
raise ValueError(f"Unknown backend type: {backend_type}. "
|
||||
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb")
|
||||
f"Supported: file, sqlite, redis, mongodb, postgresql, dynamodb, valkey")
|
||||
|
||||
def __getattr__(name: str):
|
||||
"""
|
||||
@@ -442,7 +448,8 @@ def __getattr__(name: str):
|
||||
This allows `from praisonaiagents.storage.backends import RedisBackend` to work
|
||||
while maintaining lazy loading.
|
||||
"""
|
||||
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend"}:
|
||||
if name in {"RedisBackend", "MongoDBBackend", "PostgreSQLBackend", "DynamoDBBackend",
|
||||
"ValkeyBackend", "ValkeySearchBackend"}:
|
||||
return getattr(_heavy_backends, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ Usage:
|
||||
"""
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from typing import Any, Dict, Optional, Type, get_type_hints
|
||||
from typing import Any, Dict, List, Optional, Type, get_type_hints
|
||||
import inspect
|
||||
import logging
|
||||
|
||||
@@ -234,18 +234,81 @@ class BaseTool(ABC):
|
||||
if getattr(self.run, '__isabstractmethod__', False):
|
||||
errors.append("Tool must implement the 'run()' method")
|
||||
|
||||
# Check parameters schema is valid
|
||||
# Check parameters schema is valid and OpenAI-compatible
|
||||
if self.parameters:
|
||||
if not isinstance(self.parameters, dict):
|
||||
errors.append("'parameters' must be a dictionary")
|
||||
elif "type" not in self.parameters:
|
||||
errors.append("'parameters' must have a 'type' field")
|
||||
elif "properties" not in self.parameters:
|
||||
errors.append("'parameters' must have a 'properties' field for OpenAI compatibility")
|
||||
|
||||
# Validate schema structure for OpenAI compatibility
|
||||
try:
|
||||
schema = self.get_schema()
|
||||
if not isinstance(schema, dict):
|
||||
errors.append("get_schema() must return a dictionary")
|
||||
elif schema.get("type") != "function":
|
||||
errors.append("get_schema() must return schema with type='function'")
|
||||
elif "function" not in schema:
|
||||
errors.append("get_schema() must have 'function' key")
|
||||
else:
|
||||
func = schema["function"]
|
||||
if not isinstance(func.get("parameters"), dict):
|
||||
errors.append("Schema function.parameters must be a dictionary")
|
||||
elif "properties" not in func["parameters"]:
|
||||
errors.append("Schema function.parameters must have 'properties' for OpenAI compatibility")
|
||||
except Exception as e:
|
||||
errors.append(f"Schema generation failed: {e}")
|
||||
|
||||
if errors:
|
||||
raise ToolValidationError(f"Tool '{self.name}' validation failed: {'; '.join(errors)}")
|
||||
|
||||
return True
|
||||
|
||||
def validate_schema_roundtrip(self) -> bool:
|
||||
"""Validate that schema can round-trip through OpenAI-style serialization.
|
||||
|
||||
This ensures the tool schema is compatible with LLM providers that expect
|
||||
OpenAI function calling format and that parameters can be properly parsed.
|
||||
|
||||
Raises:
|
||||
ToolValidationError: If round-trip fails
|
||||
|
||||
Returns:
|
||||
True if round-trip validation passes
|
||||
"""
|
||||
try:
|
||||
import json
|
||||
|
||||
# Get the schema
|
||||
schema = self.get_schema()
|
||||
|
||||
# Serialize to JSON (what gets sent to LLM)
|
||||
serialized = json.dumps(schema)
|
||||
|
||||
# Deserialize back
|
||||
deserialized = json.loads(serialized)
|
||||
|
||||
# Validate structure matches expectations
|
||||
if deserialized != schema:
|
||||
raise ToolValidationError(f"Schema serialization round-trip failed for tool '{self.name}'")
|
||||
|
||||
# Validate OpenAI format requirements
|
||||
func_schema = deserialized.get("function", {})
|
||||
parameters = func_schema.get("parameters", {})
|
||||
|
||||
if not isinstance(parameters.get("properties"), dict):
|
||||
raise ToolValidationError(f"Tool '{self.name}' parameters.properties must be a dict for OpenAI compatibility")
|
||||
|
||||
if "required" in parameters and not isinstance(parameters["required"], list):
|
||||
raise ToolValidationError(f"Tool '{self.name}' parameters.required must be a list")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
raise ToolValidationError(f"Schema round-trip validation failed for tool '{self.name}': {e}")
|
||||
|
||||
@classmethod
|
||||
def validate_class(cls) -> bool:
|
||||
"""Validate a tool class before instantiation.
|
||||
@@ -282,7 +345,9 @@ def validate_tool(tool: Any) -> bool:
|
||||
ToolValidationError: If validation fails
|
||||
"""
|
||||
if isinstance(tool, BaseTool):
|
||||
return tool.validate()
|
||||
tool.validate()
|
||||
tool.validate_schema_roundtrip()
|
||||
return True
|
||||
|
||||
if callable(tool):
|
||||
# For plain functions, check they have a name
|
||||
@@ -294,5 +359,67 @@ def validate_tool(tool: Any) -> bool:
|
||||
raise ToolValidationError(f"Invalid tool type: {type(tool)}")
|
||||
|
||||
|
||||
def validate_tool_schema_consistency(tools: List[Any]) -> bool:
|
||||
"""Validate a list of tools for schema consistency with OpenAI format.
|
||||
|
||||
This function ensures all tools in a list can be properly serialized
|
||||
and have consistent schema structures for use with LLM providers.
|
||||
|
||||
Args:
|
||||
tools: List of tool objects to validate
|
||||
|
||||
Returns:
|
||||
True if all tools are valid and consistent
|
||||
|
||||
Raises:
|
||||
ToolValidationError: If validation fails
|
||||
"""
|
||||
if not tools:
|
||||
return True
|
||||
|
||||
import json
|
||||
from .decorator import get_tool_schema
|
||||
schemas = []
|
||||
|
||||
for i, tool in enumerate(tools):
|
||||
try:
|
||||
# Validate individual tool
|
||||
validate_tool(tool)
|
||||
|
||||
# Get schema from different tool types
|
||||
if isinstance(tool, BaseTool):
|
||||
schema = tool.get_schema()
|
||||
elif hasattr(tool, 'get_schema') and callable(getattr(tool, 'get_schema')):
|
||||
schema = tool.get_schema()
|
||||
elif callable(tool):
|
||||
schema = get_tool_schema(tool)
|
||||
else:
|
||||
raise ToolValidationError(f"Cannot extract schema from tool at index {i}: {type(tool)}")
|
||||
|
||||
if not schema:
|
||||
raise ToolValidationError(f"Tool at index {i} returned empty schema")
|
||||
|
||||
# Validate JSON serialization
|
||||
try:
|
||||
json.dumps(schema)
|
||||
except (TypeError, ValueError) as e:
|
||||
raise ToolValidationError(f"Tool schema at index {i} is not JSON serializable: {e}")
|
||||
|
||||
schemas.append(schema)
|
||||
|
||||
except Exception as e:
|
||||
raise ToolValidationError(f"Tool validation failed at index {i}: {e}")
|
||||
|
||||
# Check for duplicate tool names
|
||||
names = set()
|
||||
for schema in schemas:
|
||||
name = schema.get("function", {}).get("name")
|
||||
if name in names:
|
||||
raise ToolValidationError(f"Duplicate tool name '{name}' found in tool list")
|
||||
names.add(name)
|
||||
|
||||
return True
|
||||
|
||||
|
||||
# For backward compatibility - tools can also just be functions
|
||||
# The @tool decorator (in decorator.py) wraps functions into BaseTool instances
|
||||
@@ -214,6 +214,13 @@ def tool(
|
||||
availability=availability
|
||||
)
|
||||
|
||||
# Validate the tool at creation time for early error detection
|
||||
try:
|
||||
tool_instance.validate()
|
||||
tool_instance.validate_schema_roundtrip()
|
||||
except Exception as e:
|
||||
logging.warning(f"Tool validation warning for {tool_instance.name}: {e}")
|
||||
|
||||
# Register with global registry if available
|
||||
try:
|
||||
from .registry import get_registry
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.45"
|
||||
version = "1.6.48"
|
||||
description = "Praison AI agents for completing complex tasks with Self Reflection Agents"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
|
||||
@@ -349,6 +349,161 @@ class TestDbSessionAdapter:
|
||||
assert meta["agent_id"] == "abc"
|
||||
assert meta["total_input_tokens"] == 42
|
||||
|
||||
def test_clear_session_purges_db_history(self):
|
||||
"""clear_session must not leave stale messages reloadable from the DB."""
|
||||
from praisonai.integrations.db_session_adapter import DbSessionAdapter
|
||||
from praisonaiagents.db.protocol import DbMessage
|
||||
|
||||
persisted: list = []
|
||||
|
||||
mock_db = MagicMock()
|
||||
|
||||
def _on_start(agent_name, session_id):
|
||||
return list(persisted)
|
||||
|
||||
def _on_user(sid, content, **kw):
|
||||
persisted.append(DbMessage(role="user", content=content))
|
||||
|
||||
def _on_assistant(sid, content, **kw):
|
||||
persisted.append(DbMessage(role="assistant", content=content))
|
||||
|
||||
mock_db.on_agent_start.side_effect = _on_start
|
||||
mock_db.on_user_message.side_effect = _on_user
|
||||
mock_db.on_agent_message.side_effect = _on_assistant
|
||||
|
||||
conv = MagicMock()
|
||||
conv.delete_messages.side_effect = lambda sid, ids: persisted.clear()
|
||||
mock_db.conversation = conv
|
||||
|
||||
adapter = DbSessionAdapter(mock_db)
|
||||
sid = "bot-trim-session"
|
||||
adapter.add_message(sid, "user", "old")
|
||||
adapter.add_message(sid, "assistant", "old-reply")
|
||||
assert len(persisted) == 2
|
||||
|
||||
adapter.clear_session(sid)
|
||||
conv.delete_messages.assert_called_once_with(sid, None)
|
||||
assert persisted == []
|
||||
assert adapter.get_chat_history(sid) == []
|
||||
|
||||
adapter.add_message(sid, "user", "new-only")
|
||||
assert len(adapter.get_chat_history(sid)) == 1
|
||||
assert len(persisted) == 1
|
||||
|
||||
adapter2 = DbSessionAdapter(mock_db)
|
||||
assert adapter2.get_chat_history(sid) == [{"role": "user", "content": "new-only"}]
|
||||
|
||||
def test_metadata_survives_adapter_restart(self):
|
||||
"""set_metadata must persist to the conversation store for resume after restart."""
|
||||
from praisonai.integrations.db_session_adapter import DbSessionAdapter
|
||||
from praisonai.persistence.conversation.base import ConversationSession
|
||||
|
||||
sessions: dict = {}
|
||||
|
||||
def _get_session(sid):
|
||||
return sessions.get(sid)
|
||||
|
||||
def _create_session(session):
|
||||
sessions[session.session_id] = session
|
||||
|
||||
def _update_session(session):
|
||||
sessions[session.session_id] = session
|
||||
|
||||
conv = MagicMock()
|
||||
conv.get_session.side_effect = _get_session
|
||||
conv.create_session.side_effect = _create_session
|
||||
conv.update_session.side_effect = _update_session
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.on_agent_start.return_value = []
|
||||
mock_db.conversation = None # Prevent auto-creation of conversation attr
|
||||
mock_db._conversation_store = conv
|
||||
|
||||
sid = "meta-restart-session"
|
||||
adapter1 = DbSessionAdapter(mock_db)
|
||||
adapter1.set_metadata(
|
||||
sid,
|
||||
{
|
||||
"agent_id": "agent-xyz",
|
||||
"total_input_tokens": 99,
|
||||
"session_history": [{"turn": 1}],
|
||||
},
|
||||
)
|
||||
|
||||
adapter2 = DbSessionAdapter(mock_db)
|
||||
meta = adapter2.get_metadata(sid)
|
||||
assert meta["agent_id"] == "agent-xyz"
|
||||
assert meta["total_input_tokens"] == 99
|
||||
assert meta["session_history"] == [{"turn": 1}]
|
||||
|
||||
def test_managed_agent_metadata_persistence_e2e(self):
|
||||
"""Integration test: ManagedAgent metadata survives restart via real adapter."""
|
||||
import tempfile
|
||||
import os
|
||||
from praisonaiagents.managed import LocalManagedAgent
|
||||
from praisonai.integrations.db_session_adapter import DbSessionAdapter
|
||||
|
||||
# Create a real SQLite database for this test
|
||||
with tempfile.NamedTemporaryFile(suffix=".db", delete=False) as tmp:
|
||||
db_path = tmp.name
|
||||
|
||||
try:
|
||||
# Mock PraisonDB that uses file-based conversation store
|
||||
mock_db = MagicMock()
|
||||
mock_db.on_agent_start.return_value = []
|
||||
|
||||
# Real conversation store backed by file
|
||||
from praisonai.persistence.conversation.base import ConversationSession
|
||||
from praisonai.persistence.conversation.file_store import FileConversationStore
|
||||
|
||||
conv_store = FileConversationStore(store_path=db_path + "_conv.json")
|
||||
mock_db.conversation = None
|
||||
mock_db._conversation_store = conv_store
|
||||
|
||||
# Create and run agent with metadata
|
||||
adapter = DbSessionAdapter(mock_db)
|
||||
agent = LocalManagedAgent(
|
||||
name="test-agent",
|
||||
instructions="You are a helpful assistant.",
|
||||
session_store=adapter,
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Start agent and collect session metadata
|
||||
session_id = "integration-test-session"
|
||||
adapter._ensure_session(session_id, agent.name)
|
||||
|
||||
# Set some metadata that should persist
|
||||
test_metadata = {
|
||||
"agent_id": "test-agent-123",
|
||||
"total_input_tokens": 150,
|
||||
"session_history": [{"turn": 1, "action": "started"}],
|
||||
"compute_instance": "test-instance-1"
|
||||
}
|
||||
adapter.set_metadata(session_id, test_metadata)
|
||||
|
||||
# Simulate process restart with new adapter and agent
|
||||
adapter2 = DbSessionAdapter(mock_db)
|
||||
agent2 = LocalManagedAgent(
|
||||
name="test-agent",
|
||||
instructions="You are a helpful assistant.",
|
||||
session_store=adapter2,
|
||||
llm="gpt-4o-mini"
|
||||
)
|
||||
|
||||
# Metadata should be restored from persistence
|
||||
restored_meta = adapter2.get_metadata(session_id)
|
||||
assert restored_meta["agent_id"] == "test-agent-123"
|
||||
assert restored_meta["total_input_tokens"] == 150
|
||||
assert restored_meta["session_history"] == [{"turn": 1, "action": "started"}]
|
||||
assert restored_meta["compute_instance"] == "test-instance-1"
|
||||
|
||||
finally:
|
||||
# Clean up temp files
|
||||
for path in [db_path, db_path + "_conv.json"]:
|
||||
if os.path.exists(path):
|
||||
os.unlink(path)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 7. ManagedAgent factory wiring
|
||||
|
||||
@@ -6,6 +6,7 @@ Databases tested (all via local Docker):
|
||||
2. PostgreSQL — ConversationStore + ManagedAgent roundtrip
|
||||
3. MySQL — ConversationStore + ManagedAgent roundtrip
|
||||
4. Redis — StateStore + metadata persistence
|
||||
4a. Valkey — StateStore + metadata persistence
|
||||
5. MongoDB — StateStore + metadata persistence
|
||||
6. ClickHouse — Raw connectivity + data write/read
|
||||
7. JSON file — DefaultSessionStore + ManagedAgent roundtrip
|
||||
@@ -27,7 +28,6 @@ from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -562,6 +562,206 @@ class TestRedisStateStore:
|
||||
r.close()
|
||||
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4a. Valkey — StateStore operations + metadata persistence
|
||||
# ===========================================================================
|
||||
class TestValkeyStateStore:
|
||||
"""Valkey StateStore: real operations against a live Valkey instance."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def check_valkey(self):
|
||||
"""Skip if Valkey not available."""
|
||||
try:
|
||||
from praisonai.persistence._valkey_client import create_valkey_client
|
||||
client = create_valkey_client(host="localhost", port=6379)
|
||||
client.ping()
|
||||
client.close()
|
||||
except Exception:
|
||||
pytest.skip("Valkey not available on localhost:6379")
|
||||
|
||||
def test_valkey_state_operations(self):
|
||||
"""StateStore get/set/delete/exists/hash with real Valkey."""
|
||||
from praisonai.persistence.state.valkey import ValkeyStateStore
|
||||
prefix = f"test_{uuid.uuid4().hex[:6]}:"
|
||||
store = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
|
||||
|
||||
try:
|
||||
# Basic set/get
|
||||
store.set("agent_id", "agent_valkey_001")
|
||||
assert store.get("agent_id") == "agent_valkey_001"
|
||||
|
||||
# JSON roundtrip
|
||||
state = {
|
||||
"agent_id": "agent_valkey_001",
|
||||
"agent_version": 3,
|
||||
"total_input_tokens": 500,
|
||||
"total_output_tokens": 200,
|
||||
"compute_instance_id": "docker_valkey_test",
|
||||
"session_history": [{"id": "s1", "status": "idle"}],
|
||||
}
|
||||
store.set_json("managed_state", state)
|
||||
recovered = store.get_json("managed_state")
|
||||
assert recovered["agent_id"] == "agent_valkey_001"
|
||||
assert recovered["total_input_tokens"] == 500
|
||||
assert recovered["compute_instance_id"] == "docker_valkey_test"
|
||||
assert len(recovered["session_history"]) == 1
|
||||
|
||||
# Hash operations
|
||||
store.hset("agent_meta", "version", "5")
|
||||
store.hset("agent_meta", "env_id", "env_abc")
|
||||
assert str(store.hget("agent_meta", "version")) == "5"
|
||||
all_meta = store.hgetall("agent_meta")
|
||||
assert str(all_meta["env_id"]) == "env_abc"
|
||||
|
||||
# Exists and delete
|
||||
assert store.exists("agent_id")
|
||||
store.delete("agent_id")
|
||||
assert not store.exists("agent_id")
|
||||
|
||||
finally:
|
||||
for k in store.keys():
|
||||
store.delete(k)
|
||||
store.close()
|
||||
|
||||
def test_valkey_managed_state_persist_restore(self):
|
||||
"""Simulate managed agent state persist/restore across store instances."""
|
||||
from praisonai.persistence.state.valkey import ValkeyStateStore
|
||||
prefix = f"mgd_{uuid.uuid4().hex[:6]}:"
|
||||
store = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
|
||||
|
||||
try:
|
||||
session_id = f"valkey_session_{uuid.uuid4().hex[:8]}"
|
||||
state = {
|
||||
"agent_id": "agent_valkey_mgd",
|
||||
"agent_version": 2,
|
||||
"environment_id": "env_valkey_001",
|
||||
"total_input_tokens": 1000,
|
||||
"total_output_tokens": 500,
|
||||
"compute_instance_id": "modal_valkey_xyz",
|
||||
"session_history": [
|
||||
{"id": session_id, "status": "idle", "title": "Valkey test"},
|
||||
],
|
||||
}
|
||||
store.set_json(f"managed:{session_id}", state)
|
||||
|
||||
# Simulate restart — new store instance, same prefix
|
||||
store2 = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
|
||||
recovered = store2.get_json(f"managed:{session_id}")
|
||||
assert recovered is not None
|
||||
assert recovered["agent_id"] == "agent_valkey_mgd"
|
||||
assert recovered["total_input_tokens"] == 1000
|
||||
assert recovered["compute_instance_id"] == "modal_valkey_xyz"
|
||||
assert len(recovered["session_history"]) == 1
|
||||
|
||||
store.close()
|
||||
store2.close()
|
||||
finally:
|
||||
store3 = ValkeyStateStore(host="localhost", port=6379, prefix=prefix)
|
||||
for k in store3.keys():
|
||||
store3.delete(k)
|
||||
store3.close()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 4b. Valkey Vector Search — ValkeyVectorKnowledgeStore against a live instance
|
||||
# ===========================================================================
|
||||
class TestValkeyVectorSearch:
|
||||
"""Valkey vector search: real operations against a live ValkeySearch instance."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def check_valkey_search(self):
|
||||
"""Skip if Valkey is unreachable or ValkeySearch (FT.*) is not loaded."""
|
||||
try:
|
||||
from glide_sync import ft
|
||||
except ImportError:
|
||||
pytest.skip("valkey-glide-sync not installed")
|
||||
|
||||
try:
|
||||
from praisonai.persistence._valkey_client import create_valkey_client
|
||||
except ImportError as e:
|
||||
pytest.skip(f"praisonai not importable: {e}")
|
||||
|
||||
client = None
|
||||
try:
|
||||
client = create_valkey_client(host="localhost", port=6379)
|
||||
client.ping()
|
||||
except Exception as e:
|
||||
if client is not None:
|
||||
client.close()
|
||||
pytest.skip(f"Valkey not available on localhost:6379: {e}")
|
||||
|
||||
try:
|
||||
# Probe FT.* support; raises if module not loaded
|
||||
ft.list(client)
|
||||
except Exception as e:
|
||||
pytest.skip(f"ValkeySearch (FT.*) module not loaded on localhost:6379: {e}")
|
||||
finally:
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
def test_valkey_vector_search_roundtrip(self):
|
||||
"""Full create → insert → search → delete_collection roundtrip."""
|
||||
from praisonai.persistence.knowledge.valkey_vector import ValkeyVectorKnowledgeStore
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
|
||||
prefix = f"praisontest_{uuid.uuid4().hex[:6]}_"
|
||||
collection = f"vec_{uuid.uuid4().hex[:6]}"
|
||||
dimension = 4
|
||||
store = ValkeyVectorKnowledgeStore(host="localhost", port=6379, prefix=prefix)
|
||||
|
||||
try:
|
||||
# Create index
|
||||
store.create_collection(collection, dimension=dimension, distance="cosine")
|
||||
assert store.collection_exists(collection)
|
||||
|
||||
# Insert documents with simple, distinguishable embeddings
|
||||
docs = [
|
||||
KnowledgeDocument(
|
||||
id="doc_a",
|
||||
content="alpha document",
|
||||
embedding=[1.0, 0.0, 0.0, 0.0],
|
||||
),
|
||||
KnowledgeDocument(
|
||||
id="doc_b",
|
||||
content="bravo document",
|
||||
embedding=[0.0, 1.0, 0.0, 0.0],
|
||||
),
|
||||
KnowledgeDocument(
|
||||
id="doc_c",
|
||||
content="charlie document",
|
||||
embedding=[0.0, 0.0, 1.0, 0.0],
|
||||
),
|
||||
]
|
||||
inserted = store.insert(collection, docs)
|
||||
assert set(inserted) == {"doc_a", "doc_b", "doc_c"}
|
||||
|
||||
# Vector search: query closest to doc_a
|
||||
results = store.search(collection, query_embedding=[1.0, 0.0, 0.0, 0.0], limit=2)
|
||||
assert len(results) >= 1, "Expected at least one search result"
|
||||
assert results[0].id == "doc_a", f"Expected doc_a as nearest neighbor, got {results[0].id}"
|
||||
assert results[0].content == "alpha document"
|
||||
|
||||
# Verify the bare ID is returned (no prefix leakage)
|
||||
for r in results:
|
||||
assert ":" not in r.id or r.id in {"doc_a", "doc_b", "doc_c"}
|
||||
|
||||
# get() roundtrip
|
||||
got = store.get(collection, ["doc_b"])
|
||||
assert len(got) == 1
|
||||
assert got[0].id == "doc_b"
|
||||
assert got[0].content == "bravo document"
|
||||
|
||||
# Delete one doc
|
||||
deleted = store.delete(collection, ids=["doc_c"])
|
||||
assert deleted == 1
|
||||
finally:
|
||||
try:
|
||||
store.delete_collection(collection)
|
||||
finally:
|
||||
store.close()
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# 5. MongoDB — StateStore operations + metadata persistence
|
||||
# ===========================================================================
|
||||
@@ -673,7 +873,6 @@ class TestClickHouseConnectivity:
|
||||
def test_clickhouse_write_read(self):
|
||||
"""Write and read data from ClickHouse."""
|
||||
import clickhouse_connect
|
||||
|
||||
table = f"praison_test_{uuid.uuid4().hex[:8]}"
|
||||
c = clickhouse_connect.get_client(
|
||||
host="localhost", port=8123, username="clickhouse", password="clickhouse",
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
"""Test tool schema parity and OpenAI compatibility.
|
||||
|
||||
This module tests the fixes for issue #1696 to ensure BaseTool ↔ OpenAI-compatible
|
||||
tool schema parity in chat/agent dispatch.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from typing import Any, Dict, List
|
||||
from praisonaiagents.tools.base import BaseTool, ToolValidationError, validate_tool_schema_consistency
|
||||
from praisonaiagents.tools.decorator import tool, FunctionTool
|
||||
|
||||
|
||||
class MockTool(BaseTool):
|
||||
"""A simple mock tool for testing."""
|
||||
|
||||
name = "mock_tool"
|
||||
description = "A mock tool for testing"
|
||||
|
||||
def run(self, query: str, count: int = 1) -> str:
|
||||
return f"Mock result for {query} (count: {count})"
|
||||
|
||||
|
||||
class TestBasicToolSchemaValidation:
|
||||
"""Test basic tool schema validation and OpenAI compatibility."""
|
||||
|
||||
def test_base_tool_schema_structure(self):
|
||||
"""Test that BaseTool generates correct OpenAI schema structure."""
|
||||
tool = MockTool()
|
||||
schema = tool.get_schema()
|
||||
|
||||
# Validate top-level structure
|
||||
assert isinstance(schema, dict)
|
||||
assert schema["type"] == "function"
|
||||
assert "function" in schema
|
||||
|
||||
# Validate function structure
|
||||
func = schema["function"]
|
||||
assert func["name"] == "mock_tool"
|
||||
assert func["description"] == "A mock tool for testing"
|
||||
assert "parameters" in func
|
||||
|
||||
# Validate parameters structure
|
||||
params = func["parameters"]
|
||||
assert params["type"] == "object"
|
||||
assert "properties" in params
|
||||
assert "required" in params
|
||||
|
||||
# Validate specific properties
|
||||
props = params["properties"]
|
||||
assert "query" in props
|
||||
assert "count" in props
|
||||
assert props["query"]["type"] == "string"
|
||||
assert props["count"]["type"] == "integer"
|
||||
|
||||
# Validate required fields
|
||||
assert "query" in params["required"]
|
||||
assert "count" not in params["required"] # Has default value
|
||||
|
||||
def test_tool_schema_json_serialization(self):
|
||||
"""Test that tool schemas can be JSON serialized (round-trip test)."""
|
||||
tool = MockTool()
|
||||
schema = tool.get_schema()
|
||||
|
||||
# Should serialize without errors
|
||||
serialized = json.dumps(schema)
|
||||
assert isinstance(serialized, str)
|
||||
|
||||
# Should deserialize back to same structure
|
||||
deserialized = json.loads(serialized)
|
||||
assert deserialized == schema
|
||||
|
||||
def test_tool_validation_passes(self):
|
||||
"""Test that valid tools pass validation."""
|
||||
tool = MockTool()
|
||||
|
||||
# Basic validation should pass
|
||||
assert tool.validate() == True
|
||||
|
||||
# Round-trip validation should pass
|
||||
assert tool.validate_schema_roundtrip() == True
|
||||
|
||||
def test_function_tool_schema_parity(self):
|
||||
"""Test that @tool decorated functions have same schema structure."""
|
||||
@tool
|
||||
def test_function(query: str, count: int = 1) -> str:
|
||||
"""A test function for schema validation."""
|
||||
return f"Result for {query} (count: {count})"
|
||||
|
||||
schema = test_function.get_schema()
|
||||
|
||||
# Should have same structure as BaseTool
|
||||
assert schema["type"] == "function"
|
||||
func = schema["function"]
|
||||
assert func["name"] == "test_function"
|
||||
assert "parameters" in func
|
||||
|
||||
params = func["parameters"]
|
||||
assert params["type"] == "object"
|
||||
assert "properties" in params
|
||||
assert "required" in params
|
||||
|
||||
|
||||
class TestToolSchemaValidationErrors:
|
||||
"""Test that validation catches schema errors."""
|
||||
|
||||
def test_invalid_tool_missing_properties(self):
|
||||
"""Test validation fails for tools with invalid parameters."""
|
||||
|
||||
class BadTool(BaseTool):
|
||||
name = "bad_tool"
|
||||
description = "Bad tool"
|
||||
parameters = {"type": "object"} # Missing properties
|
||||
|
||||
def run(self, query: str) -> str:
|
||||
return query
|
||||
|
||||
tool = BadTool()
|
||||
with pytest.raises(ToolValidationError, match="must have a 'properties' field"):
|
||||
tool.validate()
|
||||
|
||||
def test_invalid_tool_missing_type(self):
|
||||
"""Test validation fails for tools with missing type."""
|
||||
|
||||
class BadTool(BaseTool):
|
||||
name = "bad_tool"
|
||||
description = "Bad tool"
|
||||
parameters = {"properties": {}} # Missing type
|
||||
|
||||
def run(self, query: str) -> str:
|
||||
return query
|
||||
|
||||
tool = BadTool()
|
||||
with pytest.raises(ToolValidationError, match="must have a 'type' field"):
|
||||
tool.validate()
|
||||
|
||||
|
||||
class TestToolListValidation:
|
||||
"""Test validation of tool lists for consistency."""
|
||||
|
||||
def test_valid_tool_list(self):
|
||||
"""Test that valid tool lists pass consistency checks."""
|
||||
@tool
|
||||
def tool_one(x: str) -> str:
|
||||
"""First tool."""
|
||||
return x
|
||||
|
||||
@tool
|
||||
def tool_two(y: int) -> int:
|
||||
"""Second tool."""
|
||||
return y
|
||||
|
||||
tool_three = MockTool()
|
||||
|
||||
tools = [tool_one, tool_two, tool_three]
|
||||
|
||||
# Should pass validation
|
||||
assert validate_tool_schema_consistency(tools) == True
|
||||
|
||||
def test_duplicate_tool_names_error(self):
|
||||
"""Test that duplicate tool names are caught."""
|
||||
@tool(name="duplicate_name")
|
||||
def tool_one(x: str) -> str:
|
||||
return x
|
||||
|
||||
@tool(name="duplicate_name")
|
||||
def tool_two(y: int) -> int:
|
||||
return y
|
||||
|
||||
tools = [tool_one, tool_two]
|
||||
|
||||
with pytest.raises(ToolValidationError, match="Duplicate tool name"):
|
||||
validate_tool_schema_consistency(tools)
|
||||
|
||||
def test_empty_tool_list(self):
|
||||
"""Test that empty tool lists are valid."""
|
||||
assert validate_tool_schema_consistency([]) == True
|
||||
|
||||
|
||||
class TestRealAgenticScenario:
|
||||
"""Test real agentic scenarios to ensure end-to-end compatibility."""
|
||||
|
||||
def test_tool_schema_agent_integration(self):
|
||||
"""Test that tools work with agent in real scenario."""
|
||||
from praisonaiagents import Agent
|
||||
|
||||
@tool
|
||||
def echo_tool(message: str) -> str:
|
||||
"""Echo the input message."""
|
||||
return f"Echo: {message}"
|
||||
|
||||
# Create agent with tool - should not raise validation errors
|
||||
agent = Agent(
|
||||
name="test_agent",
|
||||
instructions="You are a helpful assistant",
|
||||
tools=[echo_tool]
|
||||
)
|
||||
|
||||
# Schema should be valid for LLM use
|
||||
schema = echo_tool.get_schema()
|
||||
assert schema["type"] == "function"
|
||||
|
||||
# Should be JSON serializable
|
||||
json.dumps(schema)
|
||||
|
||||
# Agent should format tools properly
|
||||
formatted_tools = agent._format_tools_for_completion(agent.tools)
|
||||
assert len(formatted_tools) == 1
|
||||
assert formatted_tools[0]["type"] == "function"
|
||||
|
||||
print("✅ Real agentic test passed: Tool schema integration works end-to-end")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for Agent.__deepcopy__ and Agent.clone_for_channel() (issue #1746).
|
||||
|
||||
Validates:
|
||||
- copy.deepcopy(agent) works without threading.RLock errors
|
||||
- clone_for_channel() returns an independent instance
|
||||
- Cloned agent has its own lock objects (not shared)
|
||||
- Key configuration attributes are preserved in the clone
|
||||
- Multiple clones are isolated from each other
|
||||
"""
|
||||
import copy
|
||||
import threading
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAgentDeepCopy:
|
||||
"""Test Agent.__deepcopy__ and clone_for_channel()."""
|
||||
|
||||
def _make_agent(self, **kwargs):
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
return Agent(name="TestAgent", instructions="test", **kwargs)
|
||||
|
||||
def test_deepcopy_does_not_raise(self):
|
||||
"""copy.deepcopy(agent) must not raise TypeError for RLock."""
|
||||
agent = self._make_agent()
|
||||
cloned = copy.deepcopy(agent)
|
||||
assert cloned is not agent
|
||||
|
||||
def test_clone_for_channel_returns_new_instance(self):
|
||||
agent = self._make_agent()
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned is not agent
|
||||
|
||||
def test_clone_has_independent_cache_lock(self):
|
||||
"""__cache_lock must be a new RLock, not shared with original."""
|
||||
agent = self._make_agent()
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned._Agent__cache_lock is not agent._Agent__cache_lock
|
||||
assert isinstance(cloned._Agent__cache_lock, type(threading.RLock()))
|
||||
|
||||
def test_clone_has_independent_cost_lock(self):
|
||||
"""_cost_lock must be a new Lock, not shared with original."""
|
||||
agent = self._make_agent()
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned._cost_lock is not agent._cost_lock
|
||||
|
||||
def test_clone_preserves_name(self):
|
||||
agent = self._make_agent(name="MyAgent")
|
||||
assert agent.clone_for_channel().name == "MyAgent"
|
||||
|
||||
def test_clone_preserves_instructions(self):
|
||||
agent = self._make_agent(instructions="Do the thing")
|
||||
assert agent.clone_for_channel().instructions == "Do the thing"
|
||||
|
||||
def test_clone_preserves_role_goal_backstory(self):
|
||||
agent = self._make_agent(
|
||||
role="Analyst",
|
||||
goal="Analyse",
|
||||
backstory="Expert analyst",
|
||||
)
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned.role == "Analyst"
|
||||
assert cloned.goal == "Analyse"
|
||||
assert cloned.backstory == "Expert analyst"
|
||||
|
||||
def test_clone_preserves_llm(self):
|
||||
agent = self._make_agent(llm="gpt-4o-mini")
|
||||
assert agent.clone_for_channel().llm == "gpt-4o-mini"
|
||||
|
||||
def test_multiple_clones_are_isolated(self):
|
||||
"""Regression: creating two clones (simulating 2nd+ gateway channel) must work."""
|
||||
agent = self._make_agent()
|
||||
clone1 = agent.clone_for_channel()
|
||||
clone2 = agent.clone_for_channel()
|
||||
assert clone1 is not clone2
|
||||
assert clone1._Agent__cache_lock is not clone2._Agent__cache_lock
|
||||
|
||||
def test_deepcopy_multiple_times(self):
|
||||
"""Successive deepcopies must all succeed (no cumulative state corruption)."""
|
||||
agent = self._make_agent()
|
||||
for _ in range(3):
|
||||
cloned = copy.deepcopy(agent)
|
||||
assert cloned is not agent
|
||||
@@ -0,0 +1,45 @@
|
||||
from types import SimpleNamespace
|
||||
import sys
|
||||
|
||||
|
||||
def test_start_verbose_composed_callbacks_forward_all_arguments(monkeypatch):
|
||||
from praisonaiagents import Agent, AgentTeam
|
||||
|
||||
on_start_calls = []
|
||||
on_complete_calls = []
|
||||
debug_messages = []
|
||||
|
||||
def on_task_start(task, task_id):
|
||||
on_start_calls.append((task, task_id))
|
||||
|
||||
def on_task_complete(task, task_output):
|
||||
on_complete_calls.append((task, task_output))
|
||||
|
||||
team = AgentTeam(
|
||||
agents=[
|
||||
Agent(name="Researcher", instructions="Research"),
|
||||
Agent(name="Writer", instructions="Write"),
|
||||
],
|
||||
hooks={
|
||||
"on_task_start": on_task_start,
|
||||
"on_task_complete": on_task_complete,
|
||||
},
|
||||
)
|
||||
|
||||
def fake_run_all_tasks():
|
||||
task_id, task = next(iter(team.tasks.items()))
|
||||
task_output = SimpleNamespace(raw="done")
|
||||
team.on_task_start(task, task_id)
|
||||
team.on_task_complete(task, task_output)
|
||||
|
||||
monkeypatch.setattr(team, "run_all_tasks", fake_run_all_tasks)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr("praisonaiagents.agents.agents.logging.debug", lambda msg: debug_messages.append(str(msg)))
|
||||
|
||||
team.start(output="verbose")
|
||||
|
||||
assert len(on_start_calls) == 1
|
||||
assert len(on_complete_calls) == 1
|
||||
assert on_start_calls[0][1] == next(iter(team.tasks.keys()))
|
||||
assert on_complete_calls[0][1].raw == "done"
|
||||
assert all("Error in verbose task" not in message for message in debug_messages)
|
||||
@@ -48,6 +48,12 @@ class TestResolveMemory:
|
||||
result = resolve_memory("redis")
|
||||
assert isinstance(result, MemoryConfig)
|
||||
assert result.backend == MemoryBackend.REDIS
|
||||
|
||||
def test_string_valkey_backend(self):
|
||||
"""'valkey' string returns MemoryConfig with VALKEY backend."""
|
||||
result = resolve_memory("valkey")
|
||||
assert isinstance(result, MemoryConfig)
|
||||
assert result.backend == MemoryBackend.VALKEY
|
||||
|
||||
def test_string_custom_backend(self):
|
||||
"""Custom string backend is preserved."""
|
||||
|
||||
@@ -5,6 +5,8 @@ Tests Mem0Adapter lazy loading and normalization.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -174,3 +176,54 @@ class TestProtocolCompliance:
|
||||
|
||||
for method in required_methods:
|
||||
assert hasattr(Mem0Adapter, method), f"Missing method: {method}"
|
||||
|
||||
|
||||
class TestChromaKnowledgeAdapterWhereFilters:
|
||||
"""Tests for ChromaKnowledgeAdapter where-filter formatting."""
|
||||
|
||||
def _make_adapter(self):
|
||||
from praisonaiagents.knowledge.adapters.factories import ChromaKnowledgeAdapter
|
||||
|
||||
adapter = ChromaKnowledgeAdapter.__new__(ChromaKnowledgeAdapter)
|
||||
adapter.collection = MagicMock()
|
||||
adapter.collection.query.return_value = {
|
||||
"ids": [[]],
|
||||
"documents": [[]],
|
||||
"metadatas": [[]],
|
||||
"distances": [[]],
|
||||
}
|
||||
return adapter
|
||||
|
||||
def test_search_wraps_multiple_where_filters_with_and(self, monkeypatch):
|
||||
embedding_module = importlib.import_module("praisonaiagents.embedding")
|
||||
|
||||
adapter = self._make_adapter()
|
||||
monkeypatch.setattr(
|
||||
embedding_module,
|
||||
"embedding",
|
||||
lambda *args, **kwargs: SimpleNamespace(embeddings=[[0.1, 0.2]]),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
adapter.search("q", user_id="u1", agent_id="a1", run_id="r1")
|
||||
|
||||
where = adapter.collection.query.call_args.kwargs["where"]
|
||||
assert where == {
|
||||
"$and": [{"user_id": "u1"}, {"agent_id": "a1"}, {"run_id": "r1"}]
|
||||
}
|
||||
|
||||
def test_search_keeps_single_where_filter_unwrapped(self, monkeypatch):
|
||||
embedding_module = importlib.import_module("praisonaiagents.embedding")
|
||||
|
||||
adapter = self._make_adapter()
|
||||
monkeypatch.setattr(
|
||||
embedding_module,
|
||||
"embedding",
|
||||
lambda *args, **kwargs: SimpleNamespace(embeddings=[[0.1, 0.2]]),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
adapter.search("q", user_id="u1")
|
||||
|
||||
where = adapter.collection.query.call_args.kwargs["where"]
|
||||
assert where == {"user_id": "u1"}
|
||||
@@ -223,3 +223,188 @@ class TestDefaultContextBuilder:
|
||||
|
||||
context = builder.build(results)
|
||||
assert "\n\n" in context
|
||||
|
||||
|
||||
class TestExtractValue:
|
||||
"""Tests for the _extract_value helper function."""
|
||||
|
||||
def test_extract_value_from_dict(self):
|
||||
"""Test extracting value from dictionary."""
|
||||
from praisonaiagents.rag.context import _extract_value
|
||||
|
||||
item = {"text": "test content", "metadata": {"source": "test.pdf"}}
|
||||
|
||||
assert _extract_value(item, "text") == "test content"
|
||||
assert _extract_value(item, "metadata")["source"] == "test.pdf"
|
||||
assert _extract_value(item, "nonexistent", "default") == "default"
|
||||
|
||||
def test_extract_value_from_searchresultitem(self):
|
||||
"""Test extracting value from SearchResultItem object."""
|
||||
from praisonaiagents.rag.context import _extract_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(
|
||||
text="test content",
|
||||
source="test.pdf",
|
||||
filename="test.pdf",
|
||||
metadata={"extra": "data"}
|
||||
)
|
||||
|
||||
assert _extract_value(item, "text") == "test content"
|
||||
assert _extract_value(item, "source") == "test.pdf"
|
||||
assert _extract_value(item, "filename") == "test.pdf"
|
||||
assert _extract_value(item, "metadata")["extra"] == "data"
|
||||
assert _extract_value(item, "nonexistent", "default") == "default"
|
||||
|
||||
|
||||
class TestExtractMetadataValue:
|
||||
"""Tests for the _extract_metadata_value helper function."""
|
||||
|
||||
def test_extract_metadata_value_from_metadata(self):
|
||||
"""Test extracting value from metadata dict."""
|
||||
from praisonaiagents.rag.context import _extract_metadata_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(text="content", source="fallback.pdf")
|
||||
metadata = {"source": "metadata.pdf"}
|
||||
|
||||
# Should prefer metadata over top-level
|
||||
result = _extract_metadata_value(item, metadata, "source", "")
|
||||
assert result == "metadata.pdf"
|
||||
|
||||
def test_extract_metadata_value_fallback_to_toplevel(self):
|
||||
"""Test fallback to top-level when metadata doesn't have key."""
|
||||
from praisonaiagents.rag.context import _extract_metadata_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(text="content", source="toplevel.pdf")
|
||||
metadata = {} # Empty metadata
|
||||
|
||||
# Should fall back to top-level source
|
||||
result = _extract_metadata_value(item, metadata, "source", "")
|
||||
assert result == "toplevel.pdf"
|
||||
|
||||
def test_extract_metadata_value_with_default(self):
|
||||
"""Test fallback to default when neither metadata nor top-level have key."""
|
||||
from praisonaiagents.rag.context import _extract_metadata_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(text="content") # No source
|
||||
metadata = {} # No source in metadata
|
||||
|
||||
# Should use default
|
||||
result = _extract_metadata_value(item, metadata, "source", "default.pdf")
|
||||
assert result == "default.pdf"
|
||||
|
||||
|
||||
class TestSearchResultItemSupport:
|
||||
"""Tests for SearchResultItem object support in context functions."""
|
||||
|
||||
def test_deduplicate_chunks_with_searchresultitem(self):
|
||||
"""Test deduplication with SearchResultItem objects."""
|
||||
from praisonaiagents.rag.context import deduplicate_chunks
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
SearchResultItem(text="Same content", source="a.pdf"),
|
||||
SearchResultItem(text="Same content", source="a.pdf"), # Duplicate
|
||||
SearchResultItem(text="Different content", source="b.pdf"),
|
||||
]
|
||||
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2 # One duplicate should be removed
|
||||
|
||||
def test_deduplicate_chunks_mixed_formats(self):
|
||||
"""Test deduplication with mixed dict and SearchResultItem formats."""
|
||||
from praisonaiagents.rag.context import deduplicate_chunks
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
{"text": "Content A", "metadata": {"source": "a.pdf"}},
|
||||
SearchResultItem(text="Content A", source="a.pdf"), # Should be deduped
|
||||
SearchResultItem(text="Content B", source="b.pdf"),
|
||||
]
|
||||
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2 # One duplicate should be removed
|
||||
|
||||
def test_deduplicate_with_top_level_source(self):
|
||||
"""Test deduplication considers top-level source from SearchResultItem."""
|
||||
from praisonaiagents.rag.context import deduplicate_chunks
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
SearchResultItem(text="Same text", source="source1.pdf"),
|
||||
SearchResultItem(text="Same text", source="source2.pdf"),
|
||||
]
|
||||
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2 # Different sources should not be deduped
|
||||
|
||||
def test_build_context_with_searchresultitem(self):
|
||||
"""Test building context with SearchResultItem objects."""
|
||||
from praisonaiagents.rag.context import build_context
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
SearchResultItem(
|
||||
text="First content",
|
||||
source="source1.pdf",
|
||||
filename="file1.pdf"
|
||||
),
|
||||
SearchResultItem(
|
||||
text="Second content",
|
||||
source="source2.pdf",
|
||||
filename="file2.pdf"
|
||||
),
|
||||
]
|
||||
|
||||
context, used = build_context(results)
|
||||
|
||||
assert "First content" in context
|
||||
assert "Second content" in context
|
||||
assert "file1.pdf" in context # Should use filename
|
||||
assert "file2.pdf" in context
|
||||
assert len(used) == 2
|
||||
|
||||
def test_build_context_source_fallback(self):
|
||||
"""Test that build_context falls back to top-level source when metadata is empty."""
|
||||
from praisonaiagents.rag.context import build_context
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
# SearchResultItem with source but empty metadata
|
||||
results = [
|
||||
SearchResultItem(
|
||||
text="Content with source",
|
||||
source="fallback.pdf",
|
||||
metadata={} # Empty metadata, should fall back to top-level source
|
||||
)
|
||||
]
|
||||
|
||||
context, used = build_context(results, include_source=True)
|
||||
|
||||
assert "Content with source" in context
|
||||
assert "fallback.pdf" in context # Should use top-level source
|
||||
assert len(used) == 1
|
||||
|
||||
def test_build_context_mixed_formats_with_sources(self):
|
||||
"""Test building context with mixed dict/SearchResultItem formats."""
|
||||
from praisonaiagents.rag.context import build_context
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
{"text": "Dict content", "metadata": {"filename": "dict.pdf"}},
|
||||
SearchResultItem(
|
||||
text="Object content",
|
||||
filename="object.pdf",
|
||||
metadata={}
|
||||
),
|
||||
]
|
||||
|
||||
context, used = build_context(results, include_source=True)
|
||||
|
||||
assert "Dict content" in context
|
||||
assert "Object content" in context
|
||||
assert "dict.pdf" in context
|
||||
assert "object.pdf" in context # Should use top-level filename
|
||||
assert len(used) == 2
|
||||
@@ -8,6 +8,7 @@ from praisonaiagents.rag.context import (
|
||||
deduplicate_chunks,
|
||||
build_context,
|
||||
)
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
|
||||
class TestDeduplicateChunksNormalization:
|
||||
@@ -122,3 +123,25 @@ class TestEdgeCases:
|
||||
context, used = build_context(results)
|
||||
assert len(used) == 1
|
||||
assert "valid content" in context
|
||||
|
||||
|
||||
class TestSearchResultItemCompatibility:
|
||||
"""Tests for SearchResultItem object compatibility."""
|
||||
|
||||
def test_deduplicate_uses_source_attribute_fallback(self):
|
||||
"""Different source attributes should produce unique chunks."""
|
||||
results = [
|
||||
SearchResultItem(text="same content", source="a.pdf"),
|
||||
SearchResultItem(text="same content", source="b.pdf"),
|
||||
]
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2
|
||||
|
||||
def test_build_context_uses_filename_attribute_fallback(self):
|
||||
"""Source label should use object filename/source attributes."""
|
||||
results = [
|
||||
SearchResultItem(text="chunk", filename="doc.md"),
|
||||
]
|
||||
context, used = build_context(results, include_source=True)
|
||||
assert len(used) == 1
|
||||
assert "doc.md" in context
|
||||
@@ -103,6 +103,35 @@ class TestHierarchicalSessionStore:
|
||||
"""Clean up test fixtures."""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_add_message_preserves_concurrent_writes(self):
|
||||
"""Concurrent add_message must not lose messages (same as DefaultSessionStore)."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
reader = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
def test_update_session_metadata_preserves_extended_fields(self):
|
||||
"""Metadata updates must not strip parent_id, snapshots, etc."""
|
||||
session_id = self.store.create_session(title="Parent")
|
||||
self.store.add_message(session_id, "user", "hello")
|
||||
parent_id = self.store.create_session(title="Child", parent_id=session_id)
|
||||
snapshot_id = self.store.create_snapshot(session_id, label="checkpoint")
|
||||
|
||||
assert self.store.update_session_metadata(session_id, model="gpt-4o-mini")
|
||||
|
||||
session = self.store.get_extended_session(session_id)
|
||||
assert session.metadata.get("model") == "gpt-4o-mini"
|
||||
assert session.title == "Parent"
|
||||
assert parent_id in session.children_ids
|
||||
assert any(s.id == snapshot_id for s in session.snapshots)
|
||||
|
||||
def test_create_session(self):
|
||||
"""Test creating a session."""
|
||||
session_id = self.store.create_session(title="Test Session")
|
||||
|
||||
@@ -282,6 +282,101 @@ class TestDefaultSessionStore:
|
||||
session = temp_store.get_session("session-1")
|
||||
assert session.agent_name == "TestBot"
|
||||
assert session.user_id == "user-123"
|
||||
|
||||
def test_update_session_metadata_preserves_messages(self, temp_store):
|
||||
"""Metadata updates must not drop messages added by another store instance."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
assert reader.update_session_metadata(
|
||||
"session-1", model="gpt-4o-mini", total_tokens=42
|
||||
)
|
||||
|
||||
writer.invalidate_cache("session-1")
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
session = writer.get_session("session-1")
|
||||
assert session.metadata.get("model") == "gpt-4o-mini"
|
||||
assert session.metadata.get("total_tokens") == 42
|
||||
|
||||
def test_set_agent_info_preserves_messages(self, temp_store):
|
||||
"""Agent info updates must not drop messages added by another store instance."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
assert reader.set_agent_info("session-1", agent_name="TestBot", user_id="u-1")
|
||||
|
||||
writer.invalidate_cache("session-1")
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
session = writer.get_session("session-1")
|
||||
assert session.agent_name == "TestBot"
|
||||
assert session.user_id == "u-1"
|
||||
|
||||
def test_get_chat_history_sees_writes_from_other_store(self, temp_store):
|
||||
"""Reads must reload from disk, not a stale in-memory cache."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
history = reader.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
def test_clear_session_preserves_new_messages(self, temp_store):
|
||||
"""Clear must reload from disk so concurrent adds are not lost."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
assert reader.clear_session("session-1")
|
||||
|
||||
writer.invalidate_cache("session-1")
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 0
|
||||
|
||||
def test_set_gateway_info_preserves_messages(self, temp_store):
|
||||
"""Gateway info updates must not drop messages added by another store instance."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = DefaultSessionStore(session_dir=tmpdir)
|
||||
reader = DefaultSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
assert reader.set_gateway_info("session-1", gateway_session_id="gw-123", agent_id="agent-456")
|
||||
|
||||
writer.invalidate_cache("session-1")
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
session = writer.get_session("session-1")
|
||||
assert session.gateway_session_id == "gw-123"
|
||||
assert session.agent_id == "agent-456"
|
||||
|
||||
def test_concurrent_writes(self, temp_store):
|
||||
"""Test concurrent writes to same session."""
|
||||
@@ -313,7 +408,7 @@ class TestDefaultSessionStore:
|
||||
assert history == []
|
||||
|
||||
def test_invalidate_cache(self, temp_store):
|
||||
"""Test cache invalidation."""
|
||||
"""Test that reads always see latest disk state."""
|
||||
temp_store.add_user_message("session-1", "Hello")
|
||||
|
||||
# Modify file directly
|
||||
@@ -324,11 +419,11 @@ class TestDefaultSessionStore:
|
||||
with open(filepath, "w") as f:
|
||||
json.dump(data, f)
|
||||
|
||||
# Without invalidation, cache returns old data
|
||||
# Reads always reload from disk (no stale cache)
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 1 # Cached
|
||||
assert len(history) == 2 # Fresh from disk
|
||||
|
||||
# After invalidation, new data is loaded
|
||||
# invalidate_cache still works but is now essentially a no-op for reads
|
||||
temp_store.invalidate_cache("session-1")
|
||||
history = temp_store.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
|
||||
@@ -0,0 +1,311 @@
|
||||
"""Tests for skill capability validation."""
|
||||
|
||||
import pytest
|
||||
import os
|
||||
from pathlib import Path
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from praisonaiagents.skills.models import SkillProperties, SkillRequirements, SkillState
|
||||
from praisonaiagents.skills.capability_validator import (
|
||||
CapabilityValidator,
|
||||
EnforcementLevel,
|
||||
ValidationResult
|
||||
)
|
||||
|
||||
|
||||
class TestSkillRequirements:
|
||||
"""Test SkillRequirements parsing and normalization."""
|
||||
|
||||
def test_from_frontmatter_empty(self):
|
||||
"""Test parsing empty frontmatter."""
|
||||
requirements = SkillRequirements.from_frontmatter({})
|
||||
assert requirements.is_empty()
|
||||
assert not requirements # Test __bool__
|
||||
|
||||
def test_from_frontmatter_tools_list(self):
|
||||
"""Test parsing tools from list."""
|
||||
metadata = {"requires_tools": ["web_search", "file_write"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.tools == ["web_search", "file_write"]
|
||||
assert requirements.servers == []
|
||||
assert not requirements.is_empty()
|
||||
|
||||
def test_from_frontmatter_tools_string(self):
|
||||
"""Test parsing tools from string."""
|
||||
metadata = {"requires-tools": "web_search file_write"}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.tools == ["web_search", "file_write"]
|
||||
|
||||
def test_from_frontmatter_backward_compatibility(self):
|
||||
"""Test backward compatibility with allowed-tools."""
|
||||
metadata = {"allowed-tools": ["existing_tool"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.tools == ["existing_tool"]
|
||||
|
||||
def test_from_frontmatter_servers(self):
|
||||
"""Test parsing server requirements."""
|
||||
metadata = {"requires_servers": ["mcp:filesystem", "http:internal"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.servers == ["mcp:filesystem", "http:internal"]
|
||||
|
||||
def test_from_frontmatter_env_vars(self):
|
||||
"""Test parsing environment variable requirements."""
|
||||
metadata = {"requires_env": ["API_KEY", "SECRET_TOKEN"]}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.env_vars == ["API_KEY", "SECRET_TOKEN"]
|
||||
|
||||
def test_from_frontmatter_openclaw_hints(self):
|
||||
"""Test parsing OpenClaw hints."""
|
||||
metadata = {"openclaw": {"version": "1.0", "compatibility": "hermes"}}
|
||||
requirements = SkillRequirements.from_frontmatter(metadata)
|
||||
assert requirements.openclaw_hints == {"version": "1.0", "compatibility": "hermes"}
|
||||
|
||||
def test_normalize_list(self):
|
||||
"""Test list normalization utility."""
|
||||
assert SkillRequirements._normalize_list("a b c") == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list("a,b,c") == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list("a, b c") == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list(["a", "b", "c"]) == ["a", "b", "c"]
|
||||
assert SkillRequirements._normalize_list("") == []
|
||||
assert SkillRequirements._normalize_list([]) == []
|
||||
assert SkillRequirements._normalize_list(123) == []
|
||||
|
||||
|
||||
class TestCapabilityValidator:
|
||||
"""Test capability validation logic."""
|
||||
|
||||
def test_enforcement_levels(self):
|
||||
"""Test different enforcement levels."""
|
||||
assert EnforcementLevel.DISABLED.value == "disabled"
|
||||
assert EnforcementLevel.WARN.value == "warn"
|
||||
assert EnforcementLevel.STRICT.value == "strict"
|
||||
|
||||
def test_validator_initialization(self):
|
||||
"""Test validator initialization."""
|
||||
validator = CapabilityValidator()
|
||||
assert validator.enforcement_level == EnforcementLevel.WARN
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.STRICT)
|
||||
assert validator.enforcement_level == EnforcementLevel.STRICT
|
||||
|
||||
def test_validate_skill_no_requirements(self):
|
||||
"""Test validation of skill with no requirements."""
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=None
|
||||
)
|
||||
|
||||
validator = CapabilityValidator()
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.skill_name == "test-skill"
|
||||
assert result.state == SkillState.ACTIVE
|
||||
assert result.is_fully_satisfied
|
||||
assert not result.has_critical_missing
|
||||
|
||||
def test_validate_skill_empty_requirements(self):
|
||||
"""Test validation of skill with empty requirements."""
|
||||
requirements = SkillRequirements()
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator()
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.state == SkillState.ACTIVE
|
||||
assert result.is_fully_satisfied
|
||||
|
||||
@patch.dict(os.environ, {"TEST_VAR": "value"})
|
||||
def test_validate_skill_satisfied_requirements(self):
|
||||
"""Test validation with all requirements satisfied."""
|
||||
requirements = SkillRequirements(
|
||||
tools=["available_tool"],
|
||||
servers=["available_server"],
|
||||
env_vars=["TEST_VAR"]
|
||||
)
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
available_tools = {"available_tool"}
|
||||
available_servers = {"available_server"}
|
||||
|
||||
validator = CapabilityValidator()
|
||||
result = validator.validate_skill(
|
||||
skill,
|
||||
available_tools=available_tools,
|
||||
available_servers=available_servers
|
||||
)
|
||||
|
||||
assert result.state == SkillState.ACTIVE
|
||||
assert result.satisfied_tools == ["available_tool"]
|
||||
assert result.satisfied_servers == ["available_server"]
|
||||
assert result.satisfied_env_vars == ["TEST_VAR"]
|
||||
assert result.is_fully_satisfied
|
||||
|
||||
def test_validate_skill_missing_tools_warn_mode(self):
|
||||
"""Test validation with missing tools in warn mode."""
|
||||
requirements = SkillRequirements(tools=["missing_tool"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.WARN)
|
||||
result = validator.validate_skill(
|
||||
skill,
|
||||
available_tools=set(),
|
||||
available_servers=set()
|
||||
)
|
||||
|
||||
assert result.state == SkillState.DEGRADED
|
||||
assert result.missing_tools == ["missing_tool"]
|
||||
assert not result.is_fully_satisfied
|
||||
assert result.has_critical_missing
|
||||
assert len(result.warnings) == 1
|
||||
assert "missing_tool" in result.warnings[0]
|
||||
|
||||
def test_validate_skill_missing_tools_strict_mode(self):
|
||||
"""Test validation with missing tools in strict mode."""
|
||||
requirements = SkillRequirements(tools=["missing_tool"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.STRICT)
|
||||
result = validator.validate_skill(
|
||||
skill,
|
||||
available_tools=set(),
|
||||
available_servers=set()
|
||||
)
|
||||
|
||||
assert result.state == SkillState.UNAVAILABLE
|
||||
assert result.missing_tools == ["missing_tool"]
|
||||
assert not result.is_fully_satisfied
|
||||
assert result.has_critical_missing
|
||||
assert len(result.errors) == 1
|
||||
assert "missing_tool" in result.errors[0]
|
||||
|
||||
def test_validate_skill_missing_env_vars_warn(self):
|
||||
"""Test validation with missing environment variables in warn mode."""
|
||||
requirements = SkillRequirements(env_vars=["MISSING_VAR"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.WARN)
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.state == SkillState.DEGRADED
|
||||
assert result.missing_env_vars == ["MISSING_VAR"]
|
||||
assert len(result.warnings) == 1
|
||||
assert len(result.errors) == 0
|
||||
|
||||
def test_validate_skill_missing_env_vars_strict(self):
|
||||
"""Test validation with missing environment variables in strict mode."""
|
||||
requirements = SkillRequirements(env_vars=["MISSING_VAR"])
|
||||
skill = SkillProperties(
|
||||
name="test-skill",
|
||||
description="Test skill",
|
||||
requirements=requirements
|
||||
)
|
||||
|
||||
validator = CapabilityValidator(EnforcementLevel.STRICT)
|
||||
result = validator.validate_skill(skill)
|
||||
|
||||
assert result.state == SkillState.UNAVAILABLE
|
||||
assert result.missing_env_vars == ["MISSING_VAR"]
|
||||
assert len(result.warnings) == 0
|
||||
assert len(result.errors) == 1
|
||||
|
||||
def test_validation_result_to_dict(self):
|
||||
"""Test ValidationResult serialization."""
|
||||
result = ValidationResult(
|
||||
skill_name="test-skill",
|
||||
state=SkillState.DEGRADED,
|
||||
satisfied_tools=["tool1"],
|
||||
missing_tools=["tool2"],
|
||||
satisfied_servers=[],
|
||||
missing_servers=[],
|
||||
satisfied_env_vars=[],
|
||||
missing_env_vars=["VAR1"],
|
||||
warnings=["Missing tool2"],
|
||||
errors=[]
|
||||
)
|
||||
|
||||
data = result.to_dict()
|
||||
assert data["skill_name"] == "test-skill"
|
||||
assert data["state"] == "degraded"
|
||||
assert data["satisfied_tools"] == ["tool1"]
|
||||
assert data["missing_tools"] == ["tool2"]
|
||||
assert data["warnings"] == ["Missing tool2"]
|
||||
assert data["is_fully_satisfied"] == False
|
||||
assert data["has_critical_missing"] == True
|
||||
|
||||
|
||||
class TestIntegration:
|
||||
"""Integration tests for the full capability validation system."""
|
||||
|
||||
def test_skill_parsing_with_requirements(self):
|
||||
"""Test that skills parse requirements correctly."""
|
||||
from praisonaiagents.skills.parser import read_properties
|
||||
from tempfile import TemporaryDirectory
|
||||
|
||||
with TemporaryDirectory() as temp_dir:
|
||||
skill_dir = Path(temp_dir) / "test-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
skill_md.write_text('''---
|
||||
name: test-skill
|
||||
description: A test skill
|
||||
requires_tools:
|
||||
- web_search
|
||||
- file_write
|
||||
requires_servers:
|
||||
- mcp:filesystem
|
||||
requires_env:
|
||||
- API_KEY
|
||||
openclaw:
|
||||
version: "1.0"
|
||||
---
|
||||
|
||||
# Test Skill
|
||||
|
||||
This is a test skill.
|
||||
''')
|
||||
|
||||
properties = read_properties(skill_dir)
|
||||
assert properties.requirements is not None
|
||||
assert properties.requirements.tools == ["web_search", "file_write"]
|
||||
assert properties.requirements.servers == ["mcp:filesystem"]
|
||||
assert properties.requirements.env_vars == ["API_KEY"]
|
||||
assert properties.requirements.openclaw_hints == {"version": "1.0"}
|
||||
|
||||
def test_skill_manager_enforcement(self):
|
||||
"""Test that SkillManager respects enforcement levels."""
|
||||
from praisonaiagents.skills.manager import SkillManager
|
||||
|
||||
# Test that manager initializes with default enforcement
|
||||
manager = SkillManager()
|
||||
assert manager._validator.enforcement_level in [
|
||||
EnforcementLevel.WARN,
|
||||
EnforcementLevel.DISABLED,
|
||||
EnforcementLevel.STRICT,
|
||||
EnforcementLevel.TELEMETRY
|
||||
]
|
||||
|
||||
# Test explicit enforcement level
|
||||
strict_manager = SkillManager(EnforcementLevel.STRICT)
|
||||
assert strict_manager._validator.enforcement_level == EnforcementLevel.STRICT
|
||||
@@ -241,3 +241,89 @@ Body.
|
||||
loader.activate(skill)
|
||||
|
||||
assert skill.is_activated is True
|
||||
|
||||
def test_load_utf8_content(self):
|
||||
"""Test loading UTF-8 encoded skill content.
|
||||
|
||||
Regression test for Windows encoding issues (Issue #1695).
|
||||
Tests UTF-8 handling in SKILL.md body, scripts, and references.
|
||||
"""
|
||||
from praisonaiagents.skills.loader import SkillLoader
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "utf8-loader-test"
|
||||
skill_dir.mkdir()
|
||||
|
||||
# Create SKILL.md with UTF-8 characters
|
||||
skill_content = """---
|
||||
name: utf8-loader-test
|
||||
description: Loader test with UTF-8 — em dashes — and more
|
||||
---
|
||||
|
||||
# UTF-8 Instructions
|
||||
|
||||
This skill handles UTF-8 content:
|
||||
- Smart quotes: "hello" and 'world'
|
||||
- Em dash: —
|
||||
- Arrows: → ← ↑ ↓
|
||||
- Accented: café, naïve, résumé
|
||||
- Emoji: 🔥 📝 ✨
|
||||
"""
|
||||
|
||||
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
|
||||
f.write(skill_content)
|
||||
|
||||
# Create scripts directory with UTF-8 script
|
||||
scripts_dir = skill_dir / "scripts"
|
||||
scripts_dir.mkdir()
|
||||
|
||||
script_content = """#!/bin/bash
|
||||
# Script with UTF-8: café processing → résumé generation
|
||||
echo "Processing naïve input with émphasis"
|
||||
"""
|
||||
|
||||
with open(scripts_dir / "process.sh", 'w', encoding='utf-8') as f:
|
||||
f.write(script_content)
|
||||
|
||||
# Create references directory with UTF-8 content
|
||||
refs_dir = skill_dir / "references"
|
||||
refs_dir.mkdir()
|
||||
|
||||
ref_content = """Reference with UTF-8:
|
||||
Café automation guide — step by step
|
||||
Arrow symbols: → ← ↑ ↓
|
||||
Special chars: "smart quotes" and émphasis
|
||||
"""
|
||||
|
||||
with open(refs_dir / "readme.txt", 'w', encoding='utf-8') as f:
|
||||
f.write(ref_content)
|
||||
|
||||
# Test loading and activation
|
||||
loader = SkillLoader()
|
||||
skill = loader.load_metadata(str(skill_dir))
|
||||
|
||||
assert skill is not None
|
||||
assert "—" in skill.properties.description # UTF-8 in metadata
|
||||
|
||||
# Test activation (loads SKILL.md body)
|
||||
success = loader.activate(skill)
|
||||
assert success is True
|
||||
assert "—" in skill.instructions # UTF-8 in instructions
|
||||
assert "café" in skill.instructions
|
||||
assert "🔥" in skill.instructions # Emoji
|
||||
|
||||
# Test script loading
|
||||
scripts = loader.load_scripts(skill)
|
||||
assert "process.sh" in scripts
|
||||
script_text = scripts["process.sh"]
|
||||
assert "café" in script_text
|
||||
assert "résumé" in script_text
|
||||
assert "naïve" in script_text
|
||||
|
||||
# Test references loading
|
||||
refs = loader.load_references(skill)
|
||||
assert "readme.txt" in refs
|
||||
ref_text = refs["readme.txt"]
|
||||
assert "Café" in ref_text
|
||||
assert "—" in ref_text
|
||||
assert "émphasis" in ref_text
|
||||
@@ -286,3 +286,54 @@ Body.
|
||||
assert False, "Should have raised ValidationError"
|
||||
except ValidationError as e:
|
||||
assert "description" in str(e).lower()
|
||||
|
||||
def test_read_properties_utf8_encoding(self):
|
||||
"""Test reading UTF-8 encoded SKILL.md files.
|
||||
|
||||
Regression test for Windows encoding issues (Issue #1695).
|
||||
Verifies that skills with non-ASCII characters load correctly.
|
||||
"""
|
||||
from praisonaiagents.skills.parser import read_properties
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "utf8-skill"
|
||||
skill_dir.mkdir()
|
||||
|
||||
# Create SKILL.md with various UTF-8 characters
|
||||
utf8_content = """---
|
||||
name: utf8-test
|
||||
description: UTF-8 encoding test — quality — verification
|
||||
license: MIT
|
||||
compatibility: Supports café files → résumé processing
|
||||
metadata:
|
||||
author: "José María"
|
||||
notes: "Handles naïve input with émphasis"
|
||||
---
|
||||
|
||||
# UTF-8 Test Skill
|
||||
|
||||
This skill demonstrates UTF-8 character handling:
|
||||
- Em dash: —
|
||||
- Smart quotes: "hello" and 'world'
|
||||
- Arrow: →
|
||||
- Accented chars: café, naïve, résumé
|
||||
- Emoji: 🔥 📝 ✨
|
||||
|
||||
Works with internationalization!
|
||||
"""
|
||||
|
||||
# Explicitly write as UTF-8 to simulate real-world scenario
|
||||
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
|
||||
f.write(utf8_content)
|
||||
|
||||
# Test that parsing works without UnicodeDecodeError
|
||||
props = read_properties(skill_dir)
|
||||
|
||||
# Verify UTF-8 characters are preserved correctly
|
||||
assert props.name == "utf8-test"
|
||||
assert "—" in props.description # em dash
|
||||
assert "café" in props.compatibility # accented character
|
||||
assert "résumé" in props.compatibility # accented character
|
||||
assert "José María" in props.metadata["author"] # accented characters
|
||||
assert "naïve" in props.metadata["notes"] # accented character
|
||||
assert "émphasis" in props.metadata["notes"] # accented character
|
||||
@@ -295,3 +295,58 @@ description: A test skill for validation
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("directory" in e.lower() for e in errors)
|
||||
|
||||
def test_validate_utf8_skill(self):
|
||||
"""Test validation of UTF-8 encoded skill.
|
||||
|
||||
Regression test for Windows encoding issues (Issue #1695).
|
||||
Ensures validator can handle UTF-8 characters without errors.
|
||||
"""
|
||||
from praisonaiagents.skills.validator import validate
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "utf8-validation-test"
|
||||
skill_dir.mkdir()
|
||||
|
||||
# Create SKILL.md with UTF-8 content
|
||||
utf8_content = """---
|
||||
name: utf8-validation-test
|
||||
description: Validation test with UTF-8 — em dashes — and accents
|
||||
license: MIT
|
||||
compatibility: Works with café files → résumé output
|
||||
---
|
||||
|
||||
# UTF-8 Validation Test
|
||||
|
||||
Testing validation with various UTF-8 characters.
|
||||
"""
|
||||
|
||||
# Write as UTF-8 explicitly
|
||||
with open(skill_dir / "SKILL.md", 'w', encoding='utf-8') as f:
|
||||
f.write(utf8_content)
|
||||
|
||||
# Validate should not produce errors for UTF-8 content
|
||||
errors = validate(skill_dir)
|
||||
|
||||
assert errors == [], f"Validation should pass for UTF-8 content, got errors: {errors}"
|
||||
|
||||
def test_validate_non_utf8_skill_returns_error(self):
|
||||
"""Test validation returns a structured error for non-UTF-8 SKILL.md."""
|
||||
from praisonaiagents.skills.validator import validate
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
skill_dir = Path(tmpdir) / "latin1-validation-test"
|
||||
skill_dir.mkdir()
|
||||
|
||||
latin1_content = """---
|
||||
name: latin1-validation-test
|
||||
description: Café skill
|
||||
---
|
||||
"""
|
||||
with open(skill_dir / "SKILL.md", "wb") as f:
|
||||
f.write(latin1_content.encode("cp1252"))
|
||||
|
||||
errors = validate(skill_dir)
|
||||
|
||||
assert len(errors) > 0
|
||||
assert any("utf-8" in e.lower() for e in errors)
|
||||
@@ -0,0 +1,293 @@
|
||||
"""
|
||||
Unit tests for kanban protocols and hook events.
|
||||
|
||||
Tests the protocol contracts and hook events without any heavy
|
||||
implementations like SQLite or LLM calls.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
import json
|
||||
from typing import runtime_checkable
|
||||
|
||||
from praisonaiagents.kanban.protocols import (
|
||||
KanbanStoreProtocol,
|
||||
KanbanTaskProtocol,
|
||||
KanbanCommentingProtocol,
|
||||
KanbanLinkingProtocol,
|
||||
VALID_KANBAN_STATUSES,
|
||||
)
|
||||
from praisonaiagents.hooks.types import (
|
||||
HookEvent,
|
||||
KanbanHookInput,
|
||||
)
|
||||
|
||||
|
||||
class TestKanbanProtocol:
|
||||
"""Test KanbanStoreProtocol runtime checking and constants."""
|
||||
|
||||
def test_kanban_store_protocol_is_runtime_checkable(self):
|
||||
"""Test that KanbanStoreProtocol is @runtime_checkable."""
|
||||
assert runtime_checkable(KanbanStoreProtocol)
|
||||
|
||||
def test_valid_kanban_statuses_includes_ui_columns(self):
|
||||
"""Test that VALID_KANBAN_STATUSES includes all PraisonAIUI columns."""
|
||||
expected_statuses = {
|
||||
"triage",
|
||||
"todo",
|
||||
"ready",
|
||||
"running",
|
||||
"blocked",
|
||||
"review",
|
||||
"done",
|
||||
"archived"
|
||||
}
|
||||
assert VALID_KANBAN_STATUSES == expected_statuses
|
||||
|
||||
def test_valid_kanban_statuses_is_frozen(self):
|
||||
"""Test that VALID_KANBAN_STATUSES is immutable."""
|
||||
assert isinstance(VALID_KANBAN_STATUSES, frozenset)
|
||||
|
||||
def test_mock_store_passes_isinstance_check(self):
|
||||
"""Test that a mock store implementing the core protocol passes isinstance check."""
|
||||
|
||||
class MockKanbanStore:
|
||||
"""Mock implementation for testing - only core methods required."""
|
||||
|
||||
def get_board(self, *, board="default", tenant=None, include_archived=False):
|
||||
return {"board": board, "tasks": []}
|
||||
|
||||
def get_task(self, task_id: str):
|
||||
return {"id": task_id, "title": "Test Task"}
|
||||
|
||||
def create_task(self, data: dict):
|
||||
return {"id": "task_123", **data}
|
||||
|
||||
def update_task(self, task_id: str, data: dict):
|
||||
return {"id": task_id, **data}
|
||||
|
||||
def move_task(self, task_id: str, status: str):
|
||||
return {"id": task_id, "status": status}
|
||||
|
||||
def bulk_update(self, task_ids: list[str], status: str):
|
||||
return {"updated": len(task_ids), "status": status}
|
||||
|
||||
def delete_task(self, task_id: str):
|
||||
return True
|
||||
|
||||
def list_events(self, since: float = 0.0, board: str = "default"):
|
||||
return []
|
||||
|
||||
def health(self):
|
||||
return {"status": "healthy"}
|
||||
|
||||
mock_store = MockKanbanStore()
|
||||
assert isinstance(mock_store, KanbanStoreProtocol)
|
||||
|
||||
def test_extension_protocol_checks(self):
|
||||
"""Test that extension protocols work independently."""
|
||||
|
||||
class CommentingStore:
|
||||
"""Store that implements commenting functionality."""
|
||||
def add_comment(self, task_id: str, text: str, author: str | None = None):
|
||||
return {"id": "comment_1", "text": text, "author": author}
|
||||
|
||||
class LinkingStore:
|
||||
"""Store that implements linking functionality."""
|
||||
def link_tasks(self, parent_id: str, child_id: str):
|
||||
return True
|
||||
def unlink_tasks(self, parent_id: str, child_id: str):
|
||||
return True
|
||||
|
||||
class FullFeaturedStore:
|
||||
"""Store that implements all protocols."""
|
||||
# Core protocol
|
||||
def get_board(self, *, board="default", tenant=None, include_archived=False):
|
||||
return {"board": board, "tasks": []}
|
||||
def get_task(self, task_id: str):
|
||||
return {"id": task_id}
|
||||
def create_task(self, data: dict):
|
||||
return {"id": "task_123", **data}
|
||||
def update_task(self, task_id: str, data: dict):
|
||||
return {"id": task_id, **data}
|
||||
def move_task(self, task_id: str, status: str):
|
||||
return {"id": task_id, "status": status}
|
||||
def bulk_update(self, task_ids: list[str], status: str):
|
||||
return {"updated": len(task_ids)}
|
||||
def delete_task(self, task_id: str):
|
||||
return True
|
||||
def list_events(self, since: float = 0.0, board: str = "default"):
|
||||
return []
|
||||
def health(self):
|
||||
return {"status": "healthy"}
|
||||
# Extension protocols
|
||||
def add_comment(self, task_id: str, text: str, author: str | None = None):
|
||||
return {"id": "comment_1", "text": text}
|
||||
def link_tasks(self, parent_id: str, child_id: str):
|
||||
return True
|
||||
def unlink_tasks(self, parent_id: str, child_id: str):
|
||||
return True
|
||||
|
||||
commenting_store = CommentingStore()
|
||||
linking_store = LinkingStore()
|
||||
full_store = FullFeaturedStore()
|
||||
|
||||
# Test individual extension protocols
|
||||
assert isinstance(commenting_store, KanbanCommentingProtocol)
|
||||
assert isinstance(linking_store, KanbanLinkingProtocol)
|
||||
|
||||
# Test that extension stores don't implement core protocol
|
||||
assert not isinstance(commenting_store, KanbanStoreProtocol)
|
||||
assert not isinstance(linking_store, KanbanStoreProtocol)
|
||||
|
||||
# Test full featured store implements all protocols
|
||||
assert isinstance(full_store, KanbanStoreProtocol)
|
||||
assert isinstance(full_store, KanbanCommentingProtocol)
|
||||
assert isinstance(full_store, KanbanLinkingProtocol)
|
||||
|
||||
def test_kanban_task_protocol_shape(self):
|
||||
"""Test KanbanTaskProtocol typed dict structure."""
|
||||
# This tests that the TypedDict can be used for type hints
|
||||
# The shape is validated at runtime by mypy/type checkers
|
||||
task: KanbanTaskProtocol = {
|
||||
"id": "task_123",
|
||||
"title": "Test Task",
|
||||
"body": "Task description",
|
||||
"status": "todo",
|
||||
"assignee": "[email protected]",
|
||||
"priority": "high",
|
||||
"tenant": "test_tenant",
|
||||
"board": "default",
|
||||
"created_at": 1640995200.0,
|
||||
"updated_at": 1640995200.0,
|
||||
}
|
||||
|
||||
# Basic validation that we can access the fields
|
||||
assert task["id"] == "task_123"
|
||||
assert task["title"] == "Test Task"
|
||||
assert task["status"] == "todo"
|
||||
|
||||
|
||||
class TestKanbanHookEvents:
|
||||
"""Test kanban hook events and payload types."""
|
||||
|
||||
def test_kanban_hook_events_in_enum(self):
|
||||
"""Test that all kanban hook events are registered in HookEvent."""
|
||||
expected_events = [
|
||||
HookEvent.KANBAN_TASK_CREATED,
|
||||
HookEvent.KANBAN_TASK_CLAIMED,
|
||||
HookEvent.KANBAN_TASK_MOVED,
|
||||
HookEvent.KANBAN_TASK_DONE,
|
||||
HookEvent.KANBAN_TASK_BLOCKED,
|
||||
HookEvent.KANBAN_TASK_FAILED,
|
||||
]
|
||||
|
||||
for event in expected_events:
|
||||
assert event in HookEvent
|
||||
|
||||
def test_kanban_hook_events_serializable(self):
|
||||
"""Test that kanban hook events can be serialized."""
|
||||
events = [
|
||||
HookEvent.KANBAN_TASK_CREATED,
|
||||
HookEvent.KANBAN_TASK_CLAIMED,
|
||||
HookEvent.KANBAN_TASK_MOVED,
|
||||
HookEvent.KANBAN_TASK_DONE,
|
||||
HookEvent.KANBAN_TASK_BLOCKED,
|
||||
HookEvent.KANBAN_TASK_FAILED,
|
||||
]
|
||||
|
||||
for event in events:
|
||||
# Should be able to serialize to JSON and reconstruct enum value
|
||||
serialized = json.dumps({"event": event.value})
|
||||
restored = HookEvent(json.loads(serialized)["event"])
|
||||
assert restored == event
|
||||
|
||||
def test_kanban_hook_input_creation(self):
|
||||
"""Test KanbanHookInput dataclass creation and fields."""
|
||||
hook_input = KanbanHookInput(
|
||||
session_id="session_123",
|
||||
cwd="/home/user",
|
||||
event_name="kanban_task_created",
|
||||
timestamp="2023-01-01T00:00:00Z",
|
||||
task_id="task_456",
|
||||
board="project_board",
|
||||
status="todo",
|
||||
assignee="[email protected]",
|
||||
from_status=None,
|
||||
to_status="running"
|
||||
)
|
||||
|
||||
assert hook_input.session_id == "session_123"
|
||||
assert hook_input.task_id == "task_456"
|
||||
assert hook_input.board == "project_board"
|
||||
assert hook_input.status == "todo"
|
||||
assert hook_input.assignee == "[email protected]"
|
||||
assert hook_input.from_status is None
|
||||
assert hook_input.to_status == "running"
|
||||
|
||||
def test_kanban_hook_input_defaults(self):
|
||||
"""Test KanbanHookInput with default values."""
|
||||
hook_input = KanbanHookInput(
|
||||
session_id="session_123",
|
||||
cwd="/home/user",
|
||||
event_name="kanban_task_created",
|
||||
timestamp="2023-01-01T00:00:00Z"
|
||||
)
|
||||
|
||||
# Check defaults
|
||||
assert hook_input.task_id == ""
|
||||
assert hook_input.board == "default"
|
||||
assert hook_input.status == ""
|
||||
assert hook_input.assignee is None
|
||||
assert hook_input.from_status is None
|
||||
assert hook_input.to_status is None
|
||||
|
||||
def test_kanban_hook_input_to_dict(self):
|
||||
"""Test KanbanHookInput serialization to dict."""
|
||||
hook_input = KanbanHookInput(
|
||||
session_id="session_123",
|
||||
cwd="/home/user",
|
||||
event_name="kanban_task_moved",
|
||||
timestamp="2023-01-01T00:00:00Z",
|
||||
task_id="task_456",
|
||||
board="project_board",
|
||||
from_status="todo",
|
||||
to_status="running"
|
||||
)
|
||||
|
||||
result = hook_input.to_dict()
|
||||
|
||||
assert result["session_id"] == "session_123"
|
||||
assert result["event_name"] == "kanban_task_moved"
|
||||
assert result["task_id"] == "task_456"
|
||||
assert result["board"] == "project_board"
|
||||
assert result["from_status"] == "todo"
|
||||
assert result["to_status"] == "running"
|
||||
|
||||
|
||||
class TestProtocolIntegration:
|
||||
"""Integration tests for protocol usage patterns."""
|
||||
|
||||
def test_protocol_import_structure(self):
|
||||
"""Test that protocols can be imported from expected paths."""
|
||||
# Test direct imports
|
||||
from praisonaiagents.kanban.protocols import KanbanStoreProtocol
|
||||
from praisonaiagents.hooks.types import KanbanHookInput
|
||||
|
||||
assert KanbanStoreProtocol is not None
|
||||
assert KanbanHookInput is not None
|
||||
|
||||
def test_lazy_import_from_package_root(self):
|
||||
"""Test that kanban protocols can be imported from package root."""
|
||||
from praisonaiagents.kanban import (
|
||||
KanbanStoreProtocol,
|
||||
KanbanTaskProtocol,
|
||||
KanbanCommentingProtocol,
|
||||
KanbanLinkingProtocol,
|
||||
VALID_KANBAN_STATUSES,
|
||||
)
|
||||
|
||||
assert KanbanStoreProtocol is not None
|
||||
assert KanbanTaskProtocol is not None
|
||||
assert KanbanCommentingProtocol is not None
|
||||
assert KanbanLinkingProtocol is not None
|
||||
assert VALID_KANBAN_STATUSES is not None
|
||||
Generated
+1
-1
@@ -3125,7 +3125,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.45"
|
||||
version = "1.6.48"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-21",
|
||||
"lastUpdated": "2026-05-30",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 334,
|
||||
"pythonCoreFeatures": 335,
|
||||
"rustFeatures": 667,
|
||||
"gapCount": 55,
|
||||
"parityPercentage": 199.7
|
||||
"gapCount": 56,
|
||||
"parityPercentage": 199.1
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"exports": [
|
||||
"A2A",
|
||||
"A2UI",
|
||||
"AGUI",
|
||||
"AUTONOMY_PRESETS",
|
||||
"Agent",
|
||||
@@ -1104,6 +1105,7 @@
|
||||
"WebSearchProvider"
|
||||
],
|
||||
"config_loader": [
|
||||
"AutoRagConfig",
|
||||
"SessionConfig"
|
||||
],
|
||||
"context": [
|
||||
@@ -1739,9 +1741,6 @@
|
||||
"sandbox": [],
|
||||
"session": [],
|
||||
"skills": [],
|
||||
"specialized": [
|
||||
"AutoRagConfig"
|
||||
],
|
||||
"specialized_agents": [],
|
||||
"streaming": [],
|
||||
"task": [],
|
||||
@@ -2821,6 +2820,15 @@
|
||||
"status": "DONE",
|
||||
"category": "ui"
|
||||
},
|
||||
{
|
||||
"feature": "A2UI",
|
||||
"python": true,
|
||||
"rust": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "ui"
|
||||
},
|
||||
{
|
||||
"feature": "AGUI",
|
||||
"python": true,
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
# Rust Feature Parity Tracker
|
||||
|
||||
> **Python Features:** 334 | **Rust Features:** 667 | **Parity:** 85.3%
|
||||
> **Python Features:** 335 | **Rust Features:** 667 | **Parity:** 85.1%
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 334 |
|
||||
| Python Core Features | 335 |
|
||||
| Rust Features | 667 |
|
||||
| **Actual Gap Count** | **49** |
|
||||
| **Actual Gap Count** | **50** |
|
||||
| Language Limitations (N/A) | 5 |
|
||||
| **Parity** | **85.3%** |
|
||||
| **Parity** | **85.1%** |
|
||||
|
||||
## Implemented Features
|
||||
|
||||
@@ -694,6 +694,7 @@ These Python features cannot be directly implemented in Rust due to reserved key
|
||||
|
||||
## Missing Features
|
||||
|
||||
- ❌ `A2UI`
|
||||
- ❌ `AgentMessageEvent`
|
||||
- ❌ `AsyncLearnProtocol`
|
||||
- ❌ `AutoApproveBackend`
|
||||
|
||||
@@ -1,22 +1,23 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-21",
|
||||
"lastUpdated": "2026-05-30",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
"pythonCoreFeatures": 334,
|
||||
"pythonCoreFeatures": 335,
|
||||
"pythonWrapperFeatures": 99,
|
||||
"typescriptFeatures": 1192,
|
||||
"gapCount": 42,
|
||||
"gapCount": 43,
|
||||
"priorityP0": 1,
|
||||
"priorityP1": 0,
|
||||
"priorityP2": 0,
|
||||
"priorityP3": 41
|
||||
"priorityP3": 42
|
||||
},
|
||||
"pythonCoreSDK": {
|
||||
"path": "/home/runner/work/PraisonAI/PraisonAI/src/praisonai-agents/praisonaiagents",
|
||||
"exports": [
|
||||
"A2A",
|
||||
"A2UI",
|
||||
"AGUI",
|
||||
"AUTONOMY_PRESETS",
|
||||
"Agent",
|
||||
@@ -727,6 +728,7 @@
|
||||
],
|
||||
"ui": [
|
||||
"A2A",
|
||||
"A2UI",
|
||||
"AGUI"
|
||||
],
|
||||
"workflows": [
|
||||
@@ -3911,6 +3913,15 @@
|
||||
"status": "DONE",
|
||||
"category": "ui"
|
||||
},
|
||||
{
|
||||
"feature": "A2UI",
|
||||
"python": true,
|
||||
"typescript": false,
|
||||
"priority": "P3",
|
||||
"effort": "low",
|
||||
"status": "TODO",
|
||||
"category": "ui"
|
||||
},
|
||||
{
|
||||
"feature": "AGUI",
|
||||
"python": true,
|
||||
|
||||
@@ -1,20 +1,20 @@
|
||||
# Feature Parity Tracker
|
||||
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-05-21
|
||||
> **Version:** 1.5.87 | **Last Updated:** 2026-05-30
|
||||
> **Source of Truth:** Python SDK (praisonaiagents)
|
||||
|
||||
## Summary
|
||||
|
||||
| Metric | Count |
|
||||
|--------|-------|
|
||||
| Python Core Features | 334 |
|
||||
| Python Core Features | 335 |
|
||||
| Python Wrapper Features | 99 |
|
||||
| TypeScript Features | 1192 |
|
||||
| **Gap Count** | **42** |
|
||||
| **Gap Count** | **43** |
|
||||
| P0 (Critical) | 1 |
|
||||
| P1 (High) | 0 |
|
||||
| P2 (Medium) | 0 |
|
||||
| P3 (Low) | 41 |
|
||||
| P3 (Low) | 42 |
|
||||
|
||||
## Gap Matrix
|
||||
|
||||
@@ -147,10 +147,11 @@
|
||||
| `enable\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
| `get\_telemetry` | ✅ | ✅ | low | ✅ DONE |
|
||||
|
||||
### P3_Advanced (179 done, 41 todo)
|
||||
### P3_Advanced (179 done, 42 todo)
|
||||
|
||||
| Feature | Python | TypeScript | Effort | Status |
|
||||
|---------|--------|------------|--------|--------|
|
||||
| `A2UI` | ✅ | ❌ | low | ⏳ TODO |
|
||||
| `AgentMessageEvent` | ✅ | ❌ | high | ⏳ TODO |
|
||||
| `AsyncLearnProtocol` | ✅ | ❌ | medium | ⏳ TODO |
|
||||
| `AutoApproveBackend` | ✅ | ❌ | high | ⏳ TODO |
|
||||
@@ -629,10 +630,10 @@ from praisonaiagents import ContextEvent, ContextEventType, ContextListSink, Con
|
||||
</details>
|
||||
|
||||
<details>
|
||||
<summary><strong>ui</strong> (2 exports)</summary>
|
||||
<summary><strong>ui</strong> (3 exports)</summary>
|
||||
|
||||
```python
|
||||
from praisonaiagents import A2A, AGUI
|
||||
from praisonaiagents import A2A, A2UI, AGUI
|
||||
```
|
||||
|
||||
</details>
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -3,8 +3,8 @@ class Praisonai < Formula
|
||||
|
||||
desc "AI tools for various AI applications"
|
||||
homepage "https://github.com/MervinPraison/PraisonAI"
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.45.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.45.tar.gz | shasum -a 256`.split.first
|
||||
url "https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.48.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.48.tar.gz | shasum -a 256`.split.first
|
||||
license "MIT"
|
||||
|
||||
depends_on "[email protected]"
|
||||
|
||||
@@ -32,6 +32,8 @@ __all__ = [
|
||||
'HostedAgentConfig',
|
||||
'LocalAgent',
|
||||
'LocalAgentConfig',
|
||||
# Integration functions
|
||||
'run_integrated_gateway',
|
||||
]
|
||||
|
||||
# Telemetry initialization state - thread-safe (threading imported above)
|
||||
@@ -144,6 +146,9 @@ def __getattr__(name):
|
||||
elif name == 'configure_host':
|
||||
from .integration.host_app import configure_host
|
||||
return configure_host
|
||||
elif name == 'run_integrated_gateway':
|
||||
from .integration.gateway_host import run_integrated_gateway
|
||||
return run_integrated_gateway
|
||||
elif name == 'AgentApp':
|
||||
# Silent alias for AgentOS (backward compatibility)
|
||||
from .app import AgentOS
|
||||
|
||||
@@ -8,7 +8,8 @@ on every call (which is expensive and breaks multi-agent workflows).
|
||||
import asyncio
|
||||
import os
|
||||
import threading
|
||||
from concurrent.futures import Future
|
||||
import concurrent.futures
|
||||
from concurrent.futures import CancelledError as FutureCancelledError, Future
|
||||
from typing import Awaitable, TypeVar
|
||||
|
||||
T = TypeVar("T")
|
||||
@@ -112,9 +113,30 @@ def run_sync(coro: Awaitable[T], *, timeout: float | None = _DEFAULT_TIMEOUT) ->
|
||||
with _BG._lock:
|
||||
loop = _BG.get_unlocked()
|
||||
fut: Future = asyncio.run_coroutine_threadsafe(coro, loop)
|
||||
return fut.result(timeout=timeout)
|
||||
|
||||
try:
|
||||
return fut.result(timeout=timeout)
|
||||
except (TimeoutError, concurrent.futures.TimeoutError):
|
||||
# Propagate cancellation into the background loop so the underlying
|
||||
# awaitable (DB query, HTTP call, subprocess wait) actually unwinds.
|
||||
fut.cancel()
|
||||
try:
|
||||
# Give cancellation a short grace period to release resources.
|
||||
fut.exception(timeout=1.0)
|
||||
except (
|
||||
TimeoutError,
|
||||
concurrent.futures.TimeoutError,
|
||||
asyncio.CancelledError,
|
||||
FutureCancelledError,
|
||||
):
|
||||
pass
|
||||
raise
|
||||
except BaseException:
|
||||
# Ctrl-C / GeneratorExit / SystemExit must also cancel the bg task.
|
||||
fut.cancel()
|
||||
raise
|
||||
|
||||
|
||||
def shutdown() -> None:
|
||||
"""Public hook for long-running server processes to stop the bridge cleanly."""
|
||||
_BG.shutdown()
|
||||
_BG.shutdown()
|
||||
File diff suppressed because it is too large.
Load diff
@@ -7,419 +7,17 @@ scheduler package for full functionality including async support.
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
"praisonai.async_agent_scheduler is pending deprecation; it will be moved to "
|
||||
"praisonai.scheduler.async_agent_scheduler in a future release. Continue "
|
||||
"importing from praisonai.async_agent_scheduler until then.",
|
||||
PendingDeprecationWarning,
|
||||
"praisonai.async_agent_scheduler is deprecated; "
|
||||
"use 'from praisonai.scheduler import AsyncAgentScheduler' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# TODO: Once AsyncAgentScheduler is moved to scheduler package, import from there
|
||||
# For now, re-export the existing implementation to avoid breaking changes
|
||||
from .scheduler.shared import ScheduleParser, backoff_delay, safe_call
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Callable, Union
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncAgentExecutorInterface(ABC):
|
||||
"""Abstract interface for async agent execution."""
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, task: str) -> Any:
|
||||
"""Execute the agent with given task asynchronously."""
|
||||
pass
|
||||
|
||||
|
||||
class AsyncPraisonAgentExecutor(AsyncAgentExecutorInterface):
|
||||
"""Async executor for PraisonAI agents."""
|
||||
|
||||
def __init__(self, agent):
|
||||
"""
|
||||
Initialize executor with a PraisonAI agent.
|
||||
|
||||
Args:
|
||||
agent: PraisonAI Agent instance
|
||||
"""
|
||||
self.agent = agent
|
||||
|
||||
async def execute(self, task: str) -> Any:
|
||||
"""
|
||||
Execute the agent with the given task.
|
||||
|
||||
Args:
|
||||
task: Task description to execute
|
||||
|
||||
Returns:
|
||||
Agent execution result
|
||||
"""
|
||||
try:
|
||||
# Check if agent has async support
|
||||
if hasattr(self.agent, 'astart'):
|
||||
result = await self.agent.astart(task)
|
||||
elif hasattr(self.agent, 'start'):
|
||||
# Wrap sync call in executor
|
||||
result = await asyncio.to_thread(self.agent.start, task)
|
||||
else:
|
||||
raise AttributeError("Agent must have either 'start' or 'astart' method")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Async agent execution failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class AsyncAgentScheduler:
|
||||
"""
|
||||
Async-native scheduler for running PraisonAI agents periodically.
|
||||
|
||||
Features:
|
||||
- Proper async/await execution
|
||||
- Cancellation support
|
||||
- No global state pollution
|
||||
- Native async coordination
|
||||
|
||||
Example:
|
||||
scheduler = AsyncAgentScheduler(agent, task="Check news")
|
||||
await scheduler.start(schedule_expr="hourly")
|
||||
await asyncio.sleep(3600) # Let it run
|
||||
await scheduler.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent,
|
||||
task: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_failure: Optional[Callable] = None
|
||||
):
|
||||
"""
|
||||
Initialize async agent scheduler.
|
||||
|
||||
Args:
|
||||
agent: PraisonAI Agent instance
|
||||
task: Task description to execute
|
||||
config: Optional configuration dict
|
||||
on_success: Callback function on successful execution
|
||||
on_failure: Callback function on failed execution
|
||||
"""
|
||||
self.agent = agent
|
||||
self.task = task
|
||||
self.config = config or {}
|
||||
self.on_success = on_success
|
||||
self.on_failure = on_failure
|
||||
|
||||
self.is_running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._executor = AsyncPraisonAgentExecutor(agent)
|
||||
self._execution_count = 0
|
||||
self._success_count = 0
|
||||
self._failure_count = 0
|
||||
|
||||
# Sync lock for async primitives creation and bound loop tracking
|
||||
self._primitives_lock = threading.Lock()
|
||||
self._stop_event: Optional[asyncio.Event] = None
|
||||
self._cancel_event: Optional[asyncio.Event] = None
|
||||
self._stats_lock: Optional[asyncio.Lock] = None
|
||||
self._bound_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
def _ensure_async_primitives(self) -> None:
|
||||
"""Create async primitives if they don't exist yet.
|
||||
|
||||
Thread-safe and loop-aware: primitives are bound to the current running loop.
|
||||
If called from a different loop, new primitives are created.
|
||||
"""
|
||||
loop = asyncio.get_running_loop() # must be called from a coroutine
|
||||
|
||||
with self._primitives_lock:
|
||||
if self._bound_loop is not loop:
|
||||
self._stop_event = asyncio.Event()
|
||||
self._cancel_event = asyncio.Event()
|
||||
self._stats_lock = asyncio.Lock()
|
||||
self._bound_loop = loop
|
||||
|
||||
|
||||
async def start(
|
||||
self,
|
||||
schedule_expr: str,
|
||||
max_retries: int = 3,
|
||||
run_immediately: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Start scheduled agent execution.
|
||||
|
||||
Args:
|
||||
schedule_expr: Schedule expression (e.g., "hourly", "*/6h", "3600")
|
||||
max_retries: Maximum retry attempts on failure
|
||||
run_immediately: If True, run agent immediately before starting schedule
|
||||
|
||||
Returns:
|
||||
True if scheduler started successfully
|
||||
"""
|
||||
if self.is_running:
|
||||
logger.warning("Async scheduler is already running")
|
||||
return False
|
||||
|
||||
try:
|
||||
interval = ScheduleParser.parse(schedule_expr)
|
||||
self.is_running = True
|
||||
self._ensure_async_primitives() # NEW — bind to the loop start() runs on
|
||||
self._stop_event.clear()
|
||||
|
||||
logger.info(f"Starting async agent scheduler: {getattr(self.agent, 'name', 'Agent')}")
|
||||
logger.info(f"Task: {self.task}")
|
||||
logger.info(f"Schedule: {schedule_expr} ({interval}s interval)")
|
||||
|
||||
# Run immediately if requested
|
||||
if run_immediately:
|
||||
logger.info("Running agent immediately before starting schedule...")
|
||||
await self._execute_with_retry(max_retries)
|
||||
|
||||
# Start background task
|
||||
self._task = asyncio.create_task(
|
||||
self._run_schedule(interval, max_retries)
|
||||
)
|
||||
|
||||
logger.info("Async agent scheduler started successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start async scheduler: {e}")
|
||||
self.is_running = False
|
||||
return False
|
||||
|
||||
async def stop(self) -> bool:
|
||||
"""
|
||||
Stop the scheduler gracefully.
|
||||
|
||||
IMPORTANT: This method must be called from the same event loop
|
||||
that was used to start the scheduler.
|
||||
|
||||
Returns:
|
||||
True if stopped successfully
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called from a different event loop than start()
|
||||
"""
|
||||
if not self.is_running:
|
||||
logger.info("Async scheduler is not running")
|
||||
return True
|
||||
|
||||
logger.info("Stopping async agent scheduler...")
|
||||
|
||||
# _stop_event is guaranteed non-None after a successful start()
|
||||
if self._stop_event is None:
|
||||
logger.warning("stop() called before start(); nothing to stop.")
|
||||
self.is_running = False
|
||||
return True
|
||||
|
||||
# Ensure we're on the same loop that was bound during start()
|
||||
current_loop = asyncio.get_running_loop()
|
||||
if self._bound_loop is not None and current_loop is not self._bound_loop:
|
||||
raise RuntimeError(
|
||||
"stop() must be called from the same event loop as start(). "
|
||||
f"Expected: {self._bound_loop}, got: {current_loop}"
|
||||
)
|
||||
|
||||
self._stop_event.set()
|
||||
|
||||
try:
|
||||
if self._task:
|
||||
try:
|
||||
await asyncio.wait_for(self._task, timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Scheduler task didn't stop gracefully, cancelling")
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler task raised on cancel: {e}")
|
||||
except asyncio.CancelledError:
|
||||
# Task already cancelled; treat as expected during shutdown
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler task raised during stop: {e}")
|
||||
finally:
|
||||
self.is_running = False
|
||||
|
||||
logger.info("Async agent scheduler stopped")
|
||||
|
||||
# Log final stats with consistent snapshot
|
||||
if self._stats_lock is not None:
|
||||
async with self._stats_lock:
|
||||
total = self._execution_count
|
||||
ok = self._success_count
|
||||
fail = self._failure_count
|
||||
else:
|
||||
total = self._execution_count
|
||||
ok = self._success_count
|
||||
fail = self._failure_count
|
||||
logger.info(f"Execution stats - Total: {total}, Success: {ok}, Failed: {fail}")
|
||||
return True
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current execution statistics (synchronous, best-effort).
|
||||
|
||||
Warning: This method provides a best-effort view of stats without
|
||||
guaranteeing atomicity. For consistent snapshots in async context,
|
||||
use get_stats_async() instead.
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
# Primitives are created lazily in _ensure_async_primitives(); if not yet
|
||||
# initialized there are no concurrent writers, so a direct read is safe.
|
||||
if self._stats_lock is not None:
|
||||
async with self._stats_lock:
|
||||
total = self._execution_count
|
||||
ok = self._success_count
|
||||
fail = self._failure_count
|
||||
running = self.is_running
|
||||
else:
|
||||
total = self._execution_count
|
||||
ok = self._success_count
|
||||
fail = self._failure_count
|
||||
running = self.is_running
|
||||
|
||||
return {
|
||||
"is_running": running,
|
||||
"total_executions": total,
|
||||
"successful_executions": ok,
|
||||
"failed_executions": fail,
|
||||
"success_rate": (ok / total * 100) if total > 0 else 0
|
||||
}
|
||||
|
||||
async def get_stats_async(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current execution statistics with atomic snapshot (async).
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
if self._stats_lock is None:
|
||||
# Not yet started: stats are all zero, no lock needed
|
||||
execs, success, failed = 0, 0, 0
|
||||
else:
|
||||
# Take atomic snapshot of all counters
|
||||
async with self._stats_lock:
|
||||
execs = self._execution_count
|
||||
success = self._success_count
|
||||
failed = self._failure_count
|
||||
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"total_executions": execs,
|
||||
"successful_executions": success,
|
||||
"failed_executions": failed,
|
||||
"success_rate": (success / execs * 100) if execs > 0 else 0
|
||||
}
|
||||
|
||||
def get_stats_sync(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Alias for get_stats() for clarity.
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
return self.get_stats()
|
||||
|
||||
async def _run_schedule(self, interval: int, max_retries: int):
|
||||
"""Internal method to run scheduled agent executions."""
|
||||
try:
|
||||
self._ensure_async_primitives()
|
||||
while not self._stop_event.is_set():
|
||||
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Starting async scheduled agent execution")
|
||||
|
||||
await self._execute_with_retry(max_retries)
|
||||
|
||||
# Wait for next scheduled time or stop event
|
||||
logger.info(f"Next execution in {interval} seconds ({interval/3600:.1f} hours)")
|
||||
try:
|
||||
await asyncio.wait_for(self._stop_event.wait(), timeout=interval)
|
||||
break # Stop event was set
|
||||
except asyncio.TimeoutError:
|
||||
continue # Timeout reached, continue with next execution
|
||||
finally:
|
||||
self.is_running = False
|
||||
|
||||
async def _execute_with_retry(self, max_retries: int):
|
||||
"""Execute agent with retry logic."""
|
||||
self._ensure_async_primitives() # guarantees _stats_lock is bound to current loop
|
||||
|
||||
async with self._stats_lock:
|
||||
self._execution_count += 1
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logger.info(f"Async attempt {attempt + 1}/{max_retries}")
|
||||
result = await self._executor.execute(self.task)
|
||||
|
||||
logger.info(f"Async agent execution successful on attempt {attempt + 1}")
|
||||
logger.info(f"Result: {result}")
|
||||
|
||||
async with self._stats_lock:
|
||||
self._success_count += 1
|
||||
safe_call(self.on_success, result)
|
||||
return
|
||||
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
logger.error(f"Async agent execution failed on attempt {attempt + 1}: {e}")
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = backoff_delay(attempt)
|
||||
logger.info(f"Waiting {wait_time}s before async retry...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
async with self._stats_lock:
|
||||
self._failure_count += 1
|
||||
logger.error(f"Async agent execution failed after {max_retries} attempts")
|
||||
safe_call(
|
||||
self.on_failure,
|
||||
last_exc if last_exc is not None
|
||||
else RuntimeError(f"Failed after {max_retries} attempts")
|
||||
)
|
||||
|
||||
async def execute_once(self) -> Any:
|
||||
"""
|
||||
Execute agent immediately (one-time execution).
|
||||
|
||||
Returns:
|
||||
Agent execution result
|
||||
"""
|
||||
logger.info("Executing agent once (async)")
|
||||
try:
|
||||
result = await self._executor.execute(self.task)
|
||||
logger.info(f"One-time async execution successful: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"One-time async execution failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
def create_async_agent_scheduler(
|
||||
agent,
|
||||
task: str,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> AsyncAgentScheduler:
|
||||
"""
|
||||
Factory function to create async agent scheduler.
|
||||
|
||||
Args:
|
||||
agent: PraisonAI Agent instance
|
||||
task: Task description
|
||||
config: Optional configuration
|
||||
|
||||
Returns:
|
||||
Configured AsyncAgentScheduler instance
|
||||
"""
|
||||
return AsyncAgentScheduler(agent, task, config)
|
||||
# Re-export from the canonical location
|
||||
from .scheduler.async_agent_scheduler import ( # noqa: F401
|
||||
AsyncAgentScheduler,
|
||||
AsyncPraisonAgentExecutor,
|
||||
AsyncAgentExecutorInterface,
|
||||
create_async_agent_scheduler,
|
||||
logger,
|
||||
)
|
||||
@@ -14,6 +14,7 @@ from pydantic import BaseModel
|
||||
from typing import Any, Dict, List, Optional, Type, TypeVar
|
||||
import os
|
||||
import json
|
||||
import asyncio
|
||||
import yaml
|
||||
import threading
|
||||
from rich import print
|
||||
@@ -423,12 +424,13 @@ class BaseAutoGenerator:
|
||||
}
|
||||
]
|
||||
self._openai_client = None # lazy, per-instance
|
||||
self._openai_client_lock = threading.Lock()
|
||||
self._async_openai_client = None # lazy, per-instance async client
|
||||
self._client_lock = threading.Lock()
|
||||
|
||||
def _get_openai_client(self):
|
||||
"""Get or create the OpenAI client for this instance."""
|
||||
if self._openai_client is None:
|
||||
with self._openai_client_lock:
|
||||
with self._client_lock:
|
||||
if self._openai_client is None:
|
||||
try:
|
||||
from openai import OpenAI
|
||||
@@ -442,18 +444,43 @@ class BaseAutoGenerator:
|
||||
return self._openai_client
|
||||
|
||||
def close(self):
|
||||
"""Close the OpenAI client if it exists."""
|
||||
if not hasattr(self, '_openai_client_lock'):
|
||||
"""Close the sync OpenAI client if it exists."""
|
||||
if not hasattr(self, '_client_lock'):
|
||||
return # Object was never fully initialized
|
||||
with self._openai_client_lock:
|
||||
with self._client_lock:
|
||||
client = getattr(self, '_openai_client', None)
|
||||
self._openai_client = None
|
||||
if client is not None:
|
||||
client.close()
|
||||
|
||||
def __del__(self):
|
||||
"""Best-effort cleanup, but the canonical path is explicit close()."""
|
||||
|
||||
async def aclose(self):
|
||||
"""Close both sync and async OpenAI clients if they exist."""
|
||||
if not hasattr(self, '_client_lock'):
|
||||
return # Object was never fully initialized
|
||||
with self._client_lock:
|
||||
sync_client = getattr(self, '_openai_client', None)
|
||||
self._openai_client = None
|
||||
async_client = getattr(self, '_async_openai_client', None)
|
||||
self._async_openai_client = None
|
||||
if sync_client is not None:
|
||||
await asyncio.to_thread(sync_client.close)
|
||||
if async_client is not None:
|
||||
await async_client.close()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
self.close()
|
||||
return False
|
||||
|
||||
async def __aenter__(self):
|
||||
return self
|
||||
|
||||
async def __aexit__(self, exc_type, exc, tb):
|
||||
await self.aclose()
|
||||
return False
|
||||
|
||||
|
||||
def _structured_completion(self, response_model: Type[T], messages: List[Dict], **kwargs) -> T:
|
||||
"""
|
||||
@@ -505,6 +532,68 @@ class BaseAutoGenerator:
|
||||
"Install with: pip install litellm OR pip install openai"
|
||||
)
|
||||
|
||||
async def _astructured_completion(self, response_model: Type[T], messages: List[Dict], **kwargs) -> T:
|
||||
"""
|
||||
Make an async structured LLM completion with provider fallback.
|
||||
|
||||
Priority:
|
||||
1. LiteLLM async (if available) - supports 100+ LLM providers
|
||||
2. OpenAI AsyncSDK (fallback) - uses beta.chat.completions.parse
|
||||
|
||||
Args:
|
||||
response_model: Pydantic model class for structured output
|
||||
messages: List of message dicts for the LLM
|
||||
**kwargs: Additional arguments passed to the LLM
|
||||
|
||||
Returns:
|
||||
Instance of response_model with parsed response
|
||||
|
||||
Raises:
|
||||
ImportError: If neither litellm nor openai is installed
|
||||
"""
|
||||
model_name = self.config_list[0]['model']
|
||||
|
||||
# Try LiteLLM async first (preferred - supports 100+ providers)
|
||||
if _check_litellm_available():
|
||||
litellm = _get_litellm()
|
||||
response = await litellm.acompletion(
|
||||
model=model_name,
|
||||
messages=messages,
|
||||
response_format=response_model,
|
||||
**kwargs
|
||||
)
|
||||
content = response.choices[0].message.content
|
||||
return response_model.model_validate_json(content)
|
||||
|
||||
# Fallback to OpenAI AsyncSDK (uses beta.chat.completions.parse)
|
||||
if _check_openai_available():
|
||||
if self._async_openai_client is None:
|
||||
with self._client_lock:
|
||||
if self._async_openai_client is None:
|
||||
try:
|
||||
from openai import AsyncOpenAI
|
||||
except ImportError as e:
|
||||
raise ImportError("Install with: pip install openai") from e
|
||||
cfg = self.config_list[0]
|
||||
self._async_openai_client = AsyncOpenAI(
|
||||
api_key=cfg.get("api_key") or os.environ.get("OPENAI_API_KEY"),
|
||||
base_url=cfg.get("base_url"),
|
||||
)
|
||||
|
||||
response = await self._async_openai_client.beta.chat.completions.parse(
|
||||
model=model_name,
|
||||
messages=messages,
|
||||
response_format=response_model,
|
||||
**kwargs
|
||||
)
|
||||
return response.choices[0].message.parsed
|
||||
|
||||
# Neither available - raise helpful error
|
||||
raise ImportError(
|
||||
"Structured output requires either litellm or openai. "
|
||||
"Install with: pip install litellm OR pip install openai"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def get_available_tools() -> List[str]:
|
||||
"""Return list of available tools for agent assignment."""
|
||||
@@ -708,6 +797,36 @@ class AutoGenerator(BaseAutoGenerator):
|
||||
self.convert_and_save(json_data, merge=merge)
|
||||
full_path = os.path.abspath(self.agent_file)
|
||||
return full_path
|
||||
|
||||
async def agenerate(self, merge=False):
|
||||
"""
|
||||
Async version of generate() - generates a team structure for the specified topic.
|
||||
|
||||
Args:
|
||||
merge (bool): Whether to merge with existing agents.yaml file instead of overwriting.
|
||||
|
||||
Returns:
|
||||
str: The full path of the YAML file containing the generated team structure.
|
||||
|
||||
Raises:
|
||||
Exception: If the generation process fails.
|
||||
|
||||
Usage:
|
||||
async with AutoGenerator(framework="crewai", topic="Create a movie script about Cat in Mars") as gen:
|
||||
path = await gen.agenerate()
|
||||
print(path)
|
||||
"""
|
||||
response = await self._astructured_completion(
|
||||
response_model=TeamStructure,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant designed to output complex team structures."},
|
||||
{"role": "user", "content": self.get_user_content()}
|
||||
]
|
||||
)
|
||||
json_data = json.loads(response.model_dump_json())
|
||||
self.convert_and_save(json_data, merge=merge)
|
||||
full_path = os.path.abspath(self.agent_file)
|
||||
return full_path
|
||||
|
||||
def convert_and_save(self, json_data, merge=False):
|
||||
"""Converts the provided JSON data into the desired YAML format and saves it to a file.
|
||||
@@ -1121,6 +1240,32 @@ Respond with:
|
||||
return self._save_workflow(self.merge_with_existing_workflow(json_data), pattern)
|
||||
return self._save_workflow(json_data, pattern)
|
||||
|
||||
async def agenerate(self, pattern: str = "sequential", merge: bool = False) -> str:
|
||||
"""
|
||||
Async version of generate() - Generate a workflow YAML file.
|
||||
|
||||
Args:
|
||||
pattern: Workflow pattern - "sequential", "routing", "parallel", "loop",
|
||||
"orchestrator-workers", "evaluator-optimizer"
|
||||
merge: If True, merge with existing workflow file instead of overwriting
|
||||
|
||||
Returns:
|
||||
Path to the generated workflow file
|
||||
"""
|
||||
response = await self._astructured_completion(
|
||||
response_model=WorkflowStructure,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant that designs workflow structures."},
|
||||
{"role": "user", "content": self._get_prompt(pattern)}
|
||||
]
|
||||
)
|
||||
|
||||
json_data = json.loads(response.model_dump_json())
|
||||
|
||||
if merge and os.path.exists(self.workflow_file):
|
||||
return self._save_workflow(self.merge_with_existing_workflow(json_data), pattern)
|
||||
return self._save_workflow(json_data, pattern)
|
||||
|
||||
def merge_with_existing_workflow(self, new_data: Dict) -> Dict:
|
||||
"""
|
||||
Merge new workflow data with existing workflow file.
|
||||
@@ -1421,6 +1566,29 @@ class JobWorkflowAutoGenerator(BaseAutoGenerator):
|
||||
|
||||
return self._save_workflow(response)
|
||||
|
||||
async def agenerate(self, include_judge: bool = True, include_approve: bool = False) -> str:
|
||||
"""
|
||||
Async version of generate() - Generate a job workflow YAML file.
|
||||
|
||||
Args:
|
||||
include_judge: Include a judge step for quality gating
|
||||
include_approve: Include an approve step for human approval
|
||||
|
||||
Returns:
|
||||
Path to the generated workflow file
|
||||
"""
|
||||
prompt = self._get_prompt(include_judge, include_approve)
|
||||
|
||||
response = await self._astructured_completion(
|
||||
response_model=JobWorkflowStructure,
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant that designs job workflow structures with AI agent steps."},
|
||||
{"role": "user", "content": prompt}
|
||||
]
|
||||
)
|
||||
|
||||
return self._save_workflow(response)
|
||||
|
||||
def _get_prompt(self, include_judge: bool, include_approve: bool) -> str:
|
||||
"""Generate the prompt for job workflow generation."""
|
||||
tools_list = ", ".join(self.get_available_tools())
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""
|
||||
Telegram typing indicator renewal utility.
|
||||
|
||||
Provides a background task to periodically renew typing indicators
|
||||
during long-running operations like agent.chat() calls.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
from typing import Any, Callable, Coroutine, Optional, TypeVar
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
class TypingIndicator:
|
||||
"""Manages typing indicator renewal during long operations."""
|
||||
|
||||
def __init__(self, interval: float = 4.0):
|
||||
"""
|
||||
Initialize typing indicator manager.
|
||||
|
||||
Args:
|
||||
interval: Seconds between typing renewals (default 4.0 for Telegram)
|
||||
"""
|
||||
self.interval = interval
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._cancelled = False
|
||||
|
||||
async def start(self, typing_func: Callable[[], Any]) -> None:
|
||||
"""
|
||||
Start typing indicator renewal task.
|
||||
|
||||
Args:
|
||||
typing_func: Async function that sends typing action
|
||||
"""
|
||||
if self._task and not self._task.done():
|
||||
# Already running
|
||||
return
|
||||
|
||||
self._cancelled = False
|
||||
self._task = asyncio.create_task(self._typing_loop(typing_func))
|
||||
|
||||
def cancel(self) -> None:
|
||||
"""Cancel the typing indicator renewal."""
|
||||
self._cancelled = True
|
||||
if self._task and not self._task.done():
|
||||
self._task.cancel()
|
||||
|
||||
async def _typing_loop(self, typing_func: Callable[[], Any]) -> None:
|
||||
"""
|
||||
Background loop that periodically sends typing action.
|
||||
|
||||
Args:
|
||||
typing_func: Function to call for sending typing indicator
|
||||
"""
|
||||
try:
|
||||
while not self._cancelled:
|
||||
try:
|
||||
# Send typing indicator
|
||||
if asyncio.iscoroutinefunction(typing_func):
|
||||
await typing_func()
|
||||
else:
|
||||
typing_func()
|
||||
|
||||
# Wait for interval or until cancelled
|
||||
await asyncio.sleep(self.interval)
|
||||
|
||||
except asyncio.CancelledError:
|
||||
break
|
||||
except Exception as e:
|
||||
logger.debug(f"Typing indicator send failed: {e}")
|
||||
# Continue trying even if one fails
|
||||
await asyncio.sleep(self.interval)
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
finally:
|
||||
logger.debug("Typing indicator loop ended")
|
||||
|
||||
|
||||
async def with_typing_renewal(
|
||||
typing_func: Callable[[], Any],
|
||||
operation_coro: Coroutine[Any, Any, T],
|
||||
interval: float = 4.0,
|
||||
) -> T:
|
||||
"""
|
||||
Execute an operation with typing indicator renewal.
|
||||
|
||||
Args:
|
||||
typing_func: Function to call for typing indication
|
||||
operation_coro: Coroutine to execute while showing typing
|
||||
interval: Seconds between typing renewals
|
||||
|
||||
Returns:
|
||||
Result of the operation
|
||||
"""
|
||||
indicator = TypingIndicator(interval=interval)
|
||||
|
||||
try:
|
||||
# Start typing renewal
|
||||
await indicator.start(typing_func)
|
||||
|
||||
# Execute the operation
|
||||
result = await operation_coro
|
||||
|
||||
return result
|
||||
|
||||
finally:
|
||||
# Always cancel typing renewal and await cleanup
|
||||
indicator.cancel()
|
||||
if indicator._task is not None:
|
||||
try:
|
||||
await indicator._task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
@@ -33,6 +33,7 @@ from ._debounce import InboundDebouncer
|
||||
from ._ack import AckReactor
|
||||
from ._unknown_user import UnknownUserHandler, BotContext
|
||||
from ._pairing_ui import PairingUIBuilder, PairingCallbackHandler
|
||||
from ..gateway.unicode_utils import safe_error_message, safe_log_message, extract_root_cause_from_error
|
||||
from ..gateway.pairing import PairingStore
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -222,9 +223,6 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
logger.error(f"Message handler error: {e}")
|
||||
|
||||
if self._agent and not message.is_command:
|
||||
if self.config.typing_indicator:
|
||||
await update.message.chat.send_action("typing")
|
||||
|
||||
# Ack reaction
|
||||
ack_ctx = None
|
||||
if self._ack.enabled:
|
||||
@@ -255,11 +253,28 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
) if update.message.from_user else ""
|
||||
try:
|
||||
message_text = await self._debouncer.debounce(user_id, message_text)
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, message_text,
|
||||
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
|
||||
user_name=user_name,
|
||||
)
|
||||
|
||||
# Show typing indicator with renewal during long operation
|
||||
if self.config.typing_indicator:
|
||||
from ._typing_indicator import with_typing_renewal
|
||||
|
||||
async def _typing_action():
|
||||
await update.message.chat.send_action("typing")
|
||||
|
||||
response = await with_typing_renewal(
|
||||
typing_func=_typing_action,
|
||||
operation_coro=self._session.chat(
|
||||
self._agent, user_id, message_text,
|
||||
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
|
||||
user_name=user_name,
|
||||
)
|
||||
)
|
||||
else:
|
||||
response = await self._session.chat(
|
||||
self._agent, user_id, message_text,
|
||||
chat_id=str(update.message.chat_id) if update.message.chat_id else "",
|
||||
user_name=user_name,
|
||||
)
|
||||
send_result = self.fire_message_sending(
|
||||
str(update.message.chat_id), str(response),
|
||||
reply_to=str(update.message.message_id),
|
||||
@@ -278,8 +293,9 @@ class TelegramBot(ChatCommandMixin, MessageHookMixin):
|
||||
if ack_ctx:
|
||||
await self._ack.done(ack_ctx, react_fn=_tg_react, unreact_fn=_tg_unreact)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error: {e}")
|
||||
await update.message.reply_text(f"Error: {str(e)}")
|
||||
logger.error(f"Agent error: {safe_log_message(e)}")
|
||||
user_error = extract_root_cause_from_error(str(e))
|
||||
await update.message.reply_text(f"Error: {safe_error_message(user_error)}")
|
||||
|
||||
async def handle_voice(update: Update, context: ContextTypes.DEFAULT_TYPE):
|
||||
"""Handle voice messages."""
|
||||
|
||||
@@ -77,6 +77,7 @@ configure_host(
|
||||
"debug",
|
||||
],
|
||||
agents=_build_praison_agents(),
|
||||
modules=["jobs"],
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -237,6 +237,23 @@ def doctor_cleanup(
|
||||
raise typer.Exit(0 if removed == len(stale_dirs) else 1)
|
||||
|
||||
|
||||
@app.command("skills")
|
||||
def doctor_skills(
|
||||
deep: bool = typer.Option(False, "--deep", help="Enable deeper probes"),
|
||||
json_output: bool = typer.Option(False, "--json", help="Output JSON"),
|
||||
show_requirements: bool = typer.Option(False, "--requirements", help="Show detailed requirements"),
|
||||
):
|
||||
"""Check skills capabilities and requirements."""
|
||||
args = ["skills"]
|
||||
if deep:
|
||||
args.append("--deep")
|
||||
if json_output:
|
||||
args.append("--json")
|
||||
if show_requirements:
|
||||
args.append("--requirements")
|
||||
raise typer.Exit(_run_doctor(args))
|
||||
|
||||
|
||||
@app.command("troubleshoot")
|
||||
def doctor_troubleshoot():
|
||||
"""
|
||||
|
||||
@@ -17,7 +17,7 @@ app = typer.Typer(
|
||||
@app.command("start")
|
||||
def gateway_start(
|
||||
host: str = typer.Option("127.0.0.1", "--host", help="Host to bind to"),
|
||||
port: int = typer.Option(8765, "--port", help="Port to listen on"),
|
||||
port: Optional[int] = typer.Option(None, "--port", help="Port to listen on"),
|
||||
agents: Optional[str] = typer.Option(None, "--agents", help="Path to agent configuration file"),
|
||||
config: Optional[str] = typer.Option(None, "--config", help="Path to gateway.yaml for multi-bot mode"),
|
||||
):
|
||||
@@ -27,17 +27,54 @@ def gateway_start(
|
||||
praisonai gateway start
|
||||
praisonai gateway start --config gateway.yaml
|
||||
praisonai gateway start --agents agents.yaml --port 9000
|
||||
GATEWAY_PORT=9000 praisonai gateway start
|
||||
"""
|
||||
import os
|
||||
from ..features.gateway import GatewayHandler
|
||||
|
||||
# Check for GATEWAY_PORT environment variable if port not specified
|
||||
if port is None:
|
||||
try:
|
||||
port = int(os.environ.get("GATEWAY_PORT", "8765"))
|
||||
except ValueError:
|
||||
port = 8765
|
||||
|
||||
handler = GatewayHandler()
|
||||
handler.start(host=host, port=port, agent_file=agents, config_file=config)
|
||||
|
||||
|
||||
@app.command("stop")
|
||||
def gateway_stop(
|
||||
host: str = typer.Option("127.0.0.1", "--host", help="Gateway host"),
|
||||
port: Optional[int] = typer.Option(None, "--port", help="Gateway port"),
|
||||
force: bool = typer.Option(False, "--force", help="Force stop (kill process)"),
|
||||
):
|
||||
"""Stop a running gateway instance.
|
||||
|
||||
Examples:
|
||||
praisonai gateway stop
|
||||
praisonai gateway stop --port 9000
|
||||
praisonai gateway stop --force
|
||||
"""
|
||||
import os
|
||||
from ..features.gateway import GatewayHandler
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
# Check for GATEWAY_PORT environment variable if port not specified
|
||||
if port is None:
|
||||
try:
|
||||
port = int(os.environ.get("GATEWAY_PORT", "8765"))
|
||||
except ValueError:
|
||||
port = 8765
|
||||
|
||||
handler = GatewayHandler()
|
||||
handler.stop(host=host, port=port, force=force)
|
||||
|
||||
|
||||
@app.command("status")
|
||||
def gateway_status(
|
||||
host: str = typer.Option("127.0.0.1", "--host", help="Gateway host"),
|
||||
port: int = typer.Option(8765, "--port", help="Gateway port"),
|
||||
port: Optional[int] = typer.Option(None, "--port", help="Gateway port"),
|
||||
daemon_only: bool = typer.Option(False, "--daemon-only", help="Show only daemon status"),
|
||||
):
|
||||
"""Check gateway status and daemon service status.
|
||||
@@ -47,10 +84,18 @@ def gateway_status(
|
||||
praisonai gateway status --port 9000
|
||||
praisonai gateway status --daemon-only
|
||||
"""
|
||||
import os
|
||||
from ..features.gateway import GatewayHandler
|
||||
from praisonai.daemon import get_daemon_status
|
||||
from ..output.console import get_output_controller
|
||||
|
||||
# Check for GATEWAY_PORT environment variable if port not specified
|
||||
if port is None:
|
||||
try:
|
||||
port = int(os.environ.get("GATEWAY_PORT", "8765"))
|
||||
except ValueError:
|
||||
port = 8765
|
||||
|
||||
output = get_output_controller()
|
||||
|
||||
# Show daemon status
|
||||
@@ -394,6 +439,7 @@ Manage the gateway server: praisonai gateway <command>
|
||||
|
||||
[bold]Commands:[/bold]
|
||||
[green]start[/green] Start the gateway server
|
||||
[green]stop[/green] Stop a running gateway instance
|
||||
[green]status[/green] Check gateway and daemon status
|
||||
[green]channels[/green] List channels from gateway.yaml
|
||||
[green]send[/green] Send a test message to a channel
|
||||
|
||||
@@ -113,6 +113,7 @@ Start any PraisonAI server with: praisonai serve <type>
|
||||
[bold]Server Types:[/bold]
|
||||
[green]agents[/green] HTTP REST API for agents (port 8000)
|
||||
[green]gateway[/green] WebSocket multi-agent coordination (port 8765)
|
||||
[green]ui-gateway[/green] Integrated UI-Gateway (Pattern C) (port 8765)
|
||||
[green]mcp[/green] MCP server for Claude/Cursor (port 8080)
|
||||
[green]acp[/green] Agent Client Protocol for IDEs (STDIO)
|
||||
[green]lsp[/green] Language Server Protocol (STDIO)
|
||||
@@ -369,6 +370,42 @@ def serve_ui(
|
||||
raise typer.Exit(4)
|
||||
|
||||
|
||||
@app.command("ui-gateway")
|
||||
def serve_ui_gateway(
|
||||
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
|
||||
port: int = typer.Option(8765, "--port", "-p", help="Port to bind to"),
|
||||
title: str = typer.Option("PraisonAI", "--title", "-t", help="Application title"),
|
||||
style: str = typer.Option("dashboard", "--style", "-s", help="UI style: dashboard, chat"),
|
||||
agents_file: Optional[str] = typer.Option(None, "--agents", "-a", help="Agents YAML file"),
|
||||
):
|
||||
"""Start integrated UI-Gateway (Pattern C) - aiui with backend bridges.
|
||||
|
||||
Combines PraisonAI host integration with AIUIGateway for unified dashboard + chat.
|
||||
|
||||
Examples:
|
||||
praisonai serve ui-gateway
|
||||
praisonai serve ui-gateway --style chat --port 8765
|
||||
praisonai serve ui-gateway --agents agents.yaml --title "My App"
|
||||
"""
|
||||
output = get_output_controller()
|
||||
|
||||
try:
|
||||
from ...integration.gateway_host import run_integrated_gateway
|
||||
run_integrated_gateway(
|
||||
host=host,
|
||||
port=port,
|
||||
title=title,
|
||||
style=style
|
||||
)
|
||||
except ImportError as e:
|
||||
output.print_error(f"UI-Gateway module not available: {e}")
|
||||
output.print("Install with: pip install praisonai[ui]")
|
||||
raise typer.Exit(4)
|
||||
except Exception as e:
|
||||
output.print_error(f"Failed to start UI-Gateway: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@app.command("rag")
|
||||
def serve_rag(
|
||||
host: str = typer.Option("127.0.0.1", "--host", "-h", help="Host to bind to"),
|
||||
|
||||
@@ -110,21 +110,8 @@ def create_agent_centric_tools(
|
||||
if orchestrator is None:
|
||||
orchestrator = ActionOrchestrator(runtime)
|
||||
|
||||
# Helper to run async functions synchronously
|
||||
def run_async(coro):
|
||||
"""Run async coroutine synchronously."""
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
# Create a new loop in a thread
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as executor:
|
||||
future = executor.submit(asyncio.run, coro)
|
||||
return future.result(timeout=60)
|
||||
else:
|
||||
return loop.run_until_complete(coro)
|
||||
except RuntimeError:
|
||||
return asyncio.run(coro)
|
||||
# Helper to run async functions synchronously using the shared bridge
|
||||
from praisonai._async_bridge import run_sync
|
||||
|
||||
# =========================================================================
|
||||
# ACP-Powered File Tools
|
||||
@@ -204,7 +191,7 @@ def create_agent_centric_tools(
|
||||
"verified": result.success
|
||||
})
|
||||
|
||||
return run_async(_create())
|
||||
return run_sync(_create())
|
||||
|
||||
def acp_edit_file(filepath: str, new_content: str) -> str:
|
||||
"""
|
||||
@@ -261,7 +248,7 @@ def create_agent_centric_tools(
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
return run_async(_edit())
|
||||
return run_sync(_edit())
|
||||
|
||||
def acp_delete_file(filepath: str) -> str:
|
||||
"""
|
||||
@@ -313,7 +300,7 @@ def create_agent_centric_tools(
|
||||
"verified": result.success
|
||||
})
|
||||
|
||||
return run_async(_delete())
|
||||
return run_sync(_delete())
|
||||
|
||||
def acp_execute_command(command: str, cwd: str = None) -> str:
|
||||
"""
|
||||
@@ -372,7 +359,7 @@ def create_agent_centric_tools(
|
||||
"error": result.error
|
||||
})
|
||||
|
||||
return run_async(_execute())
|
||||
return run_sync(_execute())
|
||||
|
||||
# =========================================================================
|
||||
# LSP-Powered Code Intelligence Tools
|
||||
@@ -404,7 +391,7 @@ def create_agent_centric_tools(
|
||||
except asyncio.TimeoutError:
|
||||
return json.dumps({"error": "LSP list_symbols timed out (10s)", "success": False})
|
||||
|
||||
return run_async(_list())
|
||||
return run_sync(_list())
|
||||
|
||||
def lsp_find_definition(symbol: str, file_path: str = None) -> str:
|
||||
"""
|
||||
@@ -432,7 +419,7 @@ def create_agent_centric_tools(
|
||||
except asyncio.TimeoutError:
|
||||
return json.dumps({"error": "LSP find_definition timed out (10s)", "success": False})
|
||||
|
||||
return run_async(_find())
|
||||
return run_sync(_find())
|
||||
|
||||
def lsp_find_references(symbol: str, file_path: str = None) -> str:
|
||||
"""
|
||||
@@ -460,7 +447,7 @@ def create_agent_centric_tools(
|
||||
except asyncio.TimeoutError:
|
||||
return json.dumps({"error": "LSP find_references timed out (10s)", "success": False})
|
||||
|
||||
return run_async(_find())
|
||||
return run_sync(_find())
|
||||
|
||||
def lsp_get_diagnostics(file_path: str = None) -> str:
|
||||
"""
|
||||
@@ -487,7 +474,7 @@ def create_agent_centric_tools(
|
||||
except asyncio.TimeoutError:
|
||||
return json.dumps({"error": "LSP diagnostics timed out (10s)", "success": False})
|
||||
|
||||
return run_async(_get())
|
||||
return run_sync(_get())
|
||||
|
||||
# =========================================================================
|
||||
# Basic File Tools (for read-only operations)
|
||||
|
||||
@@ -200,3 +200,141 @@ def check_bot_security(config: DoctorConfig) -> CheckResult:
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="multi_channel_tokens",
|
||||
title="Multi-Channel Token Configuration",
|
||||
description="Check for duplicate tokens across channels and validate multi-channel setup",
|
||||
category=CheckCategory.BOTS,
|
||||
severity=CheckSeverity.MEDIUM,
|
||||
)
|
||||
def check_multi_channel_tokens(config: DoctorConfig) -> CheckResult:
|
||||
"""Check multi-channel token configuration for duplicates and naming conventions."""
|
||||
start = time.time()
|
||||
from praisonai.cli._paths import resolve_bot_config_path
|
||||
config_path = getattr(config, 'config_file', None) or resolve_bot_config_path("bot.yaml")
|
||||
|
||||
# Check if bot.yaml exists
|
||||
if not os.path.exists(config_path):
|
||||
return CheckResult(
|
||||
id="multi_channel_tokens",
|
||||
title="Multi-Channel Token Configuration",
|
||||
category=CheckCategory.BOTS,
|
||||
status=CheckStatus.SKIP,
|
||||
message=f"{config_path} not found - multi-channel check skipped",
|
||||
duration_ms=(time.time() - start) * 1000,
|
||||
)
|
||||
|
||||
try:
|
||||
from praisonai.bots._config_schema import load_and_validate_bot_yaml
|
||||
config_data = load_and_validate_bot_yaml(config_path)
|
||||
|
||||
warnings = []
|
||||
errors = []
|
||||
|
||||
# Extract token environment variables from channels
|
||||
channel_tokens = {} # env_var -> [channel_keys_using_it]
|
||||
channel_platforms = {} # platform -> [channel_keys]
|
||||
|
||||
for channel_name, channel_config in config_data.channels.items():
|
||||
platform = channel_config.platform
|
||||
token_ref = channel_config.token
|
||||
|
||||
# Track platforms
|
||||
if platform not in channel_platforms:
|
||||
channel_platforms[platform] = []
|
||||
channel_platforms[platform].append(channel_name)
|
||||
|
||||
# Extract environment variable from token reference like ${TELEGRAM_BOT_TOKEN}
|
||||
if token_ref and token_ref.startswith("${") and token_ref.endswith("}"):
|
||||
env_var = token_ref[2:-1] # Remove ${ and }
|
||||
if env_var not in channel_tokens:
|
||||
channel_tokens[env_var] = []
|
||||
channel_tokens[env_var].append(channel_name)
|
||||
|
||||
# Check for duplicate token usage by env var reference
|
||||
for env_var, channels in channel_tokens.items():
|
||||
if len(channels) > 1:
|
||||
errors.append(f"Token {env_var} is used by multiple channels: {', '.join(channels)}")
|
||||
|
||||
# Also check duplicate token usage by resolved token value
|
||||
token_value_to_channels = {} # token_value -> [channel_keys]
|
||||
for env_var, channels in channel_tokens.items():
|
||||
token_value = os.environ.get(env_var)
|
||||
if token_value:
|
||||
token_value_to_channels.setdefault(token_value, []).extend(channels)
|
||||
|
||||
for _, channels in token_value_to_channels.items():
|
||||
unique_channels = sorted(set(channels))
|
||||
if len(unique_channels) > 1:
|
||||
errors.append(
|
||||
f"Same bot token value is used by multiple channels: {', '.join(unique_channels)}"
|
||||
)
|
||||
|
||||
# Check for multi-platform channels with good naming conventions
|
||||
for platform, channels in channel_platforms.items():
|
||||
if len(channels) > 1:
|
||||
# Multiple channels on same platform - check naming convention
|
||||
for channel_name in channels:
|
||||
channel_config = config_data.channels[channel_name]
|
||||
token_ref = channel_config.token
|
||||
if token_ref and token_ref.startswith("${") and token_ref.endswith("}"):
|
||||
env_var = token_ref[2:-1]
|
||||
|
||||
# Check if follows naming convention: PLATFORM_ROLE_BOT_TOKEN
|
||||
expected_pattern = f"{platform.upper()}_"
|
||||
if not env_var.startswith(expected_pattern) or not env_var.endswith("_BOT_TOKEN"):
|
||||
warnings.append(f"Channel '{channel_name}' token '{env_var}' doesn't follow naming convention '{platform.upper()}_<ROLE>_BOT_TOKEN'")
|
||||
|
||||
# Check for missing tokens
|
||||
missing_tokens = []
|
||||
for env_var in channel_tokens.keys():
|
||||
if not os.environ.get(env_var):
|
||||
missing_tokens.append(env_var)
|
||||
|
||||
# Determine status
|
||||
if errors:
|
||||
return CheckResult(
|
||||
id="multi_channel_tokens",
|
||||
title="Multi-Channel Token Configuration",
|
||||
category=CheckCategory.BOTS,
|
||||
status=CheckStatus.FAIL,
|
||||
message=f"Token configuration errors: {len(errors)} duplicate(s) found",
|
||||
details='\n'.join(errors + warnings),
|
||||
remediation="Each channel must have a unique bot token. Create separate bots in @BotFather and use unique environment variables.",
|
||||
duration_ms=(time.time() - start) * 1000,
|
||||
)
|
||||
elif warnings or missing_tokens:
|
||||
all_issues = warnings + [f"Missing token: {token}" for token in missing_tokens]
|
||||
return CheckResult(
|
||||
id="multi_channel_tokens",
|
||||
title="Multi-Channel Token Configuration",
|
||||
category=CheckCategory.BOTS,
|
||||
status=CheckStatus.WARN,
|
||||
message=f"Multi-channel setup with {len(all_issues)} recommendation(s)",
|
||||
details='\n'.join(all_issues),
|
||||
remediation="Consider following the naming convention PLATFORM_ROLE_BOT_TOKEN for clarity.",
|
||||
duration_ms=(time.time() - start) * 1000,
|
||||
)
|
||||
else:
|
||||
channel_count = sum(len(channels) for channels in channel_platforms.values())
|
||||
return CheckResult(
|
||||
id="multi_channel_tokens",
|
||||
title="Multi-Channel Token Configuration",
|
||||
category=CheckCategory.BOTS,
|
||||
status=CheckStatus.PASS,
|
||||
message=f"Multi-channel configuration looks good ({channel_count} channels)",
|
||||
duration_ms=(time.time() - start) * 1000,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
return CheckResult(
|
||||
id="multi_channel_tokens",
|
||||
title="Multi-Channel Token Configuration",
|
||||
category=CheckCategory.BOTS,
|
||||
status=CheckStatus.ERROR,
|
||||
message=f"Error checking multi-channel config: {str(e)[:100]}",
|
||||
remediation="Fix bot.yaml syntax errors first",
|
||||
duration_ms=(time.time() - start) * 1000,
|
||||
)
|
||||
|
||||
|
||||
@@ -229,3 +229,99 @@ def check_skills_praisonai_integration(config: DoctorConfig) -> CheckResult:
|
||||
message="Skills module not available",
|
||||
details=str(e),
|
||||
)
|
||||
|
||||
|
||||
@register_check(
|
||||
id="skills_capabilities",
|
||||
title="Skills Capability Requirements",
|
||||
description="Check skill capability requirements and enforcement",
|
||||
category=CheckCategory.SKILLS,
|
||||
severity=CheckSeverity.MEDIUM,
|
||||
dependencies=["skills_praisonai_integration"],
|
||||
)
|
||||
def check_skills_capabilities(config: DoctorConfig) -> CheckResult:
|
||||
"""Check skill capability requirements."""
|
||||
try:
|
||||
from praisonaiagents.skills import SkillManager
|
||||
from praisonaiagents.skills.capability_validator import EnforcementLevel
|
||||
|
||||
# Initialize skill manager
|
||||
manager = SkillManager()
|
||||
|
||||
# Discover skills
|
||||
discovered = manager.discover(include_defaults=True)
|
||||
if discovered == 0:
|
||||
return CheckResult(
|
||||
id="skills_capabilities",
|
||||
title="Skills Capability Requirements",
|
||||
category=CheckCategory.SKILLS,
|
||||
status=CheckStatus.SKIP,
|
||||
message="No skills found to check",
|
||||
)
|
||||
|
||||
# Get diagnostics for all skills
|
||||
diagnostics = manager.get_skills_diagnostics()
|
||||
|
||||
active_count = 0
|
||||
degraded_count = 0
|
||||
unavailable_count = 0
|
||||
issues = []
|
||||
|
||||
for skill_name, result in diagnostics.items():
|
||||
if result.state.value == "active":
|
||||
active_count += 1
|
||||
elif result.state.value == "degraded":
|
||||
degraded_count += 1
|
||||
issues.extend([f"{skill_name}: {w}" for w in result.warnings[:2]])
|
||||
elif result.state.value == "unavailable":
|
||||
unavailable_count += 1
|
||||
issues.extend([f"{skill_name}: {e}" for e in result.errors[:2]])
|
||||
|
||||
total = active_count + degraded_count + unavailable_count
|
||||
|
||||
# Determine status
|
||||
enforcement_level = manager._validator.enforcement_level
|
||||
|
||||
if unavailable_count == 0 and degraded_count == 0:
|
||||
status = CheckStatus.PASS
|
||||
message = f"All {total} skill(s) have requirements satisfied"
|
||||
elif unavailable_count > 0 and enforcement_level == EnforcementLevel.STRICT:
|
||||
status = CheckStatus.FAIL
|
||||
message = f"{unavailable_count}/{total} skills unavailable (strict enforcement)"
|
||||
elif unavailable_count > 0 or degraded_count > 0:
|
||||
status = CheckStatus.WARN
|
||||
message = f"{active_count}/{total} skills fully available, {degraded_count} degraded, {unavailable_count} unavailable"
|
||||
else:
|
||||
status = CheckStatus.PASS
|
||||
message = f"Skills capability check completed"
|
||||
|
||||
details = "; ".join(issues[:5]) + ("..." if len(issues) > 5 else "") if issues else None
|
||||
|
||||
metadata = {
|
||||
"total_skills": total,
|
||||
"active": active_count,
|
||||
"degraded": degraded_count,
|
||||
"unavailable": unavailable_count,
|
||||
"enforcement_level": enforcement_level.value,
|
||||
"sample_issues": issues[:10]
|
||||
}
|
||||
|
||||
return CheckResult(
|
||||
id="skills_capabilities",
|
||||
title="Skills Capability Requirements",
|
||||
category=CheckCategory.SKILLS,
|
||||
status=status,
|
||||
message=message,
|
||||
details=details,
|
||||
metadata=metadata,
|
||||
)
|
||||
|
||||
except ImportError as e:
|
||||
return CheckResult(
|
||||
id="skills_capabilities",
|
||||
title="Skills Capability Requirements",
|
||||
category=CheckCategory.SKILLS,
|
||||
status=CheckStatus.SKIP,
|
||||
message="Skills capability validation not available",
|
||||
details=str(e),
|
||||
)
|
||||
@@ -168,6 +168,82 @@ class GatewayHandler:
|
||||
except Exception as e:
|
||||
print(f"Error loading agents: {e}")
|
||||
|
||||
def stop(self, host: str = "127.0.0.1", port: int = 8765, force: bool = False) -> None:
|
||||
"""Stop a running gateway instance.
|
||||
|
||||
Args:
|
||||
host: Gateway host
|
||||
port: Gateway port
|
||||
force: Force stop (kill process)
|
||||
"""
|
||||
try:
|
||||
from praisonai.gateway.port_utils import GatewayPIDLock
|
||||
except ImportError as e:
|
||||
print(f"Error: Gateway utilities not available. {e}")
|
||||
return
|
||||
|
||||
pid_lock = GatewayPIDLock(host=host, port=port)
|
||||
lock_info = pid_lock.get_lock_info()
|
||||
|
||||
if not lock_info:
|
||||
print(f"No gateway PID lock found. Gateway may not be running on {host}:{port}")
|
||||
return
|
||||
|
||||
pid = lock_info['pid']
|
||||
is_running = lock_info['is_running']
|
||||
|
||||
if not is_running:
|
||||
print(f"Gateway process (PID {pid}) is no longer running. Cleaning up stale lock.")
|
||||
pid_lock.release_lock()
|
||||
return
|
||||
|
||||
if force:
|
||||
self._force_kill_process(pid)
|
||||
else:
|
||||
self._graceful_stop_process(pid)
|
||||
|
||||
# Clean up lock file
|
||||
pid_lock.release_lock()
|
||||
print(f"Gateway stopped (PID {pid})")
|
||||
|
||||
def _graceful_stop_process(self, pid: int) -> None:
|
||||
"""Gracefully stop a process by sending SIGTERM."""
|
||||
import signal
|
||||
import time
|
||||
import os
|
||||
|
||||
try:
|
||||
print(f"Sending stop signal to PID {pid}...")
|
||||
os.kill(pid, signal.SIGTERM)
|
||||
|
||||
# Wait up to 10 seconds for graceful shutdown
|
||||
for _ in range(100):
|
||||
try:
|
||||
os.kill(pid, 0) # Check if process exists
|
||||
time.sleep(0.1)
|
||||
except (OSError, ProcessLookupError):
|
||||
return # Process has stopped
|
||||
|
||||
print(f"Process {pid} did not stop gracefully, forcing...")
|
||||
self._force_kill_process(pid)
|
||||
|
||||
except (OSError, ProcessLookupError):
|
||||
print(f"Process {pid} not found or already stopped")
|
||||
|
||||
def _force_kill_process(self, pid: int) -> None:
|
||||
"""Force kill a process with SIGKILL (Windows: SIGTERM)."""
|
||||
import signal
|
||||
import os
|
||||
import sys
|
||||
|
||||
try:
|
||||
print(f"Force killing PID {pid}...")
|
||||
# Use SIGTERM on Windows since SIGKILL is not available
|
||||
sig = signal.SIGTERM if sys.platform == "win32" else signal.SIGKILL
|
||||
os.kill(pid, sig)
|
||||
except (OSError, ProcessLookupError):
|
||||
print(f"Process {pid} not found or already stopped")
|
||||
|
||||
def status(self, host: str = "127.0.0.1", port: int = 8765) -> None:
|
||||
"""Check gateway status.
|
||||
|
||||
@@ -178,6 +254,35 @@ class GatewayHandler:
|
||||
import urllib.request
|
||||
import json
|
||||
|
||||
# Check PID lock info first
|
||||
try:
|
||||
from praisonai.gateway.port_utils import GatewayPIDLock, is_port_in_use
|
||||
pid_lock = GatewayPIDLock(host=host, port=port)
|
||||
lock_info = pid_lock.get_lock_info()
|
||||
|
||||
if lock_info:
|
||||
pid = lock_info['pid']
|
||||
is_running = lock_info['is_running']
|
||||
lock_host = lock_info['host']
|
||||
lock_port = lock_info['port']
|
||||
|
||||
if is_running:
|
||||
print(f"Gateway PID lock: Process {pid} running ({lock_host}:{lock_port})")
|
||||
else:
|
||||
print(f"Gateway PID lock: Stale lock (process {pid} not running)")
|
||||
else:
|
||||
print("Gateway PID lock: No lock file found")
|
||||
|
||||
# Check if port is in use
|
||||
if is_port_in_use(host, port):
|
||||
print(f"Port {host}:{port}: In use")
|
||||
else:
|
||||
print(f"Port {host}:{port}: Available")
|
||||
|
||||
except ImportError:
|
||||
print("PID lock status: Utilities not available")
|
||||
|
||||
# Try to connect to health endpoint
|
||||
url = f"http://{host}:{port}/health"
|
||||
|
||||
try:
|
||||
|
||||
@@ -193,23 +193,32 @@ def _load_basic_tools() -> Dict[str, Callable]:
|
||||
return tools
|
||||
|
||||
|
||||
# ── Shared runtime singleton ────────────────────────────────────────────────
|
||||
# ACP and LSP tools share ONE InteractiveRuntime to avoid duplicate LSP servers.
|
||||
_shared_runtime = None
|
||||
_shared_agent_tools = None
|
||||
# ── Context-local runtime for multi-agent safety ──────────────────────────
|
||||
# Each agent/task/request gets its own runtime context to avoid config conflicts and race conditions.
|
||||
import contextvars
|
||||
|
||||
_runtime_var: contextvars.ContextVar = contextvars.ContextVar("interactive_runtime", default=None)
|
||||
|
||||
|
||||
def _get_shared_runtime(config: ToolConfig):
|
||||
"""Get or create a shared InteractiveRuntime instance.
|
||||
"""Get or create a context-local InteractiveRuntime instance.
|
||||
|
||||
Each context (agent/task/request) gets its own runtime with the config provided.
|
||||
This prevents race conditions and config capture issues from global singletons.
|
||||
|
||||
First-caller-wins policy: Once a runtime is created for a context with specific
|
||||
config, subsequent calls in the same context will return the cached runtime and
|
||||
ignore any different config parameters.
|
||||
|
||||
Returns (runtime, all_tools) — the runtime and the full list of
|
||||
agent-centric tool functions created from it.
|
||||
"""
|
||||
global _shared_runtime, _shared_agent_tools
|
||||
|
||||
if _shared_runtime is not None and _shared_agent_tools is not None:
|
||||
return _shared_runtime, _shared_agent_tools
|
||||
|
||||
bundle = _runtime_var.get()
|
||||
if bundle is not None:
|
||||
# Return cached runtime (first-caller-wins for this context)
|
||||
return bundle
|
||||
|
||||
# Create new runtime for this context
|
||||
from .interactive_runtime import InteractiveRuntime, RuntimeConfig
|
||||
from .agent_tools import create_agent_centric_tools
|
||||
|
||||
@@ -220,37 +229,41 @@ def _get_shared_runtime(config: ToolConfig):
|
||||
approval_mode=config.approval_mode,
|
||||
)
|
||||
|
||||
_shared_runtime = InteractiveRuntime(runtime_config)
|
||||
_shared_agent_tools = create_agent_centric_tools(_shared_runtime)
|
||||
runtime = InteractiveRuntime(runtime_config)
|
||||
agent_tools = create_agent_centric_tools(runtime)
|
||||
bundle = (runtime, agent_tools)
|
||||
_runtime_var.set(bundle)
|
||||
logger.debug("Created context-local InteractiveRuntime")
|
||||
|
||||
logger.debug("Created shared InteractiveRuntime")
|
||||
return _shared_runtime, _shared_agent_tools
|
||||
return bundle
|
||||
|
||||
|
||||
def cleanup_runtime():
|
||||
"""Stop the shared InteractiveRuntime (LSP server, ACP session).
|
||||
"""Stop the context-local InteractiveRuntime (LSP server, ACP session).
|
||||
|
||||
Call this when the agent finishes to release resources.
|
||||
Safe to call multiple times or when no runtime exists.
|
||||
Safe to call multiple times or when no runtime exists in this context.
|
||||
"""
|
||||
global _shared_runtime, _shared_agent_tools
|
||||
|
||||
if _shared_runtime is not None:
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
pool.submit(asyncio.run, _shared_runtime.stop()).result(timeout=5)
|
||||
else:
|
||||
loop.run_until_complete(_shared_runtime.stop())
|
||||
except Exception as e:
|
||||
logger.debug(f"Runtime cleanup: {e}")
|
||||
bundle = _runtime_var.get()
|
||||
if bundle is None:
|
||||
return
|
||||
|
||||
_shared_runtime = None
|
||||
_shared_agent_tools = None
|
||||
logger.debug("Shared InteractiveRuntime stopped")
|
||||
runtime, _ = bundle
|
||||
|
||||
import asyncio
|
||||
try:
|
||||
loop = asyncio.get_event_loop()
|
||||
if loop.is_running():
|
||||
import concurrent.futures
|
||||
with concurrent.futures.ThreadPoolExecutor() as pool:
|
||||
pool.submit(asyncio.run, runtime.stop()).result(timeout=5)
|
||||
else:
|
||||
loop.run_until_complete(runtime.stop())
|
||||
except Exception as e:
|
||||
logger.debug(f"Runtime cleanup: {e}")
|
||||
|
||||
_runtime_var.set(None)
|
||||
logger.debug("Context-local InteractiveRuntime stopped")
|
||||
|
||||
|
||||
def _load_acp_tools(config: ToolConfig) -> Dict[str, Callable]:
|
||||
|
||||
@@ -18,6 +18,7 @@ import asyncio
|
||||
import getpass
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -206,6 +207,89 @@ def _read_env_value(key: str) -> Optional[str]:
|
||||
return None
|
||||
|
||||
|
||||
def _generate_bot_yaml_multi_channel(channels: Dict[str, Dict], agent_name: str = "assistant", agent_instructions: str = "") -> str:
|
||||
"""Generate bot.yaml content for multiple channels.
|
||||
|
||||
Args:
|
||||
channels: Dict of channel_key -> {platform, role, env_var, token, info}
|
||||
agent_name: Default agent name
|
||||
agent_instructions: Default agent instructions
|
||||
"""
|
||||
# Sanitize instructions for YAML safety
|
||||
instructions = agent_instructions or "You are a helpful AI assistant."
|
||||
instructions = instructions.rstrip('. ')
|
||||
instructions_escaped = instructions.replace('\\', '\\\\').replace('"', '\\"')
|
||||
|
||||
lines = ["# Generated by `praisonai onboard`. Multi-channel configuration.",
|
||||
"# Compatible with both `praisonai bot start` and `praisonai gateway start`.",
|
||||
"# Each channel can route to different agents.",
|
||||
""]
|
||||
|
||||
# Gateway server block
|
||||
lines.append("gateway:")
|
||||
lines.append(' host: "127.0.0.1"')
|
||||
lines.append(" port: 8765")
|
||||
lines.append("")
|
||||
|
||||
# Legacy single-agent block for backward compatibility
|
||||
lines.append("agent:")
|
||||
lines.append(f' name: "{agent_name}"')
|
||||
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
|
||||
lines.append(' model: "gpt-4o-mini"')
|
||||
lines.append("")
|
||||
|
||||
# Multi-agent block with agents for each role
|
||||
lines.append("agents:")
|
||||
agents_added = set()
|
||||
for channel_key, channel in channels.items():
|
||||
role = channel["role"]
|
||||
if role not in agents_added:
|
||||
lines.append(f" {role}:")
|
||||
if role == "assistant":
|
||||
lines.append(f' instructions: "{instructions_escaped}. You can search the web, manage schedules, and remember past conversations. Use your tools proactively."')
|
||||
else:
|
||||
lines.append(f' instructions: "You are a {role} agent. {instructions_escaped} Use your tools proactively to help with {role}-related tasks."')
|
||||
lines.append(' model: gpt-4o-mini')
|
||||
lines.append(" memory: false")
|
||||
lines.append("")
|
||||
agents_added.add(role)
|
||||
|
||||
# Channels section with multiple channels per platform supported
|
||||
lines.append("channels:")
|
||||
for channel_key, channel in channels.items():
|
||||
platform = channel["platform"]
|
||||
role = channel["role"]
|
||||
env_var = channel["env_var"]
|
||||
info = channel["info"]
|
||||
|
||||
lines.append(f" {channel_key}:")
|
||||
lines.append(f" platform: {platform}")
|
||||
lines.append(f" token: ${{{env_var}}}")
|
||||
lines.append(" group_policy: mention_only")
|
||||
|
||||
allowed_env = info.get("allowed_users_env")
|
||||
if allowed_env:
|
||||
lines.append(f" allowed_users: ${{{allowed_env}}}")
|
||||
|
||||
lines.append(" # auto_approve_tools: true")
|
||||
lines.append(" # Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.")
|
||||
|
||||
if platform == "whatsapp":
|
||||
lines.append(" phone_number_id: ${WHATSAPP_PHONE_NUMBER_ID}")
|
||||
|
||||
# Routing to specific agent
|
||||
lines.append(" routes:")
|
||||
lines.append(f" default: {role}")
|
||||
lines.append("")
|
||||
|
||||
# Legacy routing block
|
||||
lines.append("routing:")
|
||||
lines.append(f" default: {agent_name}")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agent_instructions: str = "") -> str:
|
||||
"""Generate bot.yaml content compatible with BOTH ``praisonai bot start``
|
||||
and ``praisonai gateway start``.
|
||||
@@ -265,6 +349,8 @@ def _generate_bot_yaml(platforms: List[str], agent_name: str = "assistant", agen
|
||||
lines.append(f" allowed_users: ${{{allowed_env}}}")
|
||||
lines.append(" # auto_approve_tools: true # Chat bots cannot show CLI approval prompts; safe tools auto-approved by default.")
|
||||
lines.append(" # Set to false only if you add destructive tools AND have a chat-level approval flow.")
|
||||
lines.append(' ack_emoji: "⏳" # Show processing indicator during long agent operations')
|
||||
lines.append(' done_emoji: "✅" # Replace ack_emoji when operation completes')
|
||||
if plat == "whatsapp":
|
||||
lines.append(" phone_number_id: ${WHATSAPP_PHONE_NUMBER_ID}")
|
||||
# Gateway-style routing (ignored by `bot start` which uses top-level routing).
|
||||
@@ -284,7 +370,8 @@ class OnboardWizard:
|
||||
|
||||
def __init__(self):
|
||||
self.selected_platforms: List[str] = []
|
||||
self.tokens: Dict[str, str] = {}
|
||||
self.channels: Dict[str, Dict] = {} # channel_key -> {platform, token, role, env_var}
|
||||
self.tokens: Dict[str, str] = {} # legacy/fallback compatibility
|
||||
self.agent_name: str = "assistant"
|
||||
self.agent_instructions: str = "You are a helpful AI assistant."
|
||||
self.config_path: str = "bot.yaml"
|
||||
@@ -318,169 +405,59 @@ class OnboardWizard:
|
||||
"Existing bot.yaml will be kept (no overwrite).[/dim]"
|
||||
)
|
||||
|
||||
# Step 1: Platform selection
|
||||
console.print("\n[bold]Step 1: Choose your platform(s)[/bold]\n")
|
||||
# Step 1: Platform and channel selection
|
||||
console.print("\n[bold]Step 1: Configure your bot channels[/bold]\n")
|
||||
console.print(
|
||||
"You can create multiple bots on the same platform for different roles.\n"
|
||||
"For example: @company_cfo_bot, @company_ops_bot, @company_content_bot\n"
|
||||
)
|
||||
for key, info in PLATFORMS.items():
|
||||
console.print(f" [cyan]{key}[/cyan] — {info['name']}")
|
||||
|
||||
platforms_input = _prompt_ask(
|
||||
# Start with first platform
|
||||
first_platform = _prompt_ask(
|
||||
Prompt,
|
||||
"\nPlatform(s) [comma-separated]",
|
||||
"\nChoose your first platform",
|
||||
default="telegram",
|
||||
)
|
||||
self.selected_platforms = [p.strip().lower() for p in platforms_input.split(",") if p.strip()]
|
||||
|
||||
# Validate
|
||||
for plat in self.selected_platforms:
|
||||
if plat not in PLATFORMS:
|
||||
console.print(f"[red]Unknown platform: {plat}[/red]")
|
||||
return
|
||||
|
||||
# Step 2: Token configuration (password-hidden; persisted to ~/.praisonai/.env)
|
||||
# Every setting can be updated even if already present — existing values are
|
||||
# shown as masked defaults; press Enter to keep, or type a new value to
|
||||
# overwrite (mirrors hermes-agent setup behaviour).
|
||||
console.print("\n[bold]Step 2: Configure tokens[/bold]\n")
|
||||
console.print(
|
||||
" [dim]Press Enter to keep the existing value, or type a new one to update.[/dim]\n"
|
||||
)
|
||||
env_to_save: Dict[str, Optional[str]] = {}
|
||||
|
||||
def _mask(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
if len(value) <= 6:
|
||||
return "*" * len(value)
|
||||
return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}"
|
||||
|
||||
for plat in self.selected_platforms:
|
||||
info = PLATFORMS[plat]
|
||||
env_var = info["token_env"]
|
||||
existing = os.environ.get(env_var, "")
|
||||
|
||||
if existing:
|
||||
console.print(
|
||||
f" [green]✓[/green] {info['name']}: {env_var} = [cyan]{_mask(existing)}[/cyan]"
|
||||
)
|
||||
console.print(f" [dim]{info['token_help']}[/dim]")
|
||||
new_token = _prompt_ask(
|
||||
Prompt,
|
||||
f" Update {info['name']} token? (Enter = keep current)",
|
||||
password=True,
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
if new_token:
|
||||
self.tokens[plat] = new_token
|
||||
os.environ[env_var] = new_token
|
||||
env_to_save[env_var] = new_token
|
||||
console.print(" [green]✓[/green] Token updated")
|
||||
else:
|
||||
self.tokens[plat] = existing
|
||||
else:
|
||||
console.print(f" [dim]{info['token_help']}[/dim]")
|
||||
token = _prompt_ask(
|
||||
Prompt,
|
||||
f" {info['name']} token ({env_var})",
|
||||
password=True,
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
if token:
|
||||
self.tokens[plat] = token
|
||||
os.environ[env_var] = token
|
||||
env_to_save[env_var] = token
|
||||
console.print(" [green]✓[/green] Token captured")
|
||||
else:
|
||||
console.print(
|
||||
f" [yellow]⚠[/yellow] No token — you'll need to set {env_var} before starting"
|
||||
)
|
||||
|
||||
# Extra env vars (e.g., Slack app token) — also hidden by default
|
||||
for extra_env, extra_desc in info.get("extra_env", {}).items():
|
||||
existing_extra = os.environ.get(extra_env, "")
|
||||
if existing_extra:
|
||||
console.print(
|
||||
f" [green]✓[/green] {extra_env} = [cyan]{_mask(existing_extra)}[/cyan]"
|
||||
)
|
||||
new_extra = _prompt_ask(
|
||||
Prompt,
|
||||
f" Update {extra_desc}? (Enter = keep current)",
|
||||
password=True,
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
if new_extra:
|
||||
os.environ[extra_env] = new_extra
|
||||
env_to_save[extra_env] = new_extra
|
||||
console.print(f" [green]✓[/green] {extra_env} updated")
|
||||
else:
|
||||
extra_val = _prompt_ask(
|
||||
Prompt,
|
||||
f" {extra_desc} ({extra_env})",
|
||||
password=True,
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
if extra_val:
|
||||
os.environ[extra_env] = extra_val
|
||||
env_to_save[extra_env] = extra_val
|
||||
|
||||
# Step 2b: Security — allowlist (mirrors hermes)
|
||||
allowed_env = info.get("allowed_users_env")
|
||||
if allowed_env:
|
||||
existing_allow = os.environ.get(allowed_env, "").strip()
|
||||
if existing_allow:
|
||||
console.print(
|
||||
f" [green]✓[/green] {allowed_env} = [cyan]{existing_allow}[/cyan]"
|
||||
)
|
||||
console.print(
|
||||
f" [dim]{info.get('user_id_help', 'Comma-separated user IDs')}[/dim]"
|
||||
)
|
||||
new_allow = _prompt_ask(
|
||||
Prompt,
|
||||
f" Update allowed users for {info['name']}? (Enter = keep, 'clear' = remove)",
|
||||
default="",
|
||||
show_default=False,
|
||||
).strip()
|
||||
if new_allow.lower() == "clear":
|
||||
os.environ.pop(allowed_env, None)
|
||||
env_to_save[allowed_env] = None
|
||||
console.print(
|
||||
" [yellow]✓ Allowlist cleared — open access restored[/yellow]"
|
||||
)
|
||||
elif new_allow:
|
||||
new_allow = new_allow.replace(" ", "")
|
||||
os.environ[allowed_env] = new_allow
|
||||
env_to_save[allowed_env] = new_allow
|
||||
console.print(" [green]✓[/green] Allowlist updated")
|
||||
else:
|
||||
console.print(
|
||||
f" [bold]🔒 Security — restrict who can use your {info['name']} bot[/bold]"
|
||||
)
|
||||
console.print(
|
||||
f" [dim]{info.get('user_id_help', 'Enter comma-separated user IDs')}[/dim]"
|
||||
)
|
||||
allow_val = _prompt_ask(
|
||||
Prompt,
|
||||
" Allowed user IDs (comma-separated, empty = open access)",
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
allow_val = allow_val.replace(" ", "")
|
||||
if allow_val:
|
||||
os.environ[allowed_env] = allow_val
|
||||
env_to_save[allowed_env] = allow_val
|
||||
console.print(
|
||||
" [green]✓[/green] Allowlist saved — only listed users can talk to the bot"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
" [yellow]⚠ Warning:[/yellow] no allowlist set — anyone who finds your bot can use it."
|
||||
)
|
||||
|
||||
# Home-channel prompt intentionally removed — see PLATFORMS
|
||||
# docstring above. No code path consumes home_channel today.
|
||||
).strip().lower()
|
||||
|
||||
if first_platform not in PLATFORMS:
|
||||
console.print(f"[red]Unknown platform: {first_platform}[/red]")
|
||||
return
|
||||
|
||||
self.selected_platforms = [first_platform]
|
||||
|
||||
# Configure first channel
|
||||
self._configure_channel(console, Prompt, first_platform, is_first=True)
|
||||
|
||||
# Ask for additional channels
|
||||
while True:
|
||||
add_another = _confirm_ask(
|
||||
Confirm,
|
||||
f"\nAdd another bot channel?",
|
||||
default=False,
|
||||
)
|
||||
if not add_another:
|
||||
break
|
||||
|
||||
# Ask for platform
|
||||
platform = _prompt_ask(
|
||||
Prompt,
|
||||
"Platform (telegram, discord, slack, whatsapp)",
|
||||
default=first_platform,
|
||||
).strip().lower()
|
||||
|
||||
if platform not in PLATFORMS:
|
||||
console.print(f"[red]Unknown platform: {platform}[/red]")
|
||||
continue
|
||||
|
||||
if platform not in self.selected_platforms:
|
||||
self.selected_platforms.append(platform)
|
||||
|
||||
self._configure_channel(console, Prompt, platform, is_first=False)
|
||||
|
||||
# Step 2: Configure tokens
|
||||
env_to_save = self._configure_tokens(console, Prompt)
|
||||
|
||||
# Persist everything collected above to ~/.praisonai/.env
|
||||
env_file = _save_env_vars(env_to_save)
|
||||
@@ -489,11 +466,11 @@ class OnboardWizard:
|
||||
|
||||
# Step 3: Test connection
|
||||
console.print("\n[bold]Step 3: Test connection[/bold]\n")
|
||||
for plat in self.selected_platforms:
|
||||
if plat in self.tokens:
|
||||
console.print(f" Testing {PLATFORMS[plat]['name']}...", end=" ")
|
||||
for channel_key, channel in self.channels.items():
|
||||
if channel["token"]:
|
||||
console.print(f" Testing {channel_key}...", end=" ")
|
||||
try:
|
||||
result = asyncio.run(self._probe(plat))
|
||||
result = asyncio.run(self._probe_channel(channel))
|
||||
if result.ok:
|
||||
console.print(f"[green]✓ @{result.bot_username}[/green] ({result.elapsed_ms:.0f}ms)")
|
||||
else:
|
||||
@@ -539,8 +516,8 @@ class OnboardWizard:
|
||||
# start`` with zero flags. Overwrite-confirm still guards existing
|
||||
# configs so power users who hand-edited the file aren't surprised.
|
||||
console.print("\n[bold]Step 5: Generate configuration[/bold]\n")
|
||||
yaml_content = _generate_bot_yaml(
|
||||
self.selected_platforms,
|
||||
yaml_content = _generate_bot_yaml_multi_channel(
|
||||
self.channels,
|
||||
agent_name=self.agent_name,
|
||||
agent_instructions=self.agent_instructions,
|
||||
)
|
||||
@@ -564,13 +541,13 @@ class OnboardWizard:
|
||||
|
||||
# Step 6: Optional daemon install
|
||||
# Guard: refuse to install a daemon that has no tokens — it would loop
|
||||
# in a crash-restart cycle. List the platforms missing tokens so the
|
||||
# in a crash-restart cycle. List the channels missing tokens so the
|
||||
# user can rerun onboard once they have them.
|
||||
daemon_success = False
|
||||
missing_tokens = [
|
||||
PLATFORMS[p]["name"]
|
||||
for p in self.selected_platforms
|
||||
if p not in self.tokens
|
||||
channel_key
|
||||
for channel_key, channel in self.channels.items()
|
||||
if not channel["token"]
|
||||
]
|
||||
if missing_tokens:
|
||||
console.print(
|
||||
@@ -654,8 +631,208 @@ class OnboardWizard:
|
||||
border_style="green",
|
||||
))
|
||||
|
||||
def _configure_channel(self, console, prompt_cls, platform: str, is_first: bool = False) -> None:
|
||||
"""Configure a single channel for a platform."""
|
||||
info = PLATFORMS[platform]
|
||||
|
||||
if is_first:
|
||||
# For first channel, use default channel name
|
||||
role = ""
|
||||
channel_key = platform
|
||||
else:
|
||||
# For additional channels, ask for role
|
||||
role = _prompt_ask(
|
||||
prompt_cls,
|
||||
f"Role for this {info['name']} bot (e.g., 'cfo', 'ops', 'support')",
|
||||
default="",
|
||||
).strip().lower()
|
||||
# Sanitize role: only allow alphanumeric and underscores
|
||||
role = re.sub(r"[^a-z0-9_]+", "_", role).strip("_")
|
||||
|
||||
if role:
|
||||
channel_key = f"{platform}_{role}"
|
||||
# Ensure channel key is unique
|
||||
if channel_key in self.channels:
|
||||
console.print(f"[red]Channel '{channel_key}' already exists. Choose a different role.[/red]")
|
||||
return
|
||||
else:
|
||||
# Count existing channels for this platform
|
||||
existing_count = sum(1 for k in self.channels.keys() if k.startswith(f"{platform}"))
|
||||
channel_key = f"{platform}_{existing_count + 1}"
|
||||
|
||||
# Generate environment variable name
|
||||
if role:
|
||||
env_var = f"{platform.upper()}_{role.upper()}_BOT_TOKEN"
|
||||
else:
|
||||
# Use standard name for single channel
|
||||
env_var = info["token_env"]
|
||||
|
||||
# Store channel configuration
|
||||
self.channels[channel_key] = {
|
||||
"platform": platform,
|
||||
"role": role or "assistant",
|
||||
"env_var": env_var,
|
||||
"token": "",
|
||||
"info": info
|
||||
}
|
||||
|
||||
console.print(f" [green]✓[/green] Configured channel '[cyan]{channel_key}[/cyan]' (env: [cyan]{env_var}[/cyan])")
|
||||
|
||||
def _configure_tokens(self, console, prompt_cls) -> Dict[str, Optional[str]]:
|
||||
"""Configure tokens for all channels."""
|
||||
console.print("\n[bold]Step 2: Configure tokens[/bold]\n")
|
||||
console.print(
|
||||
" [dim]Press Enter to keep the existing value, or type a new one to update.[/dim]\n"
|
||||
)
|
||||
env_to_save: Dict[str, Optional[str]] = {}
|
||||
|
||||
def _mask(value: str) -> str:
|
||||
if not value:
|
||||
return ""
|
||||
if len(value) <= 6:
|
||||
return "*" * len(value)
|
||||
return f"{value[:2]}{'*' * (len(value) - 4)}{value[-2:]}"
|
||||
|
||||
for channel_key, channel in self.channels.items():
|
||||
platform = channel["platform"]
|
||||
role = channel["role"]
|
||||
env_var = channel["env_var"]
|
||||
info = channel["info"]
|
||||
existing = os.environ.get(env_var, "")
|
||||
|
||||
console.print(f"\n[bold]Channel: {channel_key}[/bold]")
|
||||
|
||||
if existing:
|
||||
console.print(
|
||||
f" [green]✓[/green] {info['name']}: {env_var} = [cyan]{_mask(existing)}[/cyan]"
|
||||
)
|
||||
console.print(f" [dim]{info['token_help']}[/dim]")
|
||||
new_token = _prompt_ask(
|
||||
prompt_cls,
|
||||
f" Update token for {channel_key}? (Enter = keep current)",
|
||||
password=True,
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
if new_token:
|
||||
channel["token"] = new_token
|
||||
os.environ[env_var] = new_token
|
||||
env_to_save[env_var] = new_token
|
||||
console.print(" [green]✓[/green] Token updated")
|
||||
else:
|
||||
channel["token"] = existing
|
||||
else:
|
||||
console.print(f" [dim]{info['token_help']}[/dim]")
|
||||
token = _prompt_ask(
|
||||
prompt_cls,
|
||||
f" Token for {channel_key} ({env_var})",
|
||||
password=True,
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
if token:
|
||||
channel["token"] = token
|
||||
os.environ[env_var] = token
|
||||
env_to_save[env_var] = token
|
||||
console.print(" [green]✓[/green] Token captured")
|
||||
else:
|
||||
console.print(
|
||||
f" [yellow]⚠[/yellow] No token — you'll need to set {env_var} before starting"
|
||||
)
|
||||
|
||||
# Handle extra env vars (e.g., Slack app token)
|
||||
for extra_env, extra_desc in info.get("extra_env", {}).items():
|
||||
existing_extra = os.environ.get(extra_env, "")
|
||||
if existing_extra:
|
||||
console.print(
|
||||
f" [green]✓[/green] {extra_env} = [cyan]{_mask(existing_extra)}[/cyan]"
|
||||
)
|
||||
new_extra = _prompt_ask(
|
||||
prompt_cls,
|
||||
f" Update {extra_desc}? (Enter = keep current)",
|
||||
password=True,
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
if new_extra:
|
||||
os.environ[extra_env] = new_extra
|
||||
env_to_save[extra_env] = new_extra
|
||||
console.print(f" [green]✓[/green] {extra_env} updated")
|
||||
else:
|
||||
extra_val = _prompt_ask(
|
||||
prompt_cls,
|
||||
f" {extra_desc} ({extra_env})",
|
||||
password=True,
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
if extra_val:
|
||||
os.environ[extra_env] = extra_val
|
||||
env_to_save[extra_env] = extra_val
|
||||
|
||||
# Configure allowlist for this channel
|
||||
allowed_env = info.get("allowed_users_env")
|
||||
if allowed_env:
|
||||
existing_allow = os.environ.get(allowed_env, "").strip()
|
||||
if existing_allow:
|
||||
console.print(
|
||||
f" [green]✓[/green] {allowed_env} = [cyan]{existing_allow}[/cyan]"
|
||||
)
|
||||
console.print(
|
||||
f" [dim]{info.get('user_id_help', 'Comma-separated user IDs')}[/dim]"
|
||||
)
|
||||
new_allow = _prompt_ask(
|
||||
prompt_cls,
|
||||
f" Update allowed users for {channel_key}? (Enter = keep, 'clear' = remove)",
|
||||
default="",
|
||||
show_default=False,
|
||||
).strip()
|
||||
if new_allow.lower() == "clear":
|
||||
os.environ.pop(allowed_env, None)
|
||||
env_to_save[allowed_env] = None
|
||||
console.print(
|
||||
" [yellow]✓ Allowlist cleared — open access restored[/yellow]"
|
||||
)
|
||||
elif new_allow:
|
||||
new_allow = new_allow.replace(" ", "")
|
||||
os.environ[allowed_env] = new_allow
|
||||
env_to_save[allowed_env] = new_allow
|
||||
console.print(" [green]✓[/green] Allowlist updated")
|
||||
else:
|
||||
console.print(
|
||||
f" [bold]🔒 Security — restrict who can use {channel_key}[/bold]"
|
||||
)
|
||||
console.print(
|
||||
f" [dim]{info.get('user_id_help', 'Enter comma-separated user IDs')}[/dim]"
|
||||
)
|
||||
allow_val = _prompt_ask(
|
||||
prompt_cls,
|
||||
" Allowed user IDs (comma-separated, empty = open access)",
|
||||
default="",
|
||||
show_default=False,
|
||||
)
|
||||
allow_val = allow_val.replace(" ", "")
|
||||
if allow_val:
|
||||
os.environ[allowed_env] = allow_val
|
||||
env_to_save[allowed_env] = allow_val
|
||||
console.print(
|
||||
" [green]✓[/green] Allowlist saved — only listed users can talk to the bot"
|
||||
)
|
||||
else:
|
||||
console.print(
|
||||
f" [yellow]⚠ Warning:[/yellow] no allowlist set for {channel_key} — anyone who finds your bot can use it."
|
||||
)
|
||||
|
||||
return env_to_save
|
||||
|
||||
async def _probe_channel(self, channel: Dict):
|
||||
"""Run a probe for a channel."""
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot(channel["platform"], token=channel["token"])
|
||||
return await bot.probe()
|
||||
|
||||
async def _probe(self, platform: str):
|
||||
"""Run a probe for a platform."""
|
||||
"""Run a probe for a platform (legacy method for backward compatibility)."""
|
||||
from praisonai.bots import Bot
|
||||
bot = Bot(platform, token=self.tokens.get(platform, ""))
|
||||
return await bot.probe()
|
||||
|
||||
@@ -4117,6 +4117,16 @@ Do NOT add any explanations or formatting."""
|
||||
planning_tools = getattr(self.args, 'planning_tools', None)
|
||||
if planning_tools:
|
||||
cli_config['planning_tools'] = planning_tools
|
||||
|
||||
# Extract --planning flag
|
||||
if getattr(self.args, 'planning', False):
|
||||
cli_config['planning'] = True
|
||||
|
||||
# Extract web flags
|
||||
if getattr(self.args, 'web', False):
|
||||
cli_config['web'] = True
|
||||
if getattr(self.args, 'web_fetch', False):
|
||||
cli_config['web_fetch'] = True
|
||||
|
||||
# Extract --acp flag
|
||||
if getattr(self.args, 'acp', False):
|
||||
@@ -4154,6 +4164,31 @@ Do NOT add any explanations or formatting."""
|
||||
cli_config['stream'] = stream or stream_metrics
|
||||
if stream_metrics:
|
||||
cli_config['stream_metrics'] = True
|
||||
|
||||
# Extract handoff configuration for YAML CLI parity
|
||||
handoff = getattr(self.args, 'handoff', None)
|
||||
if handoff:
|
||||
cli_config['handoff'] = handoff
|
||||
|
||||
handoff_policy = getattr(self.args, 'handoff_policy', None)
|
||||
if handoff_policy is not None:
|
||||
cli_config['handoff_policy'] = handoff_policy
|
||||
|
||||
handoff_timeout = getattr(self.args, 'handoff_timeout', None)
|
||||
if handoff_timeout is not None:
|
||||
cli_config['handoff_timeout'] = handoff_timeout
|
||||
|
||||
handoff_max_depth = getattr(self.args, 'handoff_max_depth', None)
|
||||
if handoff_max_depth is not None:
|
||||
cli_config['handoff_max_depth'] = handoff_max_depth
|
||||
|
||||
handoff_max_concurrent = getattr(self.args, 'handoff_max_concurrent', None)
|
||||
if handoff_max_concurrent is not None:
|
||||
cli_config['handoff_max_concurrent'] = handoff_max_concurrent
|
||||
|
||||
handoff_detect_cycles = getattr(self.args, 'handoff_detect_cycles', None)
|
||||
if handoff_detect_cycles is not None:
|
||||
cli_config['handoff_detect_cycles'] = handoff_detect_cycles
|
||||
|
||||
return cli_config
|
||||
|
||||
|
||||
@@ -57,7 +57,7 @@ class CloudDeployer:
|
||||
file.write("FROM python:3.11-slim\n")
|
||||
file.write("WORKDIR /app\n")
|
||||
file.write("COPY . .\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.45 gunicorn markdown\n")
|
||||
file.write("RUN pip install flask praisonai==4.6.48 gunicorn markdown\n")
|
||||
file.write("EXPOSE 8080\n")
|
||||
file.write('CMD ["gunicorn", "-b", "0.0.0.0:8080", "api:app"]\n')
|
||||
|
||||
@@ -140,52 +140,82 @@ class CloudDeployer:
|
||||
openai_key = ep.api_key or 'Enter your API key'
|
||||
openai_base = ep.base_url
|
||||
|
||||
# Build commands with actual values
|
||||
commands = [
|
||||
['gcloud', 'auth', 'configure-docker', 'us-central1-docker.pkg.dev'],
|
||||
['gcloud', 'artifacts', 'repositories', 'create', 'praisonai-repository',
|
||||
'--repository-format=docker', '--location=us-central1'],
|
||||
['docker', 'build', '--platform', 'linux/amd64', '-t',
|
||||
f'gcr.io/{project_id}/praisonai-app:latest', '.'],
|
||||
['docker', 'tag', f'gcr.io/{project_id}/praisonai-app:latest',
|
||||
f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest'],
|
||||
['docker', 'push',
|
||||
f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest'],
|
||||
['gcloud', 'run', 'deploy', 'praisonai-service',
|
||||
'--image', f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest',
|
||||
'--platform', 'managed', '--region', 'us-central1', '--allow-unauthenticated',
|
||||
'--set-env-vars', f'OPENAI_MODEL_NAME={openai_model},OPENAI_API_KEY={openai_key},OPENAI_API_BASE={openai_base}']
|
||||
]
|
||||
# Create temporary env vars file to avoid exposing secrets in argv
|
||||
import tempfile
|
||||
import yaml
|
||||
import os
|
||||
|
||||
# Run commands with appropriate handling for each platform
|
||||
for i, cmd in enumerate(commands):
|
||||
try:
|
||||
if i == 0: # First command (gcloud auth configure-docker)
|
||||
if platform.system() != 'Windows':
|
||||
# On Unix, pipe 'yes' to auto-confirm
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
||||
proc.communicate(input=b'Y\n')
|
||||
if proc.returncode != 0:
|
||||
raise subprocess.CalledProcessError(proc.returncode, cmd)
|
||||
env_vars_file = None
|
||||
try:
|
||||
# Create secure temp file for environment variables
|
||||
fd, env_vars_file = tempfile.mkstemp(suffix=".yaml", prefix="praisonai-deploy-")
|
||||
os.close(fd)
|
||||
os.chmod(env_vars_file, 0o600) # Secure file permissions
|
||||
|
||||
# Write env vars to file instead of passing in argv
|
||||
env_vars = {
|
||||
"OPENAI_MODEL_NAME": openai_model,
|
||||
"OPENAI_API_KEY": openai_key,
|
||||
"OPENAI_API_BASE": openai_base
|
||||
}
|
||||
|
||||
with open(env_vars_file, "w") as f:
|
||||
yaml.safe_dump(env_vars, f)
|
||||
|
||||
# Build commands with secure env vars file
|
||||
commands = [
|
||||
['gcloud', 'auth', 'configure-docker', 'us-central1-docker.pkg.dev'],
|
||||
['gcloud', 'artifacts', 'repositories', 'create', 'praisonai-repository',
|
||||
'--repository-format=docker', '--location=us-central1'],
|
||||
['docker', 'build', '--platform', 'linux/amd64', '-t',
|
||||
f'gcr.io/{project_id}/praisonai-app:latest', '.'],
|
||||
['docker', 'tag', f'gcr.io/{project_id}/praisonai-app:latest',
|
||||
f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest'],
|
||||
['docker', 'push',
|
||||
f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest'],
|
||||
['gcloud', 'run', 'deploy', 'praisonai-service',
|
||||
'--image', f'us-central1-docker.pkg.dev/{project_id}/praisonai-repository/praisonai-app:latest',
|
||||
'--platform', 'managed', '--region', 'us-central1', '--allow-unauthenticated',
|
||||
'--env-vars-file', env_vars_file]
|
||||
]
|
||||
|
||||
# Run commands with appropriate handling for each platform
|
||||
for i, cmd in enumerate(commands):
|
||||
try:
|
||||
if i == 0: # First command (gcloud auth configure-docker)
|
||||
if platform.system() != 'Windows':
|
||||
# On Unix, pipe 'yes' to auto-confirm
|
||||
proc = subprocess.Popen(cmd, stdin=subprocess.PIPE)
|
||||
proc.communicate(input=b'Y\n')
|
||||
if proc.returncode != 0:
|
||||
raise subprocess.CalledProcessError(proc.returncode, cmd)
|
||||
else:
|
||||
# On Windows, try with --quiet flag to avoid prompts
|
||||
cmd_with_quiet = cmd + ['--quiet']
|
||||
try:
|
||||
subprocess.run(cmd_with_quiet, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
# If --quiet fails, try without it
|
||||
print("Note: You may need to manually confirm the authentication prompt")
|
||||
subprocess.run(cmd, check=True)
|
||||
else:
|
||||
# On Windows, try with --quiet flag to avoid prompts
|
||||
cmd_with_quiet = cmd + ['--quiet']
|
||||
try:
|
||||
subprocess.run(cmd_with_quiet, check=True)
|
||||
except subprocess.CalledProcessError:
|
||||
# If --quiet fails, try without it
|
||||
print("Note: You may need to manually confirm the authentication prompt")
|
||||
subprocess.run(cmd, check=True)
|
||||
else:
|
||||
# Run other commands normally
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"ERROR: Command failed with exit status {e.returncode}")
|
||||
# Commands 2 (build) and 4 (push) and 5 (deploy) are critical
|
||||
if i in [2, 4, 5]:
|
||||
print("Critical command failed. Aborting deployment.")
|
||||
return
|
||||
print(f"Continuing with the next command...")
|
||||
# Run other commands normally
|
||||
subprocess.run(cmd, check=True)
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"ERROR: Command failed with exit status {e.returncode}")
|
||||
# Commands 2 (build) and 4 (push) and 5 (deploy) are critical
|
||||
if i in [2, 4, 5]:
|
||||
print("Critical command failed. Aborting deployment.")
|
||||
return
|
||||
print(f"Continuing with the next command...")
|
||||
|
||||
finally:
|
||||
# Always cleanup the temporary env vars file
|
||||
if env_vars_file:
|
||||
try:
|
||||
os.remove(env_vars_file)
|
||||
except Exception:
|
||||
pass # Don't fail deployment if cleanup fails
|
||||
|
||||
# Usage
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -71,44 +71,44 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
acp_enabled = global_config.get('acp', False)
|
||||
lsp_enabled = global_config.get('lsp', False)
|
||||
interactive_runtime = None
|
||||
interactive_loop = None
|
||||
|
||||
if acp_enabled or lsp_enabled:
|
||||
try:
|
||||
import asyncio
|
||||
from praisonai._async_bridge import run_sync
|
||||
from praisonai.cli.features.interactive_runtime import InteractiveRuntime, RuntimeConfig
|
||||
from praisonai.cli.features.agent_tools import create_agent_centric_tools
|
||||
|
||||
# Use scoped event loop instead of process-global mutations
|
||||
# Use scoped configuration instead of process-global mutations
|
||||
runtime_config = RuntimeConfig(
|
||||
workspace=os.getcwd(),
|
||||
acp_enabled=acp_enabled,
|
||||
lsp_enabled=lsp_enabled,
|
||||
approval_mode=os.environ.get("PRAISONAI_APPROVAL_MODE", "prompt")
|
||||
)
|
||||
interactive_runtime = InteractiveRuntime(runtime_config)
|
||||
rt = InteractiveRuntime(runtime_config)
|
||||
logger.info(f"Starting InteractiveRuntime (ACP: {acp_enabled}, LSP: {lsp_enabled})")
|
||||
|
||||
# Create a scoped event loop instead of modifying process globals
|
||||
interactive_loop = asyncio.new_event_loop()
|
||||
|
||||
# Start the runtime but keep it alive for agent execution
|
||||
interactive_loop.run_until_complete(interactive_runtime.start())
|
||||
|
||||
centric_tools = create_agent_centric_tools(interactive_runtime)
|
||||
logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
|
||||
|
||||
# Merge with tools_dict
|
||||
if tools_dict:
|
||||
tools_dict.update(centric_tools)
|
||||
else:
|
||||
tools_dict = centric_tools
|
||||
# Start the runtime on the shared background loop where it stays alive
|
||||
# and its asyncio primitives remain valid for the duration of this call
|
||||
run_sync(rt.start())
|
||||
interactive_runtime = rt # only assign AFTER start() succeeds
|
||||
|
||||
except ImportError as e:
|
||||
logger.warning(f"InteractiveRuntime not available: {e}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error setting up InteractiveRuntime: {e}")
|
||||
interactive_runtime = None
|
||||
except (RuntimeError, OSError, ConnectionError) as e:
|
||||
logger.warning(f"InteractiveRuntime startup failed: {e}")
|
||||
interactive_runtime = None
|
||||
try:
|
||||
# All work that can throw *after* start() lives here, including
|
||||
# create_agent_centric_tools, tools_dict.update, agent construction,
|
||||
# team.start(), etc.
|
||||
if interactive_runtime is not None:
|
||||
from praisonai.cli.features.agent_tools import create_agent_centric_tools
|
||||
centric_tools = create_agent_centric_tools(interactive_runtime)
|
||||
logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
|
||||
tools_dict = {**(tools_dict or {}), **centric_tools}
|
||||
|
||||
# Create agents from roles
|
||||
for role, details in config.get('roles', {}).items():
|
||||
role_filled = self._format_template(details.get('role', role), topic=topic)
|
||||
@@ -204,12 +204,11 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
return result
|
||||
finally:
|
||||
# Cleanup InteractiveRuntime if it was started
|
||||
if interactive_runtime and interactive_loop:
|
||||
if interactive_runtime is not None:
|
||||
try:
|
||||
logger.info("Stopping InteractiveRuntime")
|
||||
interactive_loop.run_until_complete(interactive_runtime.stop())
|
||||
from praisonai._async_bridge import run_sync
|
||||
run_sync(interactive_runtime.stop())
|
||||
except Exception as e:
|
||||
logger.error(f"Error stopping InteractiveRuntime: {e}")
|
||||
finally:
|
||||
interactive_loop.close()
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
"""
|
||||
Port checking and PID lock utilities for gateway collision prevention.
|
||||
|
||||
Provides utilities to check if ports are in use and manage single-instance locks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import socket
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Optional, Tuple
|
||||
|
||||
|
||||
def is_port_in_use(host: str = "127.0.0.1", port: int = 8765) -> bool:
|
||||
"""Check if a port is already in use.
|
||||
|
||||
Args:
|
||||
host: Host to check (default: 127.0.0.1)
|
||||
port: Port to check (default: 8765)
|
||||
|
||||
Returns:
|
||||
True if port is in use, False otherwise
|
||||
"""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock:
|
||||
return sock.connect_ex((host, port)) == 0
|
||||
|
||||
|
||||
def check_port_available(host: str, port: int) -> Tuple[bool, Optional[int]]:
|
||||
"""Check if a port is available and attempt to find the process using it.
|
||||
|
||||
Args:
|
||||
host: Host to check
|
||||
port: Port to check
|
||||
|
||||
Returns:
|
||||
Tuple of (is_available, pid_using_port)
|
||||
"""
|
||||
if not is_port_in_use(host, port):
|
||||
return True, None
|
||||
|
||||
# Try to find the process using the port
|
||||
try:
|
||||
import psutil
|
||||
for proc in psutil.process_iter(['pid', 'name', 'connections']):
|
||||
try:
|
||||
connections = proc.info['connections']
|
||||
if connections:
|
||||
for conn in connections:
|
||||
if (hasattr(conn, 'laddr') and conn.laddr and
|
||||
conn.laddr.port == port and
|
||||
conn.laddr.ip in ('0.0.0.0', '127.0.0.1', host)):
|
||||
return False, proc.info['pid']
|
||||
except (psutil.NoSuchProcess, psutil.AccessDenied, psutil.ZombieProcess):
|
||||
continue
|
||||
except ImportError:
|
||||
# psutil not available, can't identify process
|
||||
pass
|
||||
|
||||
return False, None
|
||||
|
||||
|
||||
class GatewayPIDLock:
|
||||
"""Manages PID lock file for single-instance gateway enforcement."""
|
||||
|
||||
def __init__(self, lock_dir: Optional[Path] = None, host: str = "127.0.0.1", port: int = 8765):
|
||||
"""Initialize PID lock manager.
|
||||
|
||||
Args:
|
||||
lock_dir: Directory for lock file (default: ~/.praisonai/)
|
||||
host: Gateway host
|
||||
port: Gateway port
|
||||
"""
|
||||
self.lock_dir = lock_dir or Path.home() / ".praisonai"
|
||||
# Make lock file specific to host and port to allow multiple instances
|
||||
safe_host = host.replace(":", "_").replace(".", "_")
|
||||
self.lock_file = self.lock_dir / f"gateway-{safe_host}-{port}.pid"
|
||||
self.lock_dir.mkdir(exist_ok=True)
|
||||
|
||||
def acquire_lock(self, host: str, port: int) -> bool:
|
||||
"""Acquire the PID lock for the gateway.
|
||||
|
||||
Args:
|
||||
host: Gateway host
|
||||
port: Gateway port
|
||||
|
||||
Returns:
|
||||
True if lock acquired successfully, False if conflict
|
||||
"""
|
||||
current_pid = os.getpid()
|
||||
|
||||
# Check if lock file exists
|
||||
if self.lock_file.exists():
|
||||
try:
|
||||
lock_content = self.lock_file.read_text().strip()
|
||||
if lock_content:
|
||||
lines = lock_content.split('\n')
|
||||
if lines:
|
||||
existing_pid = int(lines[0])
|
||||
|
||||
# Check if process is still running
|
||||
if self._is_process_running(existing_pid):
|
||||
return False # Lock held by active process
|
||||
|
||||
# Process dead, remove stale lock
|
||||
self._remove_stale_lock()
|
||||
except (ValueError, OSError):
|
||||
# Corrupted lock file, remove it
|
||||
self._remove_stale_lock()
|
||||
|
||||
# Write new lock atomically to avoid race condition
|
||||
lock_content = f"{current_pid}\n{host}\n{port}\n{int(time.time())}\n"
|
||||
try:
|
||||
# Use a temporary file and atomic rename to prevent race conditions
|
||||
temp_lock_file = self.lock_file.with_suffix(".tmp")
|
||||
temp_lock_file.write_text(lock_content)
|
||||
temp_lock_file.replace(self.lock_file)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def release_lock(self) -> None:
|
||||
"""Release the PID lock."""
|
||||
try:
|
||||
if self.lock_file.exists():
|
||||
self.lock_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
def get_lock_info(self) -> Optional[dict]:
|
||||
"""Get information about the current lock.
|
||||
|
||||
Returns:
|
||||
Dict with lock info or None if no valid lock
|
||||
"""
|
||||
if not self.lock_file.exists():
|
||||
return None
|
||||
|
||||
try:
|
||||
lock_content = self.lock_file.read_text().strip()
|
||||
if not lock_content:
|
||||
return None
|
||||
|
||||
lines = lock_content.split('\n')
|
||||
if len(lines) < 4:
|
||||
return None
|
||||
|
||||
pid = int(lines[0])
|
||||
host = lines[1]
|
||||
port = int(lines[2])
|
||||
timestamp = int(lines[3])
|
||||
|
||||
return {
|
||||
'pid': pid,
|
||||
'host': host,
|
||||
'port': port,
|
||||
'timestamp': timestamp,
|
||||
'is_running': self._is_process_running(pid)
|
||||
}
|
||||
except (ValueError, OSError):
|
||||
return None
|
||||
|
||||
def _is_process_running(self, pid: int) -> bool:
|
||||
"""Check if a process with given PID is running."""
|
||||
try:
|
||||
# Send signal 0 to check if process exists
|
||||
os.kill(pid, 0)
|
||||
return True
|
||||
except (OSError, ProcessLookupError):
|
||||
return False
|
||||
|
||||
def _remove_stale_lock(self) -> None:
|
||||
"""Remove stale lock file."""
|
||||
try:
|
||||
if self.lock_file.exists():
|
||||
self.lock_file.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def format_collision_error(host: str, port: int, lock_info: Optional[dict] = None) -> str:
|
||||
"""Format a user-friendly error message for port collisions.
|
||||
|
||||
Args:
|
||||
host: Gateway host
|
||||
port: Gateway port
|
||||
lock_info: Optional lock information
|
||||
|
||||
Returns:
|
||||
Formatted error message with actionable guidance
|
||||
"""
|
||||
port_desc = f"{host}:{port}" if host != "127.0.0.1" else f"port {port}"
|
||||
|
||||
lines = [
|
||||
f"Error: Gateway {port_desc} is already in use.",
|
||||
""
|
||||
]
|
||||
|
||||
if lock_info and lock_info.get('is_running'):
|
||||
pid = lock_info['pid']
|
||||
lines.extend([
|
||||
f" Another gateway may be running (PID {pid}).",
|
||||
f" Stop it: praisonai gateway stop",
|
||||
f" Or use a different port: GATEWAY_PORT={port + 1} praisonai gateway start",
|
||||
"",
|
||||
" Only ONE gateway process should poll each Telegram bot token.",
|
||||
])
|
||||
else:
|
||||
lines.extend([
|
||||
" Another process may be using this port.",
|
||||
f" Use a different port: praisonai gateway start --port {port + 1}",
|
||||
f" Or set environment variable: GATEWAY_PORT={port + 1}",
|
||||
])
|
||||
|
||||
return "\n".join(lines)
|
||||
@@ -29,6 +29,8 @@ from praisonaiagents.gateway import (
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from .unicode_utils import safe_error_message, safe_log_message, extract_root_cause_from_error
|
||||
|
||||
|
||||
@dataclass
|
||||
class GatewaySession:
|
||||
@@ -159,7 +161,7 @@ class WebSocketGateway:
|
||||
logger.warning(f"Config file not found: {config_path}, using defaults")
|
||||
return cls()
|
||||
|
||||
with open(config_path, "r") as f:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f) or {}
|
||||
|
||||
gateway_config = raw.get(section, {})
|
||||
@@ -235,6 +237,10 @@ class WebSocketGateway:
|
||||
except Exception as e:
|
||||
logger.debug(f"Could not save auth token to .env: {e}")
|
||||
|
||||
# Ensure single source of truth: export resolved token so all auth paths use the same secret
|
||||
if self.config.auth_token:
|
||||
os.environ["GATEWAY_AUTH_TOKEN"] = self.config.auth_token
|
||||
|
||||
# Load allowed origins from environment if not set
|
||||
if hasattr(self.config, 'allowed_origins') and not self.config.allowed_origins:
|
||||
env_origins = os.environ.get("GATEWAY_ALLOWED_ORIGINS", "").strip()
|
||||
@@ -269,6 +275,9 @@ class WebSocketGateway:
|
||||
|
||||
# Scheduler tick background task
|
||||
self._scheduler_task: Optional[asyncio.Task] = None
|
||||
|
||||
# PID lock for single-instance enforcement
|
||||
self._pid_lock: Optional[Any] = None
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
@@ -288,6 +297,30 @@ class WebSocketGateway:
|
||||
logger.warning("Gateway already running")
|
||||
return
|
||||
|
||||
# Preflight port collision check
|
||||
from .port_utils import check_port_available, GatewayPIDLock, format_collision_error
|
||||
|
||||
pid_lock = GatewayPIDLock(host=self._host, port=self._port)
|
||||
|
||||
# Check if port is available
|
||||
port_available, pid_using_port = check_port_available(self._host, self._port)
|
||||
if not port_available:
|
||||
# Check if we have a PID lock
|
||||
lock_info = pid_lock.get_lock_info()
|
||||
error_msg = format_collision_error(self._host, self._port, lock_info)
|
||||
logger.error("Gateway startup failed due to port collision")
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
# Try to acquire PID lock
|
||||
if not pid_lock.acquire_lock(self._host, self._port):
|
||||
lock_info = pid_lock.get_lock_info()
|
||||
error_msg = format_collision_error(self._host, self._port, lock_info)
|
||||
logger.error("Gateway startup failed - another instance is running")
|
||||
raise RuntimeError(error_msg)
|
||||
|
||||
# Store PID lock for cleanup
|
||||
self._pid_lock = pid_lock
|
||||
|
||||
# Validate bind-aware auth configuration before starting
|
||||
from .auth import assert_external_bind_safe
|
||||
self.config.host = self._host
|
||||
@@ -713,7 +746,15 @@ class WebSocketGateway:
|
||||
|
||||
logger.info(f"Gateway started on ws://{self._host}:{self._port}")
|
||||
|
||||
await self._server.serve()
|
||||
try:
|
||||
await self._server.serve()
|
||||
except Exception as e:
|
||||
# Clean up PID lock on any startup failure
|
||||
if hasattr(self, '_pid_lock') and self._pid_lock:
|
||||
self._pid_lock.release_lock()
|
||||
self._pid_lock = None
|
||||
# Re-raise the original exception
|
||||
raise
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""Stop the gateway server."""
|
||||
@@ -737,6 +778,10 @@ class WebSocketGateway:
|
||||
if self._server:
|
||||
self._server.should_exit = True
|
||||
|
||||
# Release PID lock
|
||||
if hasattr(self, '_pid_lock'):
|
||||
self._pid_lock.release_lock()
|
||||
|
||||
logger.info("Gateway stopped")
|
||||
|
||||
async def _handle_client_message(self, client_id: str, data: Dict[str, Any]) -> None:
|
||||
@@ -1313,7 +1358,7 @@ class WebSocketGateway:
|
||||
if not os.path.exists(config_path):
|
||||
raise FileNotFoundError(f"Gateway config not found: {config_path}")
|
||||
|
||||
with open(config_path, "r") as f:
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
raw = yaml.safe_load(f)
|
||||
|
||||
if not raw or not isinstance(raw, dict):
|
||||
@@ -1654,8 +1699,8 @@ class WebSocketGateway:
|
||||
) -> Any:
|
||||
"""Create a bot instance for the given channel type."""
|
||||
# Clone agent to prevent channel-specific settings from leaking between channels
|
||||
import copy
|
||||
agent = copy.deepcopy(agent)
|
||||
# Use clone_for_channel() instead of copy.deepcopy() to avoid RLock issues (fixes #1746)
|
||||
agent = agent.clone_for_channel()
|
||||
|
||||
# Apply smart defaults to agent (same logic as Bot() wrapper)
|
||||
from praisonai.bots._defaults import apply_bot_smart_defaults
|
||||
@@ -1859,10 +1904,6 @@ class WebSocketGateway:
|
||||
if not agent:
|
||||
agent = bot._agent # fallback to default
|
||||
|
||||
# Show typing indicator
|
||||
if bot.config.typing_indicator:
|
||||
await update.message.chat.send_action("typing")
|
||||
|
||||
# Ack reaction — show processing indicator
|
||||
ack_ctx = None
|
||||
if bot._ack.enabled:
|
||||
@@ -1889,7 +1930,20 @@ class WebSocketGateway:
|
||||
|
||||
try:
|
||||
message_text = await bot._debouncer.debounce(user_id, message_text)
|
||||
response = await bot._session.chat(agent, user_id, message_text)
|
||||
|
||||
# Show typing indicator with renewal during long operation
|
||||
if bot.config.typing_indicator:
|
||||
from praisonai.bots._typing_indicator import with_typing_renewal
|
||||
|
||||
async def _typing_action():
|
||||
await update.message.chat.send_action("typing")
|
||||
|
||||
response = await with_typing_renewal(
|
||||
typing_func=_typing_action,
|
||||
operation_coro=bot._session.chat(agent, user_id, message_text)
|
||||
)
|
||||
else:
|
||||
response = await bot._session.chat(agent, user_id, message_text)
|
||||
if hasattr(bot, '_send_response_with_media'):
|
||||
await bot._send_response_with_media(
|
||||
update.message.chat_id,
|
||||
@@ -1902,8 +1956,9 @@ class WebSocketGateway:
|
||||
if ack_ctx:
|
||||
await bot._ack.done(ack_ctx, react_fn=_tg_react, unreact_fn=_tg_unreact)
|
||||
except Exception as e:
|
||||
logger.error(f"Agent error in {name}: {e}")
|
||||
await update.message.reply_text(f"Error: {str(e)}")
|
||||
logger.error(f"Agent error in {name}: {safe_log_message(e)}")
|
||||
user_error = extract_root_cause_from_error(str(e))
|
||||
await update.message.reply_text(f"Error: {safe_error_message(user_error)}")
|
||||
|
||||
async def handle_voice(update: Update, context: Any):
|
||||
await handle_message(update, context)
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
"""
|
||||
Unicode safety utilities for gateway server on Windows.
|
||||
|
||||
Provides safe error message formatting for bot error handlers that may
|
||||
receive exception text containing Unicode characters that cannot be encoded
|
||||
with Windows cp1252 default encoding.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import re
|
||||
import unicodedata
|
||||
from typing import Union, Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Module-level constants to avoid recreation on every call
|
||||
_SYMBOL_MAP: dict = {
|
||||
'\u26a0': '!', # WARNING SIGN -> !
|
||||
'\u2713': 'OK', # CHECK MARK -> OK
|
||||
'\u2717': 'X', # BALLOT X -> X
|
||||
'\u2192': '->', # RIGHT ARROW -> ->
|
||||
'\u2190': '<-', # LEFT ARROW -> <-
|
||||
'\u2022': '*', # BULLET -> *
|
||||
'\u00ae': '(R)', # REGISTERED SIGN -> (R)
|
||||
'\u00a9': '(C)', # COPYRIGHT SIGN -> (C)
|
||||
'\u2026': '...', # HORIZONTAL ELLIPSIS -> ...
|
||||
'\u201c': '"', # LEFT DOUBLE QUOTATION MARK -> "
|
||||
'\u201d': '"', # RIGHT DOUBLE QUOTATION MARK -> "
|
||||
'\u2018': "'", # LEFT SINGLE QUOTATION MARK -> '
|
||||
'\u2019': "'", # RIGHT SINGLE QUOTATION MARK -> '
|
||||
'\u2013': '-', # EN DASH -> -
|
||||
'\u2014': '--', # EM DASH -> --
|
||||
}
|
||||
|
||||
_LATIN1_MAP: dict = {
|
||||
'\u00e0': 'a', '\u00e1': 'a', '\u00e2': 'a', '\u00e3': 'a',
|
||||
'\u00e4': 'a', '\u00e5': 'a',
|
||||
'\u00e8': 'e', '\u00e9': 'e', '\u00ea': 'e', '\u00eb': 'e',
|
||||
'\u00ec': 'i', '\u00ed': 'i', '\u00ee': 'i', '\u00ef': 'i',
|
||||
'\u00f2': 'o', '\u00f3': 'o', '\u00f4': 'o', '\u00f5': 'o', '\u00f6': 'o',
|
||||
'\u00f9': 'u', '\u00fa': 'u', '\u00fb': 'u', '\u00fc': 'u',
|
||||
'\u00e7': 'c', '\u00f1': 'n',
|
||||
'\u00c0': 'A', '\u00c1': 'A', '\u00c2': 'A', '\u00c3': 'A',
|
||||
'\u00c4': 'A', '\u00c5': 'A',
|
||||
'\u00c8': 'E', '\u00c9': 'E', '\u00ca': 'E', '\u00cb': 'E',
|
||||
'\u00cc': 'I', '\u00cd': 'I', '\u00ce': 'I', '\u00cf': 'I',
|
||||
'\u00d2': 'O', '\u00d3': 'O', '\u00d4': 'O', '\u00d5': 'O', '\u00d6': 'O',
|
||||
'\u00d9': 'U', '\u00da': 'U', '\u00db': 'U', '\u00dc': 'U',
|
||||
'\u00c7': 'C', '\u00d1': 'N',
|
||||
}
|
||||
|
||||
# Pre-compiled regex patterns for API error extraction
|
||||
_API_PATTERNS = [
|
||||
re.compile(r"Error code: (\d+) - (.+)", re.IGNORECASE), # OpenAI format
|
||||
re.compile(r"HTTP (\d+): (.+)", re.IGNORECASE), # General HTTP error
|
||||
re.compile(r"exceeded.*quota", re.IGNORECASE), # Quota/billing issues
|
||||
re.compile(r"insufficient.*quota", re.IGNORECASE), # Quota/billing issues
|
||||
re.compile(r"Rate limit.*exceeded", re.IGNORECASE), # Rate limiting
|
||||
re.compile(r"Authentication.*failed", re.IGNORECASE), # Auth issues
|
||||
]
|
||||
|
||||
|
||||
def safe_error_message(exc: Union[BaseException, str, Any], max_len: int = 500) -> str:
|
||||
"""Create ASCII-safe error text for Telegram/Discord replies on all platforms.
|
||||
|
||||
On Windows, Python's default console encoding is often cp1252 (Windows-1252),
|
||||
not UTF-8. When an exception contains non-ASCII characters (warning symbols,
|
||||
emoji, international text from LLM output), the error handler can crash with
|
||||
a 'charmap' encoding error.
|
||||
|
||||
This function sanitizes exception text to prevent secondary encoding failures
|
||||
while preserving the meaning of the original error.
|
||||
|
||||
Args:
|
||||
exc: Exception, string, or any object to convert to safe error text
|
||||
max_len: Maximum length of returned message
|
||||
|
||||
Returns:
|
||||
ASCII-safe error string suitable for bot replies
|
||||
"""
|
||||
if isinstance(exc, BaseException):
|
||||
text = str(exc) or type(exc).__name__
|
||||
else:
|
||||
text = str(exc)
|
||||
|
||||
# Apply specific symbol replacements first to preserve meaning
|
||||
for unicode_char, ascii_replacement in _SYMBOL_MAP.items():
|
||||
text = text.replace(unicode_char, ascii_replacement)
|
||||
|
||||
# Build ASCII-safe output using module-level maps and NFKD normalization.
|
||||
# NFKD decomposes e.g. 'e\u0301' (e + combining accent) so the base
|
||||
# letter survives encode('ascii', errors='ignore').
|
||||
parts = []
|
||||
for char in text:
|
||||
if ord(char) < 128:
|
||||
parts.append(char)
|
||||
elif char in _LATIN1_MAP:
|
||||
parts.append(_LATIN1_MAP[char])
|
||||
else:
|
||||
nfkd = unicodedata.normalize('NFKD', char)
|
||||
ascii_base = nfkd.encode('ascii', errors='ignore').decode('ascii')
|
||||
parts.append(ascii_base if ascii_base else '?')
|
||||
|
||||
safe_text = ''.join(parts)
|
||||
|
||||
if len(safe_text) > max_len:
|
||||
safe_text = safe_text[:max_len - 3] + "..."
|
||||
|
||||
return safe_text.strip() or "Error occurred"
|
||||
|
||||
|
||||
def safe_log_message(exc: Union[BaseException, str, Any]) -> str:
|
||||
"""Create a safe version of an error message for logging.
|
||||
|
||||
Preserves Unicode for UTF-8-capable log handlers while replacing
|
||||
lone surrogates (U+D800-U+DFFF) that cannot be encoded in any standard
|
||||
encoding.
|
||||
|
||||
Args:
|
||||
exc: Exception, string, or any object to log
|
||||
|
||||
Returns:
|
||||
String safe for logging, with lone surrogates replaced by U+FFFD
|
||||
"""
|
||||
if isinstance(exc, BaseException):
|
||||
text = str(exc) or type(exc).__name__
|
||||
else:
|
||||
text = str(exc)
|
||||
|
||||
# Replace lone surrogates that cannot be encoded in UTF-8
|
||||
return text.encode('utf-8', errors='replace').decode('utf-8')
|
||||
|
||||
|
||||
def extract_root_cause_from_error(error_text: str) -> str:
|
||||
"""Extract the root cause from complex error messages.
|
||||
|
||||
When error chaining occurs (e.g., a charmap error masking an API quota
|
||||
error), try to extract the underlying meaningful error for the user.
|
||||
|
||||
The returned string may still contain non-ASCII characters; callers
|
||||
should pass it through :func:`safe_error_message` before display.
|
||||
|
||||
Args:
|
||||
error_text: Full error message text
|
||||
|
||||
Returns:
|
||||
Simplified error message focusing on root cause (may be non-ASCII)
|
||||
"""
|
||||
for pattern in _API_PATTERNS:
|
||||
match = pattern.search(error_text)
|
||||
if match:
|
||||
if len(match.groups()) >= 2:
|
||||
return f"Error {match.group(1)}: {match.group(2)}"
|
||||
return match.group(0)
|
||||
|
||||
lower = error_text.lower()
|
||||
if "quota" in lower:
|
||||
return "API quota exceeded. Check billing."
|
||||
if "rate limit" in lower:
|
||||
return "Rate limit exceeded. Try again later."
|
||||
if "authentication" in lower:
|
||||
return "Authentication failed. Check API key."
|
||||
if "timeout" in lower:
|
||||
return "Request timeout. Try again."
|
||||
|
||||
# Return raw error text - caller will sanitize with safe_error_message
|
||||
return error_text
|
||||
@@ -2,12 +2,13 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
from typing import Any, Optional
|
||||
|
||||
|
||||
async def run_integrated_gateway(
|
||||
async def _run_integrated_gateway_async(
|
||||
*,
|
||||
port: int = 8080,
|
||||
port: int = 8765,
|
||||
host: str = "127.0.0.1",
|
||||
static_dir: Optional[str] = None,
|
||||
ui_config: Optional[dict[str, Any]] = None,
|
||||
@@ -25,3 +26,27 @@ async def run_integrated_gateway(
|
||||
ui_config=ui_config or {},
|
||||
)
|
||||
await gateway.start()
|
||||
|
||||
|
||||
def run_integrated_gateway(
|
||||
*,
|
||||
port: int = 8765,
|
||||
host: str = "127.0.0.1",
|
||||
static_dir: Optional[str] = None,
|
||||
ui_config: Optional[dict[str, Any]] = None,
|
||||
**configure_kwargs: Any,
|
||||
) -> None:
|
||||
"""Sync wrapper for integrated gateway (for CLI usage)."""
|
||||
asyncio.run(_run_integrated_gateway_async(
|
||||
port=port,
|
||||
host=host,
|
||||
static_dir=static_dir,
|
||||
ui_config=ui_config,
|
||||
**configure_kwargs
|
||||
))
|
||||
|
||||
|
||||
# Alias for backward compatibility
|
||||
async def run_integrated_gateway_async(**kwargs) -> None:
|
||||
"""Async version (for direct usage)."""
|
||||
await _run_integrated_gateway_async(**kwargs)
|
||||
@@ -33,6 +33,9 @@ def configure_host(
|
||||
agent_kwargs: Optional[Dict[str, Any]] = None,
|
||||
gateway: Any = None,
|
||||
modules: Optional[Sequence[str]] = None,
|
||||
style: str = "dashboard",
|
||||
context_paths: Optional[Sequence[str]] = None,
|
||||
**kwargs: Any,
|
||||
) -> None:
|
||||
"""Apply PraisonAIUI host settings and wire L1 backends (unless legacy mode)."""
|
||||
global _CONFIGURED
|
||||
@@ -41,7 +44,7 @@ def configure_host(
|
||||
from praisonai.ui._aiui_datastore import PraisonAISessionDataStore
|
||||
|
||||
aiui.set_datastore(PraisonAISessionDataStore())
|
||||
aiui.set_style("dashboard")
|
||||
aiui.set_style(style)
|
||||
aiui.set_branding(title=title, logo=logo)
|
||||
|
||||
if pages is not None:
|
||||
@@ -80,13 +83,21 @@ def configure_host(
|
||||
if agents:
|
||||
set_provider(PraisonAIProvider(agents=list(agents), **kwargs))
|
||||
else:
|
||||
# Load context files if specified
|
||||
instructions = kwargs.pop("instructions", "You are a helpful assistant.")
|
||||
if context_paths:
|
||||
try:
|
||||
from praisonai.integration.context_files import load_context_files
|
||||
context = load_context_files(list(context_paths))
|
||||
if context:
|
||||
instructions = f"{instructions}\n\nContext:\n{context}"
|
||||
except ImportError:
|
||||
pass # Context files helper is optional
|
||||
|
||||
set_provider(
|
||||
PraisonAIProvider(
|
||||
name=kwargs.pop("name", "PraisonAI"),
|
||||
instructions=kwargs.pop(
|
||||
"instructions",
|
||||
"You are a helpful assistant.",
|
||||
),
|
||||
instructions=instructions,
|
||||
llm=kwargs.pop(
|
||||
"llm", os.getenv("PRAISONAI_MODEL", "gpt-4o-mini")
|
||||
),
|
||||
@@ -95,6 +106,12 @@ def configure_host(
|
||||
)
|
||||
setup_bridges()
|
||||
|
||||
# Register L3 dashboard pages
|
||||
try:
|
||||
from praisonai.integration.pages import workflow_runs, bot_health
|
||||
except ImportError:
|
||||
pass # L3 pages are optional
|
||||
|
||||
_CONFIGURED = True
|
||||
|
||||
|
||||
@@ -109,15 +126,19 @@ def setup_bridges() -> None:
|
||||
from praisonai.integration.bridges.usage_bridge import register_usage_sink
|
||||
|
||||
sink = register_usage_sink()
|
||||
except Exception as exc:
|
||||
except ImportError as exc:
|
||||
log.debug("usage bridge unavailable: %s", exc)
|
||||
except Exception as exc:
|
||||
log.warning("usage bridge unavailable: %s", exc)
|
||||
|
||||
try:
|
||||
from praisonai.integration.bridges.schedules_runner import ensure_schedule_runner
|
||||
|
||||
ensure_schedule_runner()
|
||||
except Exception as exc:
|
||||
except ImportError as exc:
|
||||
log.debug("schedule runner unavailable: %s", exc)
|
||||
except Exception as exc:
|
||||
log.warning("schedule runner unavailable: %s", exc)
|
||||
|
||||
try:
|
||||
import praisonaiui.backends as backends
|
||||
@@ -144,8 +165,10 @@ def setup_bridges() -> None:
|
||||
|
||||
backends.set_backend("approvals_pending", list_pending_approvals)
|
||||
backends.set_backend("approvals_policies", get_approval_policies)
|
||||
except Exception as exc:
|
||||
except ImportError as exc:
|
||||
log.debug("aiui backend injection failed: %s", exc)
|
||||
except Exception as exc:
|
||||
log.warning("aiui backend injection failed: %s", exc)
|
||||
|
||||
|
||||
def create_host_app():
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
"""Optional L3 dashboard pages for PraisonAI integration."""
|
||||
|
||||
# Import pages to register them with aiui
|
||||
try:
|
||||
from . import workflow_runs, bot_health
|
||||
except ImportError:
|
||||
# Pages are optional and may not be available if aiui is not installed
|
||||
pass
|
||||
@@ -37,16 +37,122 @@ class DbSessionAdapter:
|
||||
self._sessions: Set[str] = set()
|
||||
self._metadata: Dict[str, Dict[str, Any]] = {}
|
||||
self._history_cache: Dict[str, List[Dict[str, str]]] = {}
|
||||
self._cleared_sessions: Set[str] = set()
|
||||
|
||||
def _conversation_store(self) -> Any:
|
||||
"""Optional ConversationStore behind the DbAdapter (if present)."""
|
||||
init = getattr(self._db, "_init_stores", None)
|
||||
if callable(init):
|
||||
try:
|
||||
init()
|
||||
except Exception as e:
|
||||
logger.debug("[db_session_adapter] _init_stores failed: %s", e)
|
||||
|
||||
for obj in (self._db, getattr(self._db, "_orchestrator", None)):
|
||||
if obj is None:
|
||||
continue
|
||||
conv = getattr(obj, "conversation", None)
|
||||
if conv is not None:
|
||||
return conv
|
||||
conv = getattr(obj, "_conversation_store", None)
|
||||
if conv is not None:
|
||||
return conv
|
||||
return None
|
||||
|
||||
def _load_metadata_from_db(self, session_id: str) -> Dict[str, Any]:
|
||||
"""Load persisted session metadata after a process restart."""
|
||||
conv = self._conversation_store()
|
||||
if conv is None or not hasattr(conv, "get_session"):
|
||||
return {}
|
||||
try:
|
||||
session = conv.get_session(session_id)
|
||||
if session is not None and getattr(session, "metadata", None):
|
||||
return dict(session.metadata)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[db_session_adapter] load metadata failed for %s: %s",
|
||||
session_id,
|
||||
e,
|
||||
)
|
||||
return {}
|
||||
|
||||
def _persist_metadata_to_db(self, session_id: str) -> None:
|
||||
"""Flush in-memory metadata to the conversation session row."""
|
||||
meta = self._metadata.get(session_id)
|
||||
if not meta:
|
||||
return
|
||||
conv = self._conversation_store()
|
||||
if conv is None or not hasattr(conv, "get_session"):
|
||||
return
|
||||
try:
|
||||
from ..persistence.conversation.base import ConversationSession
|
||||
|
||||
session = conv.get_session(session_id)
|
||||
if session is None:
|
||||
conv.create_session(
|
||||
ConversationSession(session_id=session_id, metadata=dict(meta))
|
||||
)
|
||||
else:
|
||||
session.metadata = {**(session.metadata or {}), **meta}
|
||||
conv.update_session(session)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[db_session_adapter] persist metadata failed for %s: %s",
|
||||
session_id,
|
||||
e,
|
||||
)
|
||||
|
||||
def _purge_persisted_messages(self, session_id: str) -> None:
|
||||
"""Remove persisted messages when the session is cleared or deleted."""
|
||||
conv = self._conversation_store()
|
||||
if conv is not None and hasattr(conv, "delete_messages"):
|
||||
try:
|
||||
conv.delete_messages(session_id, None)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[db_session_adapter] delete_messages failed for %s: %s",
|
||||
session_id,
|
||||
e,
|
||||
)
|
||||
|
||||
def _purge_persisted_metadata(self, session_id: str) -> None:
|
||||
"""Remove persisted metadata when the session is deleted."""
|
||||
conv = self._conversation_store()
|
||||
if conv is None:
|
||||
return
|
||||
try:
|
||||
if hasattr(conv, "delete_session"):
|
||||
conv.delete_session(session_id)
|
||||
elif hasattr(conv, "get_session") and hasattr(conv, "update_session"):
|
||||
session = conv.get_session(session_id)
|
||||
if session is not None:
|
||||
session.metadata = {}
|
||||
conv.update_session(session)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"[db_session_adapter] purge metadata failed for %s: %s",
|
||||
session_id,
|
||||
e,
|
||||
)
|
||||
|
||||
def _ensure_session(self, session_id: str, agent_name: str = "ManagedAgent") -> None:
|
||||
"""Ensure session exists in the DB adapter."""
|
||||
if session_id not in self._sessions:
|
||||
skip_history = session_id in self._cleared_sessions
|
||||
if skip_history:
|
||||
self._cleared_sessions.discard(session_id)
|
||||
try:
|
||||
msgs = self._db.on_agent_start(agent_name, session_id)
|
||||
if msgs:
|
||||
if msgs and not skip_history:
|
||||
self._history_cache[session_id] = [
|
||||
{"role": m.role, "content": m.content} for m in msgs
|
||||
]
|
||||
elif skip_history:
|
||||
self._history_cache[session_id] = []
|
||||
if session_id not in self._metadata:
|
||||
loaded = self._load_metadata_from_db(session_id)
|
||||
if loaded:
|
||||
self._metadata[session_id] = loaded
|
||||
self._sessions.add(session_id)
|
||||
except Exception as e:
|
||||
logger.warning("[db_session_adapter] on_agent_start failed: %s", e)
|
||||
@@ -99,14 +205,25 @@ class DbSessionAdapter:
|
||||
|
||||
def clear_session(self, session_id: str) -> bool:
|
||||
"""Clear all messages from a session."""
|
||||
self._history_cache.pop(session_id, None)
|
||||
if session_id in self._sessions:
|
||||
try:
|
||||
self._db.on_agent_end(session_id)
|
||||
except Exception as e:
|
||||
logger.warning("[db_session_adapter] on_agent_end failed: %s", e)
|
||||
self._purge_persisted_messages(session_id)
|
||||
self._history_cache[session_id] = []
|
||||
self._sessions.discard(session_id)
|
||||
self._cleared_sessions.add(session_id)
|
||||
return True
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session completely."""
|
||||
self._purge_persisted_messages(session_id)
|
||||
self._purge_persisted_metadata(session_id)
|
||||
self._history_cache.pop(session_id, None)
|
||||
self._metadata.pop(session_id, None)
|
||||
self._sessions.discard(session_id)
|
||||
self._cleared_sessions.discard(session_id)
|
||||
try:
|
||||
self._db.on_agent_end(session_id)
|
||||
except Exception:
|
||||
@@ -126,10 +243,16 @@ class DbSessionAdapter:
|
||||
if session_id not in self._metadata:
|
||||
self._metadata[session_id] = {}
|
||||
self._metadata[session_id].update(metadata)
|
||||
self._persist_metadata_to_db(session_id)
|
||||
|
||||
def get_metadata(self, session_id: str) -> Dict[str, Any]:
|
||||
"""Retrieve metadata for a session."""
|
||||
return dict(self._metadata.get(session_id, {}))
|
||||
if session_id in self._metadata:
|
||||
return dict(self._metadata[session_id])
|
||||
loaded = self._load_metadata_from_db(session_id)
|
||||
if loaded:
|
||||
self._metadata[session_id] = loaded
|
||||
return dict(loaded)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Session data access (used by DefaultSessionStore-compatible code)
|
||||
|
||||
@@ -486,18 +486,21 @@ class LocalManagedAgent:
|
||||
store.set_metadata(self._session_id, state)
|
||||
return
|
||||
|
||||
# DefaultSessionStore path — write into SessionData.metadata
|
||||
# DefaultSessionStore path — merge metadata under file lock (preserves messages)
|
||||
try:
|
||||
session = store.get_session(self._session_id)
|
||||
if session is not None:
|
||||
if not isinstance(session.metadata, dict):
|
||||
session.metadata = {}
|
||||
session.metadata.update(state)
|
||||
store._save_session(session)
|
||||
if hasattr(store, "update_session_metadata"):
|
||||
store.update_session_metadata(self._session_id, **state)
|
||||
else:
|
||||
from praisonaiagents.session.store import SessionData
|
||||
new_session = SessionData(session_id=self._session_id, metadata=state)
|
||||
store._save_session(new_session)
|
||||
session = store.get_session(self._session_id)
|
||||
if session is None:
|
||||
from praisonaiagents.session.store import SessionData
|
||||
|
||||
store._save_session(SessionData(session_id=self._session_id, metadata=state))
|
||||
else:
|
||||
if not isinstance(session.metadata, dict):
|
||||
session.metadata = {}
|
||||
session.metadata.update(state)
|
||||
store._save_session(session)
|
||||
except Exception as e:
|
||||
logger.debug("[local_managed] _persist_state failed: %s", e)
|
||||
|
||||
|
||||
@@ -0,0 +1,47 @@
|
||||
"""
|
||||
Shared Valkey client factory.
|
||||
|
||||
Centralises GlideClientSync creation so that ValkeyStateStore,
|
||||
ValkeyVectorKnowledgeStore, and ValkeyStorageAdapter all use the
|
||||
same connection logic.
|
||||
"""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
try:
|
||||
from glide_sync import (
|
||||
GlideClient as GlideClientSync,
|
||||
GlideClientConfiguration,
|
||||
NodeAddress,
|
||||
ServerCredentials,
|
||||
)
|
||||
except ImportError:
|
||||
GlideClientSync = None
|
||||
GlideClientConfiguration = NodeAddress = ServerCredentials = None
|
||||
|
||||
_MISSING_MSG = (
|
||||
"valkey-glide-sync is required for Valkey support. "
|
||||
"Install with: pip install 'praisonai[valkey]'"
|
||||
)
|
||||
|
||||
_CLIENT_NAME = "praisonai_persistence_client"
|
||||
|
||||
|
||||
def create_valkey_client(
|
||||
host: str = "localhost",
|
||||
port: int = 6379,
|
||||
password: Optional[str] = None,
|
||||
db: int = 0,
|
||||
):
|
||||
"""Create and return a GlideClientSync instance."""
|
||||
if GlideClientSync is None:
|
||||
raise ImportError(_MISSING_MSG)
|
||||
addresses = [NodeAddress(host, port)]
|
||||
creds = ServerCredentials(password=password) if password else None
|
||||
config = GlideClientConfiguration(
|
||||
addresses=addresses,
|
||||
credentials=creds,
|
||||
database_id=db,
|
||||
client_name=_CLIENT_NAME,
|
||||
)
|
||||
return GlideClientSync.create(config)
|
||||
@@ -0,0 +1,377 @@
|
||||
"""
|
||||
Valkey Vector implementation of KnowledgeStore.
|
||||
|
||||
Requires: valkey-glide-sync
|
||||
Install: pip install 'praisonai[valkey]'
|
||||
"""
|
||||
|
||||
import re
|
||||
import struct
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import KnowledgeStore, KnowledgeDocument, validate_identifier
|
||||
from .._valkey_client import create_valkey_client
|
||||
|
||||
try:
|
||||
from glide_sync import ft
|
||||
from glide_sync import (
|
||||
TextField, NumericField, VectorField, VectorFieldAttributesHnsw,
|
||||
VectorAlgorithm, DistanceMetricType, VectorType,
|
||||
FtCreateOptions, DataType, FtSearchOptions, ReturnField,
|
||||
)
|
||||
from glide_sync import Batch
|
||||
except ImportError:
|
||||
ft = None
|
||||
Batch = None
|
||||
TextField = NumericField = VectorField = VectorFieldAttributesHnsw = None
|
||||
VectorAlgorithm = DistanceMetricType = VectorType = None
|
||||
FtCreateOptions = DataType = FtSearchOptions = ReturnField = None
|
||||
|
||||
|
||||
_SEARCH_SPECIAL_RE = re.compile(r'([,.<>{}\[\]"\'\\:;!@#$%^&*()\-+=~?/| \t])')
|
||||
_FIELD_NAME_RE = re.compile(r'^[a-zA-Z0-9_]+$')
|
||||
|
||||
|
||||
def _escape_search_value(value: str) -> str:
|
||||
return _SEARCH_SPECIAL_RE.sub(r'\\\1', str(value))
|
||||
|
||||
|
||||
def _decode(v) -> str:
|
||||
return v.decode() if isinstance(v, bytes) else v
|
||||
|
||||
|
||||
class ValkeyVectorKnowledgeStore(KnowledgeStore):
|
||||
"""
|
||||
Valkey-based knowledge store using ValkeySearch FT commands for vector search.
|
||||
|
||||
Requires Valkey with ValkeySearch module.
|
||||
|
||||
Example:
|
||||
store = ValkeyVectorKnowledgeStore(
|
||||
host="localhost",
|
||||
port=6379,
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = 6379,
|
||||
password: Optional[str] = None,
|
||||
prefix: str = "praison:vec:",
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.password = password
|
||||
self.prefix = prefix
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
"""Lazy initialize Valkey client."""
|
||||
if self._client is None:
|
||||
self._client = create_valkey_client(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
password=self.password,
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _index_name(self, collection: str) -> str:
|
||||
return f"{self.prefix}{collection}:idx"
|
||||
|
||||
def _doc_key(self, collection: str, doc_id: str) -> str:
|
||||
return f"{self.prefix}{collection}:{doc_id}"
|
||||
|
||||
def create_collection(
|
||||
self,
|
||||
name: str,
|
||||
dimension: int,
|
||||
distance: str = "cosine",
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Create a new index."""
|
||||
validate_identifier(name, "collection name")
|
||||
client = self._get_client()
|
||||
index_name = self._index_name(name)
|
||||
|
||||
try:
|
||||
dist_enum = {"cosine": DistanceMetricType.COSINE, "euclidean": DistanceMetricType.L2, "dot": DistanceMetricType.IP}.get(distance, DistanceMetricType.COSINE)
|
||||
schema = [
|
||||
TextField("content"),
|
||||
TextField("content_hash"),
|
||||
NumericField("created_at"),
|
||||
VectorField("embedding", VectorAlgorithm.HNSW, VectorFieldAttributesHnsw(dimension, dist_enum, VectorType.FLOAT32)),
|
||||
]
|
||||
options = FtCreateOptions(DataType.HASH, prefixes=[f"{self.prefix}{name}:"])
|
||||
ft.create(client, index_name, schema, options)
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
if "already exists" not in err.lower():
|
||||
raise RuntimeError(f"Failed to create collection in Valkey: {e}") from e
|
||||
|
||||
def delete_collection(self, name: str) -> bool:
|
||||
"""Delete an index and all documents."""
|
||||
client = self._get_client()
|
||||
index_name = self._index_name(name)
|
||||
try:
|
||||
ft.dropindex(client, index_name)
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
# Manually remove orphaned document hashes left behind (no DD flag in ValkeySearch)
|
||||
pattern = f"{self.prefix}{name}:*"
|
||||
cursor: bytes = b"0"
|
||||
while True:
|
||||
result = client.scan(cursor, match=pattern, count=100)
|
||||
cursor = result[0]
|
||||
keys = result[1] or []
|
||||
if keys:
|
||||
client.delete(keys)
|
||||
if not cursor or cursor in (b"0", "0", 0):
|
||||
break
|
||||
|
||||
return True
|
||||
|
||||
def collection_exists(self, name: str) -> bool:
|
||||
"""Check if an index exists."""
|
||||
client = self._get_client()
|
||||
index_name = self._index_name(name)
|
||||
try:
|
||||
ft.info(client, index_name)
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def list_collections(self) -> List[str]:
|
||||
"""List all collections."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
indexes = ft.list(client)
|
||||
if not indexes:
|
||||
return []
|
||||
result = []
|
||||
for idx in indexes:
|
||||
idx_str = _decode(idx)
|
||||
if idx_str.startswith(self.prefix):
|
||||
name = idx_str[len(self.prefix):]
|
||||
if name.endswith(":idx"):
|
||||
name = name[:-4]
|
||||
result.append(name)
|
||||
return result
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
def insert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument],
|
||||
) -> List[str]:
|
||||
"""Insert documents using a non-atomic batch (pipeline)."""
|
||||
client = self._get_client()
|
||||
ids = []
|
||||
batch = Batch(is_atomic=False)
|
||||
|
||||
for doc in documents:
|
||||
if doc.embedding is None:
|
||||
raise ValueError(f"Document {doc.id} has no embedding")
|
||||
|
||||
key = self._doc_key(collection, doc.id)
|
||||
embedding_bytes = struct.pack(f"{len(doc.embedding)}f", *doc.embedding)
|
||||
|
||||
batch.hset(key, {
|
||||
"content": doc.content,
|
||||
"content_hash": doc.content_hash or "",
|
||||
"created_at": str(doc.created_at),
|
||||
"embedding": embedding_bytes,
|
||||
})
|
||||
ids.append(doc.id)
|
||||
|
||||
try:
|
||||
client.exec(batch, raise_on_error=True)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to insert documents into Valkey: {e}") from e
|
||||
|
||||
return ids
|
||||
|
||||
def upsert(
|
||||
self,
|
||||
collection: str,
|
||||
documents: List[KnowledgeDocument],
|
||||
) -> List[str]:
|
||||
"""Insert or update documents."""
|
||||
return self.insert(collection, documents)
|
||||
|
||||
def search(
|
||||
self,
|
||||
collection: str,
|
||||
query_embedding: List[float],
|
||||
limit: int = 5,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
score_threshold: Optional[float] = None,
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Search for similar documents.
|
||||
|
||||
Args:
|
||||
collection: Collection name.
|
||||
query_embedding: Query vector.
|
||||
limit: Max results.
|
||||
filters: Optional dict of field-value equality filters applied as
|
||||
a ValkeySearch pre-filter (e.g. {"content_hash": "abc"}).
|
||||
score_threshold: Minimum similarity (1 - distance) to include.
|
||||
"""
|
||||
client = self._get_client()
|
||||
index_name = self._index_name(collection)
|
||||
packed = struct.pack(f"{len(query_embedding)}f", *query_embedding)
|
||||
|
||||
# Build pre-filter expression
|
||||
if filters:
|
||||
filter_parts = []
|
||||
for field, value in filters.items():
|
||||
if not _FIELD_NAME_RE.match(field):
|
||||
raise ValueError(f"Invalid filter field name: {field!r}")
|
||||
if not isinstance(value, str):
|
||||
raise TypeError(
|
||||
f"Filter value for {field!r} must be a string, got {type(value).__name__}"
|
||||
)
|
||||
escaped = _escape_search_value(value)
|
||||
filter_parts.append(f"@{field}:({escaped})")
|
||||
pre_filter = " ".join(filter_parts)
|
||||
query = f"({pre_filter})=>[KNN {limit} @embedding $vec AS score]"
|
||||
else:
|
||||
query = f"*=>[KNN {limit} @embedding $vec AS score]"
|
||||
|
||||
try:
|
||||
options = FtSearchOptions(
|
||||
return_fields=[
|
||||
ReturnField("content"),
|
||||
ReturnField("content_hash"),
|
||||
ReturnField("created_at"),
|
||||
ReturnField("score"),
|
||||
],
|
||||
params={"vec": packed},
|
||||
)
|
||||
raw = ft.search(client, index_name, query, options)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to search Valkey: {e}") from e
|
||||
|
||||
# ft.search returns [count, {doc_id: {field: value, ...}, ...}]
|
||||
if not raw or len(raw) < 2:
|
||||
return []
|
||||
|
||||
documents = []
|
||||
key_prefix = f"{self.prefix}{collection}:"
|
||||
for doc_id_raw, fields in raw[1].items():
|
||||
doc_id_str = _decode(doc_id_raw)
|
||||
bare_id = doc_id_str.removeprefix(key_prefix)
|
||||
|
||||
field_map: Dict[str, str] = {}
|
||||
for fname, fval in fields.items():
|
||||
fname = _decode(fname)
|
||||
fval = _decode(fval)
|
||||
field_map[fname] = fval
|
||||
|
||||
score = float(field_map.get("score", 0))
|
||||
similarity = 1 - score
|
||||
|
||||
if score_threshold is not None and similarity < score_threshold:
|
||||
continue
|
||||
|
||||
documents.append(KnowledgeDocument(
|
||||
id=bare_id,
|
||||
content=field_map.get("content", ""),
|
||||
embedding=None,
|
||||
metadata={},
|
||||
content_hash=field_map.get("content_hash", ""),
|
||||
created_at=float(field_map.get("created_at", 0)),
|
||||
))
|
||||
|
||||
return documents
|
||||
|
||||
def get(
|
||||
self,
|
||||
collection: str,
|
||||
ids: List[str],
|
||||
) -> List[KnowledgeDocument]:
|
||||
"""Get documents by IDs using a non-atomic batch (pipeline)."""
|
||||
client = self._get_client()
|
||||
keys = [self._doc_key(collection, doc_id) for doc_id in ids]
|
||||
|
||||
batch = Batch(is_atomic=False)
|
||||
for key in keys:
|
||||
batch.hgetall(key)
|
||||
|
||||
try:
|
||||
results = client.exec(batch, raise_on_error=True)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to get documents from Valkey: {e}") from e
|
||||
|
||||
documents = []
|
||||
for doc_id, data in zip(ids, results):
|
||||
if data:
|
||||
embedding = None
|
||||
emb_key = b"embedding" if b"embedding" in data else "embedding"
|
||||
if emb_key in data:
|
||||
raw_bytes = data[emb_key]
|
||||
count = len(raw_bytes) // 4
|
||||
embedding = list(struct.unpack(f"{count}f", raw_bytes))
|
||||
|
||||
content = _decode(data.get(b"content", data.get("content", b"")))
|
||||
content_hash = _decode(data.get(b"content_hash", data.get("content_hash", b"")))
|
||||
created_at_raw = data.get(b"created_at", data.get("created_at", b"0"))
|
||||
created_at = float(_decode(created_at_raw))
|
||||
|
||||
doc = KnowledgeDocument(
|
||||
id=doc_id,
|
||||
content=content,
|
||||
embedding=embedding,
|
||||
metadata={},
|
||||
content_hash=content_hash,
|
||||
created_at=created_at,
|
||||
)
|
||||
documents.append(doc)
|
||||
|
||||
return documents
|
||||
|
||||
def delete(
|
||||
self,
|
||||
collection: str,
|
||||
ids: Optional[List[str]] = None,
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> int:
|
||||
"""Delete documents by IDs. Filter-based deletion is not supported."""
|
||||
if filters and not ids:
|
||||
raise NotImplementedError("Filter-based deletion is not supported by the Valkey backend")
|
||||
if not ids:
|
||||
return 0
|
||||
|
||||
client = self._get_client()
|
||||
keys = [self._doc_key(collection, doc_id) for doc_id in ids]
|
||||
|
||||
try:
|
||||
deleted = client.delete(keys)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to delete documents from Valkey: {e}") from e
|
||||
|
||||
return deleted
|
||||
|
||||
def count(self, collection: str) -> int:
|
||||
"""Count documents in a collection."""
|
||||
client = self._get_client()
|
||||
index_name = self._index_name(collection)
|
||||
try:
|
||||
info = ft.info(client, index_name)
|
||||
# ft.info returns a Mapping; num_docs may be bytes key
|
||||
for k in (b"num_docs", "num_docs"):
|
||||
if k in info:
|
||||
return int(info[k])
|
||||
return 0
|
||||
except Exception:
|
||||
return 0
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
finally:
|
||||
self._client = None
|
||||
@@ -198,7 +198,13 @@ def _register_builtin_knowledge_stores(registry: StoreRegistry):
|
||||
def _redis_vector(url=None, **kwargs):
|
||||
from .knowledge.redis_vector import RedisVectorKnowledgeStore
|
||||
return RedisVectorKnowledgeStore(url=url, **kwargs)
|
||||
|
||||
|
||||
def _valkey_vector(url=None, **kwargs):
|
||||
from .knowledge.valkey_vector import ValkeyVectorKnowledgeStore
|
||||
# url not used; host/port come from kwargs
|
||||
kwargs.pop("url", None)
|
||||
return ValkeyVectorKnowledgeStore(**kwargs)
|
||||
|
||||
def _cassandra(url=None, **kwargs):
|
||||
kwargs.pop("url", None) # Cassandra doesn't use url parameter
|
||||
from .knowledge.cassandra import CassandraKnowledgeStore
|
||||
@@ -258,9 +264,10 @@ def _register_builtin_knowledge_stores(registry: StoreRegistry):
|
||||
registry.register("milvus", _milvus)
|
||||
registry.register("pgvector", _pgvector)
|
||||
registry.register("redis", _redis_vector)
|
||||
registry.register("valkey", _valkey_vector)
|
||||
registry.register("cassandra", _cassandra)
|
||||
registry.register("clickhouse", _clickhouse)
|
||||
registry.register("mongodb_vector", _mongodb_vector,
|
||||
registry.register("mongodb_vector", _mongodb_vector,
|
||||
aliases=("mongodb_atlas", "mongo_vector"))
|
||||
|
||||
# Register missing backends that were supported in old factory
|
||||
@@ -286,7 +293,12 @@ def _register_builtin_state_stores(registry: StoreRegistry):
|
||||
def _redis(url=None, **kwargs):
|
||||
from .state.redis import RedisStateStore
|
||||
return RedisStateStore(url=url, **kwargs)
|
||||
|
||||
|
||||
def _valkey(url=None, **kwargs):
|
||||
from .state.valkey import ValkeyStateStore
|
||||
kwargs.pop("url", None)
|
||||
return ValkeyStateStore(**kwargs)
|
||||
|
||||
def _dynamodb(url=None, **kwargs):
|
||||
kwargs.pop("url", None) # DynamoDB doesn't use url parameter
|
||||
from .state.dynamodb import DynamoDBStateStore
|
||||
@@ -322,10 +334,11 @@ def _register_builtin_state_stores(registry: StoreRegistry):
|
||||
return GCSStateStore(bucket_name=bucket, **kwargs)
|
||||
|
||||
registry.register("redis", _redis)
|
||||
registry.register("valkey", _valkey)
|
||||
registry.register("dynamodb", _dynamodb)
|
||||
registry.register("firestore", _firestore)
|
||||
registry.register("mongodb", _mongodb)
|
||||
registry.register("async_mongodb", _async_mongodb,
|
||||
registry.register("async_mongodb", _async_mongodb,
|
||||
aliases=("motor", "mongodb_async"))
|
||||
registry.register("upstash", _upstash)
|
||||
registry.register("memory", _memory)
|
||||
|
||||
@@ -0,0 +1,230 @@
|
||||
"""
|
||||
Valkey implementation of StateStore.
|
||||
|
||||
Requires: valkey-glide-sync
|
||||
Install: pip install 'praisonai[valkey]'
|
||||
"""
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from .base import StateStore
|
||||
from .._valkey_client import create_valkey_client
|
||||
|
||||
try:
|
||||
from glide_sync import ExpirySet, ExpiryType
|
||||
except ImportError:
|
||||
ExpirySet = None
|
||||
ExpiryType = None
|
||||
|
||||
|
||||
class ValkeyStateStore(StateStore):
|
||||
"""
|
||||
Valkey-based state store for fast key-value operations.
|
||||
|
||||
Example:
|
||||
store = ValkeyStateStore(
|
||||
host="localhost",
|
||||
port=6379,
|
||||
)
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = 6379,
|
||||
password: Optional[str] = None,
|
||||
prefix: str = "praison:",
|
||||
db: int = 0,
|
||||
):
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.password = password
|
||||
self.prefix = prefix
|
||||
self.db = db
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
"""Lazy initialize Valkey client."""
|
||||
if self._client is None:
|
||||
self._client = create_valkey_client(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
password=self.password,
|
||||
db=self.db,
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _key(self, key: str) -> str:
|
||||
"""Add prefix to key."""
|
||||
return f"{self.prefix}{key}"
|
||||
|
||||
def get(self, key: str) -> Optional[Any]:
|
||||
"""Get a value by key."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
value = client.get(self._key(key))
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to get key from Valkey: {e}") from e
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
def set(self, key: str, value: Any, ttl: Optional[int] = None) -> None:
|
||||
"""Set a value with optional TTL."""
|
||||
client = self._get_client()
|
||||
if not isinstance(value, str):
|
||||
value = json.dumps(value)
|
||||
|
||||
try:
|
||||
if ttl is not None:
|
||||
client.set(self._key(key), value, expiry=ExpirySet(ExpiryType.SEC, ttl))
|
||||
else:
|
||||
client.set(self._key(key), value)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to set key in Valkey: {e}") from e
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete a key."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
return client.delete([self._key(key)]) > 0
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to delete key from Valkey: {e}") from e
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
return client.exists([self._key(key)]) > 0
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to check key existence in Valkey: {e}") from e
|
||||
|
||||
def keys(self, pattern: str = "*") -> List[str]:
|
||||
"""List keys matching pattern."""
|
||||
client = self._get_client()
|
||||
full_pattern = self._key(pattern)
|
||||
try:
|
||||
cursor: bytes = b"0"
|
||||
all_keys = []
|
||||
while True:
|
||||
result = client.scan(cursor, match=full_pattern, count=100)
|
||||
cursor = result[0]
|
||||
all_keys.extend(result[1] or [])
|
||||
if not cursor or cursor in (b"0", "0"):
|
||||
break
|
||||
prefix_len = len(self.prefix)
|
||||
result_keys = []
|
||||
for k in all_keys:
|
||||
k_str = k.decode() if isinstance(k, bytes) else k
|
||||
result_keys.append(k_str[prefix_len:] if k_str.startswith(self.prefix) else k_str)
|
||||
return result_keys
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to list keys from Valkey: {e}") from e
|
||||
|
||||
def ttl(self, key: str) -> Optional[int]:
|
||||
"""Get remaining TTL in seconds."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
result = client.ttl(self._key(key))
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to get TTL from Valkey: {e}") from e
|
||||
|
||||
if result is None or result < 0:
|
||||
return None
|
||||
return int(result)
|
||||
|
||||
def expire(self, key: str, ttl: int) -> bool:
|
||||
"""Set TTL on existing key."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
return bool(client.expire(self._key(key), ttl))
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to set expire in Valkey: {e}") from e
|
||||
|
||||
def hget(self, key: str, field: str) -> Optional[Any]:
|
||||
"""Get a field from a hash."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
value = client.hget(self._key(key), field)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to hget from Valkey: {e}") from e
|
||||
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode()
|
||||
try:
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError:
|
||||
return value
|
||||
|
||||
def hset(self, key: str, field: str, value: Any) -> None:
|
||||
"""Set a field in a hash."""
|
||||
client = self._get_client()
|
||||
if not isinstance(value, str):
|
||||
value = json.dumps(value)
|
||||
try:
|
||||
client.hset(self._key(key), {field: value})
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to hset in Valkey: {e}") from e
|
||||
|
||||
def hgetall(self, key: str) -> Dict[str, Any]:
|
||||
"""Get all fields from a hash."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
data = client.hgetall(self._key(key))
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to hgetall from Valkey: {e}") from e
|
||||
|
||||
result = {}
|
||||
if not data:
|
||||
return result
|
||||
for k, v in data.items():
|
||||
k_str = k.decode() if isinstance(k, bytes) else k
|
||||
v_str = v.decode() if isinstance(v, bytes) else v
|
||||
try:
|
||||
result[k_str] = json.loads(v_str)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
result[k_str] = v_str
|
||||
return result
|
||||
|
||||
def hdel(self, key: str, *fields: str) -> int:
|
||||
"""Delete fields from a hash."""
|
||||
if not fields:
|
||||
return 0
|
||||
client = self._get_client()
|
||||
try:
|
||||
return client.hdel(self._key(key), list(fields))
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to hdel from Valkey: {e}") from e
|
||||
|
||||
def incr(self, key: str, amount: int = 1) -> int:
|
||||
"""Increment a counter."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
return client.incrby(self._key(key), amount)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to increment key in Valkey: {e}") from e
|
||||
|
||||
def decr(self, key: str, amount: int = 1) -> int:
|
||||
"""Decrement a counter."""
|
||||
client = self._get_client()
|
||||
try:
|
||||
return client.decrby(self._key(key), amount)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to decrement key in Valkey: {e}") from e
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the store."""
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
finally:
|
||||
self._client = None
|
||||
@@ -0,0 +1,492 @@
|
||||
"""
|
||||
Tests for ValkeyVectorKnowledgeStore, ValkeyStateStore, and registry integration.
|
||||
|
||||
Tests cover:
|
||||
- ValkeyVectorKnowledgeStore collection and document operations (mocked glide client)
|
||||
- ValkeyStateStore key-value operations (mocked glide client)
|
||||
- KNOWLEDGE_STORES and STATE_STORES registry entries for "valkey"
|
||||
"""
|
||||
|
||||
import json
|
||||
import struct
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from praisonai.persistence.knowledge.base import KnowledgeDocument
|
||||
from praisonai.persistence.registry import KNOWLEDGE_STORES, STATE_STORES
|
||||
|
||||
|
||||
def _make_knowledge_store():
|
||||
"""Return a ValkeyVectorKnowledgeStore with a pre-injected MagicMock client."""
|
||||
from praisonai.persistence.knowledge.valkey_vector import ValkeyVectorKnowledgeStore
|
||||
|
||||
store = ValkeyVectorKnowledgeStore.__new__(ValkeyVectorKnowledgeStore)
|
||||
store.host = "localhost"
|
||||
store.port = 6379
|
||||
store.password = None
|
||||
store.prefix = "praison:vec:"
|
||||
mock_client = MagicMock()
|
||||
store._client = mock_client
|
||||
store._get_client = lambda: mock_client
|
||||
|
||||
return store, mock_client
|
||||
|
||||
|
||||
def _make_state_store():
|
||||
"""Return a ValkeyStateStore with a pre-injected MagicMock client."""
|
||||
from praisonai.persistence.state.valkey import ValkeyStateStore
|
||||
|
||||
store = ValkeyStateStore.__new__(ValkeyStateStore)
|
||||
store.host = "localhost"
|
||||
store.port = 6379
|
||||
store.password = None
|
||||
store.prefix = "praison:"
|
||||
store.db = 0
|
||||
mock_client = MagicMock()
|
||||
store._client = mock_client
|
||||
store._get_client = lambda: mock_client
|
||||
|
||||
return store, mock_client
|
||||
|
||||
|
||||
class TestValkeyVectorKnowledgeStore:
|
||||
"""Tests for ValkeyVectorKnowledgeStore."""
|
||||
|
||||
_FT_MODULE = "praisonai.persistence.knowledge.valkey_vector.ft"
|
||||
|
||||
def _mock_ft(self, **method_returns):
|
||||
"""Return a mock ft module with configured method returns."""
|
||||
mock_ft = MagicMock()
|
||||
for name, val in method_returns.items():
|
||||
if isinstance(val, Exception):
|
||||
getattr(mock_ft, name).side_effect = val
|
||||
else:
|
||||
getattr(mock_ft, name).return_value = val
|
||||
return mock_ft
|
||||
|
||||
def test_create_collection(self):
|
||||
"""Test create_collection calls ft.create with HNSW schema."""
|
||||
store, _ = _make_knowledge_store()
|
||||
mock_ft = self._mock_ft(create=b"OK")
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
store.create_collection("docs", dimension=4)
|
||||
|
||||
mock_ft.create.assert_called_once()
|
||||
assert mock_ft.create.call_args[0][1] == "praison:vec:docs:idx"
|
||||
|
||||
def test_create_collection_already_exists(self):
|
||||
"""Test create_collection silently ignores 'already exists' error."""
|
||||
store, _ = _make_knowledge_store()
|
||||
mock_ft = self._mock_ft(create=Exception("already exists"))
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
store.create_collection("docs", dimension=4) # should not raise
|
||||
|
||||
def test_delete_collection(self):
|
||||
"""Test delete_collection calls ft.dropindex and returns True on success."""
|
||||
store, mock_client = _make_knowledge_store()
|
||||
mock_ft = self._mock_ft(dropindex=b"OK")
|
||||
mock_client.scan.return_value = [b"0", []]
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
result = store.delete_collection("docs")
|
||||
|
||||
assert result is True
|
||||
mock_ft.dropindex.assert_called_once()
|
||||
|
||||
def test_collection_exists_true(self):
|
||||
"""Test collection_exists returns True when ft.info succeeds."""
|
||||
store, _ = _make_knowledge_store()
|
||||
mock_ft = self._mock_ft(info={b"index_name": b"praison:vec:docs:idx"})
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
result = store.collection_exists("docs")
|
||||
|
||||
assert result is True
|
||||
|
||||
def test_collection_exists_false(self):
|
||||
"""Test collection_exists returns False when ft.info raises an exception."""
|
||||
store, _ = _make_knowledge_store()
|
||||
mock_ft = self._mock_ft(info=Exception("Unknown index name"))
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
result = store.collection_exists("docs")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_list_collections(self):
|
||||
"""Test list_collections returns only names matching the prefix, stripped."""
|
||||
store, _ = _make_knowledge_store()
|
||||
mock_ft = self._mock_ft(list=[b"praison:vec:docs:idx", b"other:idx"])
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
result = store.list_collections()
|
||||
|
||||
assert "docs" in result
|
||||
assert all("other" not in name for name in result)
|
||||
|
||||
def test_insert(self):
|
||||
"""Test insert uses Batch pipeline and calls client.exec."""
|
||||
store, mock_client = _make_knowledge_store()
|
||||
mock_client.exec.return_value = [4]
|
||||
|
||||
embedding = [0.1, 0.2, 0.3, 0.4]
|
||||
doc = KnowledgeDocument(id="doc1", content="hello", embedding=embedding)
|
||||
ids = store.insert("docs", [doc])
|
||||
|
||||
mock_client.exec.assert_called_once()
|
||||
assert ids == ["doc1"]
|
||||
|
||||
def test_upsert_delegates_to_insert(self):
|
||||
"""Test upsert calls insert (same path)."""
|
||||
store, mock_client = _make_knowledge_store()
|
||||
mock_client.exec.return_value = [4]
|
||||
|
||||
doc = KnowledgeDocument(id="doc1", content="hello", embedding=[0.1, 0.2, 0.3, 0.4])
|
||||
store.upsert("docs", [doc])
|
||||
|
||||
mock_client.exec.assert_called_once()
|
||||
|
||||
def test_search(self):
|
||||
"""Test search calls ft.search and returns a list of KnowledgeDocument."""
|
||||
store, _ = _make_knowledge_store()
|
||||
ft_result = [1, {
|
||||
b"praison:vec:docs:doc1": {
|
||||
b"content": b"hello",
|
||||
b"content_hash": b"abc",
|
||||
b"created_at": b"1234.0",
|
||||
b"score": b"0.1",
|
||||
}
|
||||
}]
|
||||
mock_ft = self._mock_ft(search=ft_result)
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
results = store.search("docs", query_embedding=[0.1, 0.2, 0.3, 0.4], limit=5)
|
||||
|
||||
assert isinstance(results, list)
|
||||
assert len(results) == 1
|
||||
assert isinstance(results[0], KnowledgeDocument)
|
||||
assert results[0].id == "doc1"
|
||||
mock_ft.search.assert_called_once()
|
||||
|
||||
def test_search_with_filters(self):
|
||||
"""Test search with filters builds a pre-filter KNN query."""
|
||||
store, _ = _make_knowledge_store()
|
||||
ft_result = [1, {
|
||||
b"praison:vec:docs:doc1": {
|
||||
b"content": b"hello",
|
||||
b"content_hash": b"abc",
|
||||
b"created_at": b"1234.0",
|
||||
b"score": b"0.1",
|
||||
}
|
||||
}]
|
||||
mock_ft = self._mock_ft(search=ft_result)
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
results = store.search(
|
||||
"docs",
|
||||
query_embedding=[0.1, 0.2, 0.3, 0.4],
|
||||
limit=5,
|
||||
filters={"content_hash": "abc"},
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
query_arg = mock_ft.search.call_args[0][2]
|
||||
assert "KNN" in query_arg
|
||||
assert query_arg.startswith("(")
|
||||
|
||||
def test_search_score_threshold_excludes_low_similarity(self):
|
||||
"""Test search excludes documents whose similarity is below score_threshold."""
|
||||
store, _ = _make_knowledge_store()
|
||||
ft_result = [2, {
|
||||
b"praison:vec:docs:doc1": {
|
||||
b"content": b"close",
|
||||
b"content_hash": b"",
|
||||
b"created_at": b"1.0",
|
||||
b"score": b"0.05", # similarity = 0.95 — above threshold
|
||||
},
|
||||
b"praison:vec:docs:doc2": {
|
||||
b"content": b"far",
|
||||
b"content_hash": b"",
|
||||
b"created_at": b"2.0",
|
||||
b"score": b"0.8", # similarity = 0.2 — below threshold
|
||||
},
|
||||
}]
|
||||
mock_ft = self._mock_ft(search=ft_result)
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
results = store.search(
|
||||
"docs",
|
||||
query_embedding=[0.1, 0.2, 0.3, 0.4],
|
||||
limit=5,
|
||||
score_threshold=0.5,
|
||||
)
|
||||
|
||||
assert len(results) == 1
|
||||
assert results[0].id == "doc1"
|
||||
|
||||
def test_search_empty_result(self):
|
||||
"""Test search returns an empty list when ft.search returns no documents."""
|
||||
store, _ = _make_knowledge_store()
|
||||
mock_ft = self._mock_ft(search=[])
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
results = store.search("docs", query_embedding=[0.1, 0.2, 0.3, 0.4])
|
||||
|
||||
assert results == []
|
||||
|
||||
def test_get(self):
|
||||
"""Test get uses Batch pipeline and returns a KnowledgeDocument."""
|
||||
store, mock_client = _make_knowledge_store()
|
||||
embedding = [0.1, 0.2, 0.3, 0.4]
|
||||
embedding_bytes = struct.pack(f"{len(embedding)}f", *embedding)
|
||||
mock_client.exec.return_value = [{
|
||||
b"content": b"hello",
|
||||
b"content_hash": b"abc",
|
||||
b"created_at": b"1234.0",
|
||||
b"embedding": embedding_bytes,
|
||||
}]
|
||||
|
||||
results = store.get("docs", ["doc1"])
|
||||
|
||||
assert len(results) == 1
|
||||
assert isinstance(results[0], KnowledgeDocument)
|
||||
assert results[0].id == "doc1"
|
||||
mock_client.exec.assert_called_once()
|
||||
|
||||
def test_delete(self):
|
||||
"""Test delete calls client.delete and returns count deleted."""
|
||||
store, mock_client = _make_knowledge_store()
|
||||
mock_client.delete.return_value = 2
|
||||
|
||||
result = store.delete("docs", ids=["doc1", "doc2"])
|
||||
|
||||
assert result == 2
|
||||
|
||||
def test_count(self):
|
||||
"""Test count calls ft.info and returns integer document count."""
|
||||
store, _ = _make_knowledge_store()
|
||||
mock_ft = self._mock_ft(info={b"num_docs": 7, b"index_name": b"praison:vec:docs:idx"})
|
||||
|
||||
with patch(self._FT_MODULE, mock_ft):
|
||||
result = store.count("docs")
|
||||
|
||||
assert isinstance(result, int)
|
||||
assert result == 7
|
||||
|
||||
def test_close_closes_client(self):
|
||||
"""Test close() calls client.close() and clears the reference."""
|
||||
store, mock_client = _make_knowledge_store()
|
||||
|
||||
store.close()
|
||||
|
||||
mock_client.close.assert_called_once()
|
||||
assert store._client is None
|
||||
|
||||
|
||||
class TestValkeyStateStore:
|
||||
"""Tests for ValkeyStateStore."""
|
||||
|
||||
def test_get_existing(self):
|
||||
"""Test get returns a dict when client.get returns JSON bytes."""
|
||||
store, mock_client = _make_state_store()
|
||||
data = {"result": "ok"}
|
||||
mock_client.get.return_value = json.dumps(data).encode("utf-8")
|
||||
|
||||
result = store.get("mykey")
|
||||
|
||||
mock_client.get.assert_called_once_with("praison:mykey")
|
||||
assert result == data
|
||||
|
||||
def test_get_missing(self):
|
||||
"""Test get returns None when client.get returns None."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.get.return_value = None
|
||||
|
||||
result = store.get("missing")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_set_no_ttl(self):
|
||||
"""Test set calls client.set with prefixed key when no TTL is given."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.set.return_value = b"OK"
|
||||
|
||||
store.set("mykey", {"x": 1})
|
||||
|
||||
mock_client.set.assert_called_once()
|
||||
call_args = mock_client.set.call_args[0]
|
||||
assert call_args[0] == "praison:mykey"
|
||||
|
||||
def test_set_with_ttl(self):
|
||||
"""Test set with TTL passes SetOptions to client.set (ImportError/RuntimeError tolerated)."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.set.return_value = b"OK"
|
||||
|
||||
# glide's ExpirySet/ExpiryType are lazy-imported inside set(); without a live
|
||||
# glide install this raises ImportError — both paths are valid in CI.
|
||||
try:
|
||||
store.set("ttlkey", {"x": 1}, ttl=300)
|
||||
except (ImportError, RuntimeError):
|
||||
pass
|
||||
|
||||
def test_delete_existing(self):
|
||||
"""Test delete returns True when client.delete returns 1."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.delete.return_value = 1
|
||||
|
||||
result = store.delete("mykey")
|
||||
|
||||
mock_client.delete.assert_called_once_with(["praison:mykey"])
|
||||
assert result is True
|
||||
|
||||
def test_delete_missing(self):
|
||||
"""Test delete returns False when client.delete returns 0."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.delete.return_value = 0
|
||||
|
||||
result = store.delete("absent")
|
||||
|
||||
assert result is False
|
||||
|
||||
def test_exists_true(self):
|
||||
"""Test exists returns True when client.exists returns 1."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.exists.return_value = 1
|
||||
|
||||
assert store.exists("mykey") is True
|
||||
mock_client.exists.assert_called_once_with(["praison:mykey"])
|
||||
|
||||
def test_exists_false(self):
|
||||
"""Test exists returns False when client.exists returns 0."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.exists.return_value = 0
|
||||
|
||||
assert store.exists("absent") is False
|
||||
|
||||
def test_keys(self):
|
||||
"""Test keys returns stripped key names via client.scan."""
|
||||
store, mock_client = _make_state_store()
|
||||
# scan() returns [cursor, [keys]]; cursor b"0" signals end of iteration
|
||||
mock_client.scan.return_value = [b"0", [b"praison:key1", b"praison:key2"]]
|
||||
|
||||
result = store.keys()
|
||||
|
||||
mock_client.scan.assert_called_once()
|
||||
assert result == ["key1", "key2"]
|
||||
|
||||
def test_ttl_with_value(self):
|
||||
"""Test ttl returns seconds remaining when client.ttl returns a positive value."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.ttl.return_value = 30
|
||||
|
||||
result = store.ttl("mykey")
|
||||
|
||||
assert result == 30
|
||||
|
||||
def test_ttl_no_ttl(self):
|
||||
"""Test ttl returns None when client.ttl returns -1 (no expiry)."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.ttl.return_value = -1
|
||||
|
||||
result = store.ttl("mykey")
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_expire(self):
|
||||
"""Test expire calls client.expire and returns its result."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.expire.return_value = True
|
||||
|
||||
result = store.expire("mykey", 600)
|
||||
|
||||
mock_client.expire.assert_called_once_with("praison:mykey", 600)
|
||||
assert result is True
|
||||
|
||||
def test_hget(self):
|
||||
"""Test hget returns a parsed dict when client.hget returns JSON bytes."""
|
||||
store, mock_client = _make_state_store()
|
||||
data = {"field_val": 42}
|
||||
mock_client.hget.return_value = json.dumps(data).encode("utf-8")
|
||||
|
||||
result = store.hget("myhash", "field1")
|
||||
|
||||
mock_client.hget.assert_called_once_with("praison:myhash", "field1")
|
||||
assert result == data
|
||||
|
||||
def test_hset(self):
|
||||
"""Test hset calls client.hset with the prefixed key and a mapping dict."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.hset.return_value = 1
|
||||
|
||||
store.hset("myhash", "field1", "value1")
|
||||
|
||||
mock_client.hset.assert_called_once()
|
||||
call_args = mock_client.hset.call_args[0]
|
||||
assert call_args[0] == "praison:myhash"
|
||||
assert isinstance(call_args[1], dict)
|
||||
assert "field1" in call_args[1]
|
||||
|
||||
def test_hgetall(self):
|
||||
"""Test hgetall returns a decoded dict when client.hgetall returns bytes."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.hgetall.return_value = {
|
||||
b"field1": b'"value1"',
|
||||
b"field2": b"42",
|
||||
}
|
||||
|
||||
result = store.hgetall("myhash")
|
||||
|
||||
mock_client.hgetall.assert_called_once_with("praison:myhash")
|
||||
assert "field1" in result
|
||||
assert "field2" in result
|
||||
|
||||
def test_hdel(self):
|
||||
"""Test hdel calls client.hdel and returns the count of deleted fields."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.hdel.return_value = 2
|
||||
|
||||
result = store.hdel("myhash", "field1", "field2")
|
||||
|
||||
assert result == 2
|
||||
|
||||
def test_incr(self):
|
||||
"""Test incr calls client.incrby and returns the new value."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.incrby.return_value = 5
|
||||
|
||||
result = store.incr("counter")
|
||||
|
||||
assert result == 5
|
||||
mock_client.incrby.assert_called_once_with("praison:counter", 1)
|
||||
|
||||
def test_decr(self):
|
||||
"""Test decr calls client.decrby and returns the new value."""
|
||||
store, mock_client = _make_state_store()
|
||||
mock_client.decrby.return_value = 3
|
||||
|
||||
result = store.decr("counter")
|
||||
|
||||
assert result == 3
|
||||
mock_client.decrby.assert_called_once_with("praison:counter", 1)
|
||||
|
||||
def test_close_closes_client(self):
|
||||
"""Test close() calls client.close() and clears the reference."""
|
||||
store, mock_client = _make_state_store()
|
||||
|
||||
store.close()
|
||||
|
||||
mock_client.close.assert_called_once()
|
||||
assert store._client is None
|
||||
|
||||
|
||||
class TestRegistryValkey:
|
||||
"""Tests for Valkey backend registration in store registries."""
|
||||
|
||||
def test_knowledge_store_valkey_registered(self):
|
||||
"""Test KNOWLEDGE_STORES registry contains 'valkey'."""
|
||||
assert "valkey" in KNOWLEDGE_STORES.list_registered()
|
||||
|
||||
def test_state_store_valkey_registered(self):
|
||||
"""Test STATE_STORES registry contains 'valkey'."""
|
||||
assert "valkey" in STATE_STORES.list_registered()
|
||||
@@ -256,8 +256,11 @@ class DockerSandbox:
|
||||
cmd_parts = shlex.split(command)
|
||||
cmd_str = " ".join(shlex.quote(part) for part in cmd_parts)
|
||||
|
||||
# Generate container name for timeout cleanup
|
||||
container_name = f"praisonai-{execution_id}"
|
||||
|
||||
docker_cmd = [
|
||||
"docker", "run", "--rm",
|
||||
"docker", "run", "--rm", "--name", container_name,
|
||||
"--memory", f"{limits.memory_mb}m",
|
||||
"--cpus", str(limits.cpu_percent / 100),
|
||||
]
|
||||
@@ -302,6 +305,17 @@ class DockerSandbox:
|
||||
completed_at=completed_at,
|
||||
)
|
||||
except asyncio.TimeoutError:
|
||||
# Kill the container, not just the client
|
||||
try:
|
||||
kill_proc = await asyncio.create_subprocess_exec(
|
||||
"docker", "kill", container_name,
|
||||
stdout=asyncio.subprocess.DEVNULL,
|
||||
stderr=asyncio.subprocess.DEVNULL
|
||||
)
|
||||
await kill_proc.wait()
|
||||
except Exception:
|
||||
pass # Container may already be gone
|
||||
|
||||
proc.kill()
|
||||
await proc.wait()
|
||||
|
||||
|
||||
@@ -165,10 +165,10 @@ class SSHSandbox:
|
||||
execution_id = str(uuid.uuid4())
|
||||
started_at = time.time()
|
||||
|
||||
# Create temporary file for code
|
||||
remote_file = f"{self.working_dir}/exec_{execution_id}.{self._get_file_extension(language)}"
|
||||
|
||||
try:
|
||||
# Create temporary file for code
|
||||
remote_file = f"{self.working_dir}/exec_{execution_id}.{self._get_file_extension(language)}"
|
||||
|
||||
# Write code to remote file
|
||||
await self.write_file(remote_file, code)
|
||||
|
||||
@@ -182,9 +182,6 @@ class SSHSandbox:
|
||||
working_dir or self.working_dir
|
||||
)
|
||||
|
||||
# Cleanup temp file
|
||||
await self._connection.run(f"rm -f {shlex.quote(remote_file)}")
|
||||
|
||||
completed_at = time.time()
|
||||
duration = completed_at - started_at
|
||||
|
||||
@@ -220,6 +217,12 @@ class SSHSandbox:
|
||||
duration_seconds=time.time() - started_at,
|
||||
metadata={"host": self.host, "language": language}
|
||||
)
|
||||
finally:
|
||||
# Always cleanup temp file, never mask the real result/error with cleanup failure
|
||||
try:
|
||||
await self._connection.run(f"rm -f {shlex.quote(remote_file)}")
|
||||
except Exception:
|
||||
pass # Don't propagate cleanup errors
|
||||
|
||||
async def execute_file(
|
||||
self,
|
||||
@@ -525,13 +528,20 @@ class SSHSandbox:
|
||||
"""Run command with resource limits."""
|
||||
# Change to working directory and execute command
|
||||
# Note: The command has already been processed for shell safety by the caller
|
||||
full_command = f"cd {shlex.quote(working_dir)} && {command}"
|
||||
|
||||
# Set timeout
|
||||
timeout = None
|
||||
if limits and limits.timeout_seconds > 0:
|
||||
timeout = limits.timeout_seconds
|
||||
|
||||
# Build command with remote-side timeout if needed
|
||||
if timeout:
|
||||
# Use timeout command to ensure remote process terminates
|
||||
timeout_cmd = f"timeout {int(timeout)} sh -c {shlex.quote(command)}"
|
||||
full_command = f"cd {shlex.quote(working_dir)} && {timeout_cmd}"
|
||||
else:
|
||||
full_command = f"cd {shlex.quote(working_dir)} && {command}"
|
||||
|
||||
# Execute with timeout
|
||||
if timeout:
|
||||
result = await asyncio.wait_for(
|
||||
|
||||
@@ -30,20 +30,21 @@ def __getattr__(name):
|
||||
from .agent_scheduler import create_agent_scheduler
|
||||
return create_agent_scheduler
|
||||
elif name == 'create_scheduler':
|
||||
# Return a factory function that creates a mock scheduler for testing
|
||||
def _create_scheduler(provider='gcp', **kwargs):
|
||||
from .agent_scheduler import AgentScheduler
|
||||
# Create a mock agent and task for testing
|
||||
class MockAgent:
|
||||
pass
|
||||
return AgentScheduler(MockAgent(), "test task")
|
||||
return _create_scheduler
|
||||
# Return the real deployment scheduler factory
|
||||
from .deployment import create_deployment_scheduler
|
||||
return create_deployment_scheduler
|
||||
elif name == 'DeploymentScheduler':
|
||||
from .agent_scheduler import AgentScheduler
|
||||
return AgentScheduler
|
||||
from .deployment import DeploymentScheduler
|
||||
return DeploymentScheduler
|
||||
elif name == 'create_deployment_scheduler':
|
||||
from .agent_scheduler import create_agent_scheduler
|
||||
return create_agent_scheduler
|
||||
from .deployment import create_deployment_scheduler
|
||||
return create_deployment_scheduler
|
||||
elif name == 'AsyncAgentScheduler':
|
||||
from .async_agent_scheduler import AsyncAgentScheduler
|
||||
return AsyncAgentScheduler
|
||||
elif name == 'create_async_agent_scheduler':
|
||||
from .async_agent_scheduler import create_async_agent_scheduler
|
||||
return create_async_agent_scheduler
|
||||
elif name in ('ScheduledAgentExecutor', 'JobResult'):
|
||||
from .executor import ScheduledAgentExecutor, JobResult
|
||||
return ScheduledAgentExecutor if name == 'ScheduledAgentExecutor' else JobResult
|
||||
|
||||
@@ -9,6 +9,7 @@ import threading
|
||||
import logging
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Callable
|
||||
from concurrent.futures import ThreadPoolExecutor, TimeoutError as FuturesTimeout
|
||||
|
||||
from .base import ScheduleParser, PraisonAgentExecutor
|
||||
from .shared import backoff_delay
|
||||
@@ -73,6 +74,7 @@ class AgentScheduler:
|
||||
self._failure_count = 0
|
||||
self._total_cost = 0.0
|
||||
self._start_time = None
|
||||
self._stats_lock = threading.Lock()
|
||||
|
||||
def start(
|
||||
self,
|
||||
@@ -160,17 +162,18 @@ class AgentScheduler:
|
||||
Returns:
|
||||
Dictionary with execution stats including cost
|
||||
"""
|
||||
runtime = (datetime.now() - self._start_time).total_seconds() if self._start_time else 0
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"total_executions": self._execution_count,
|
||||
"successful_executions": self._success_count,
|
||||
"failed_executions": self._failure_count,
|
||||
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
|
||||
"total_cost_usd": round(self._total_cost, 4),
|
||||
"runtime_seconds": round(runtime, 1),
|
||||
"cost_per_execution": round(self._total_cost / self._execution_count, 4) if self._execution_count > 0 else 0
|
||||
}
|
||||
with self._stats_lock:
|
||||
runtime = (datetime.now() - self._start_time).total_seconds() if self._start_time else 0
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"total_executions": self._execution_count,
|
||||
"successful_executions": self._success_count,
|
||||
"failed_executions": self._failure_count,
|
||||
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
|
||||
"total_cost_usd": round(self._total_cost, 4),
|
||||
"runtime_seconds": round(runtime, 1),
|
||||
"cost_per_execution": round(self._total_cost / self._execution_count, 4) if self._execution_count > 0 else 0
|
||||
}
|
||||
|
||||
def _update_state_if_daemon(self):
|
||||
"""Update state file with execution stats if running as daemon."""
|
||||
@@ -232,7 +235,8 @@ class AgentScheduler:
|
||||
|
||||
def _execute_with_retry(self, max_retries: int):
|
||||
"""Execute agent with retry logic and timeout."""
|
||||
self._execution_count += 1
|
||||
with self._stats_lock:
|
||||
self._execution_count += 1
|
||||
success = False
|
||||
result = None
|
||||
|
||||
@@ -242,21 +246,16 @@ class AgentScheduler:
|
||||
|
||||
# Execute with timeout if specified
|
||||
if self.timeout:
|
||||
import signal
|
||||
|
||||
def timeout_handler(signum, frame):
|
||||
raise TimeoutError(f"Execution exceeded {self.timeout}s timeout")
|
||||
|
||||
# Set timeout alarm (Unix only)
|
||||
executor = ThreadPoolExecutor(max_workers=1)
|
||||
future = executor.submit(self._executor.execute, self.task)
|
||||
try:
|
||||
signal.signal(signal.SIGALRM, timeout_handler)
|
||||
signal.alarm(self.timeout)
|
||||
result = self._executor.execute(self.task)
|
||||
signal.alarm(0) # Cancel alarm
|
||||
except AttributeError:
|
||||
# Windows doesn't support SIGALRM, use threading.Timer fallback
|
||||
logger.warning("Timeout not supported on this platform, executing without timeout")
|
||||
result = self._executor.execute(self.task)
|
||||
result = future.result(timeout=self.timeout)
|
||||
except FuturesTimeout as e:
|
||||
future.cancel()
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
raise TimeoutError(f"Execution exceeded {self.timeout}s timeout") from e
|
||||
else:
|
||||
executor.shutdown(wait=False, cancel_futures=True)
|
||||
else:
|
||||
result = self._executor.execute(self.task)
|
||||
|
||||
@@ -268,10 +267,10 @@ class AgentScheduler:
|
||||
|
||||
# Estimate cost (rough: ~$0.0001 per execution for gpt-4o-mini)
|
||||
estimated_cost = 0.0001 # Base cost estimate
|
||||
self._total_cost += estimated_cost
|
||||
with self._stats_lock:
|
||||
self._total_cost += estimated_cost
|
||||
self._success_count += 1
|
||||
logger.debug(f"Estimated cost this run: ${estimated_cost:.4f}, Total: ${self._total_cost:.4f}")
|
||||
|
||||
self._success_count += 1
|
||||
success = True
|
||||
|
||||
if self.on_success:
|
||||
@@ -299,7 +298,8 @@ class AgentScheduler:
|
||||
return
|
||||
|
||||
if not success:
|
||||
self._failure_count += 1
|
||||
with self._stats_lock:
|
||||
self._failure_count += 1
|
||||
logger.error(f"Agent execution failed after {max_retries} attempts")
|
||||
|
||||
if self.on_failure:
|
||||
|
||||
@@ -0,0 +1,469 @@
|
||||
"""Async Agent Scheduler - Canonical implementation moved from root module.
|
||||
|
||||
This is the canonical implementation of async agent scheduling for PraisonAI.
|
||||
The old location (praisonai.async_agent_scheduler) is now a backward-compatible re-export.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import threading
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any, Callable, Union
|
||||
from abc import ABC, abstractmethod
|
||||
|
||||
from .shared import ScheduleParser, backoff_delay, safe_call
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class AsyncAgentExecutorInterface(ABC):
|
||||
"""Abstract interface for async agent execution."""
|
||||
|
||||
@abstractmethod
|
||||
async def execute(self, task: str) -> Any:
|
||||
"""Execute the agent with given task asynchronously."""
|
||||
pass
|
||||
|
||||
|
||||
class AsyncPraisonAgentExecutor(AsyncAgentExecutorInterface):
|
||||
"""Async executor for PraisonAI agents."""
|
||||
|
||||
def __init__(self, agent):
|
||||
"""
|
||||
Initialize executor with a PraisonAI agent.
|
||||
|
||||
Args:
|
||||
agent: PraisonAI Agent instance
|
||||
"""
|
||||
self.agent = agent
|
||||
|
||||
async def execute(self, task: str) -> Any:
|
||||
"""
|
||||
Execute the agent with the given task.
|
||||
|
||||
Args:
|
||||
task: Task description to execute
|
||||
|
||||
Returns:
|
||||
Agent execution result
|
||||
"""
|
||||
try:
|
||||
# Check if agent has async support
|
||||
if hasattr(self.agent, 'astart'):
|
||||
result = await self.agent.astart(task)
|
||||
elif hasattr(self.agent, 'start'):
|
||||
# Wrap sync call in executor
|
||||
result = await asyncio.to_thread(self.agent.start, task)
|
||||
else:
|
||||
raise AttributeError("Agent must have either 'start' or 'astart' method")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"Async agent execution failed: {e}")
|
||||
raise
|
||||
|
||||
|
||||
class AsyncAgentScheduler:
|
||||
"""
|
||||
Async-native scheduler for running PraisonAI agents periodically.
|
||||
|
||||
Features:
|
||||
- Proper async/await execution
|
||||
- Cancellation support
|
||||
- No global state pollution
|
||||
- Native async coordination
|
||||
- Timeout support (TODO: needs porting from sync version)
|
||||
- Budget tracking (TODO: needs porting from sync version)
|
||||
- YAML/recipe constructors (TODO: needs porting from sync version)
|
||||
|
||||
Example:
|
||||
scheduler = AsyncAgentScheduler(agent, task="Check news")
|
||||
await scheduler.start(schedule_expr="hourly")
|
||||
await asyncio.sleep(3600) # Let it run
|
||||
await scheduler.stop()
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
agent,
|
||||
task: str,
|
||||
config: Optional[Dict[str, Any]] = None,
|
||||
on_success: Optional[Callable] = None,
|
||||
on_failure: Optional[Callable] = None,
|
||||
# TODO: Add these missing features from sync version:
|
||||
# timeout: Optional[int] = None,
|
||||
# max_cost: Optional[float] = 1.00
|
||||
):
|
||||
"""
|
||||
Initialize async agent scheduler.
|
||||
|
||||
Args:
|
||||
agent: PraisonAI Agent instance
|
||||
task: Task description to execute
|
||||
config: Optional configuration dict
|
||||
on_success: Callback function on successful execution
|
||||
on_failure: Callback function on failed execution
|
||||
# TODO: Add timeout and max_cost parameters
|
||||
"""
|
||||
self.agent = agent
|
||||
self.task = task
|
||||
self.config = config or {}
|
||||
self.on_success = on_success
|
||||
self.on_failure = on_failure
|
||||
|
||||
# TODO: Add these missing features from sync version:
|
||||
# self.timeout = timeout
|
||||
# self.max_cost = max_cost
|
||||
# self._total_cost = 0.0
|
||||
|
||||
self.is_running = False
|
||||
self._task: Optional[asyncio.Task] = None
|
||||
self._executor = AsyncPraisonAgentExecutor(agent)
|
||||
self._execution_count = 0
|
||||
self._success_count = 0
|
||||
self._failure_count = 0
|
||||
|
||||
# Sync lock for async primitives creation and bound loop tracking
|
||||
self._primitives_lock = threading.Lock()
|
||||
self._stop_event: Optional[asyncio.Event] = None
|
||||
self._stats_lock: Optional[asyncio.Lock] = None
|
||||
self._bound_loop: Optional[asyncio.AbstractEventLoop] = None
|
||||
|
||||
def _ensure_async_primitives(self) -> None:
|
||||
"""Create async primitives if they don't exist yet.
|
||||
|
||||
Thread-safe and loop-aware: primitives are bound to the current running loop.
|
||||
If called from a different loop, new primitives are created.
|
||||
"""
|
||||
loop = asyncio.get_running_loop() # must be called from a coroutine
|
||||
|
||||
with self._primitives_lock:
|
||||
if self._bound_loop is not loop:
|
||||
self._stop_event = asyncio.Event()
|
||||
self._stats_lock = asyncio.Lock()
|
||||
self._bound_loop = loop
|
||||
|
||||
async def start(
|
||||
self,
|
||||
schedule_expr: str,
|
||||
max_retries: int = 3,
|
||||
run_immediately: bool = False
|
||||
) -> bool:
|
||||
"""
|
||||
Start scheduled agent execution.
|
||||
|
||||
Args:
|
||||
schedule_expr: Schedule expression (e.g., "hourly", "*/6h", "3600")
|
||||
max_retries: Maximum retry attempts on failure
|
||||
run_immediately: If True, run agent immediately before starting schedule
|
||||
|
||||
Returns:
|
||||
True if scheduler started successfully
|
||||
"""
|
||||
if self.is_running:
|
||||
logger.warning("Async scheduler is already running")
|
||||
return False
|
||||
|
||||
try:
|
||||
interval = ScheduleParser.parse(schedule_expr)
|
||||
self.is_running = True
|
||||
self._ensure_async_primitives() # bind to the loop start() runs on
|
||||
self._stop_event.clear()
|
||||
|
||||
logger.info(f"Starting async agent scheduler: {getattr(self.agent, 'name', 'Agent')}")
|
||||
logger.info(f"Task: {self.task}")
|
||||
logger.info(f"Schedule: {schedule_expr} ({interval}s interval)")
|
||||
# TODO: Add timeout and budget logging from sync version
|
||||
|
||||
# Run immediately if requested
|
||||
if run_immediately:
|
||||
logger.info("Running agent immediately before starting schedule...")
|
||||
await self._execute_with_retry(max_retries)
|
||||
|
||||
# Start background task
|
||||
self._task = asyncio.create_task(
|
||||
self._run_schedule(interval, max_retries)
|
||||
)
|
||||
|
||||
logger.info("Async agent scheduler started successfully")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to start async scheduler: {e}")
|
||||
self.is_running = False
|
||||
return False
|
||||
|
||||
async def stop(self) -> bool:
|
||||
"""
|
||||
Stop the scheduler gracefully.
|
||||
|
||||
IMPORTANT: This method must be called from the same event loop
|
||||
that was used to start the scheduler.
|
||||
|
||||
Returns:
|
||||
True if stopped successfully
|
||||
|
||||
Raises:
|
||||
RuntimeError: If called from a different event loop than start()
|
||||
"""
|
||||
if not self.is_running:
|
||||
logger.info("Async scheduler is not running")
|
||||
return True
|
||||
|
||||
logger.info("Stopping async agent scheduler...")
|
||||
|
||||
# _stop_event is guaranteed non-None after a successful start()
|
||||
if self._stop_event is None:
|
||||
logger.warning("stop() called before start(); nothing to stop.")
|
||||
self.is_running = False
|
||||
return True
|
||||
|
||||
# Ensure we're on the same loop that was bound during start()
|
||||
current_loop = asyncio.get_running_loop()
|
||||
if self._bound_loop is not None and current_loop is not self._bound_loop:
|
||||
raise RuntimeError(
|
||||
"stop() must be called from the same event loop as start(). "
|
||||
f"Expected: {self._bound_loop}, got: {current_loop}"
|
||||
)
|
||||
|
||||
self._stop_event.set()
|
||||
|
||||
try:
|
||||
if self._task:
|
||||
try:
|
||||
await asyncio.wait_for(self._task, timeout=10)
|
||||
except asyncio.TimeoutError:
|
||||
logger.warning("Scheduler task didn't stop gracefully, cancelling")
|
||||
self._task.cancel()
|
||||
try:
|
||||
await self._task
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler task raised on cancel: {e}")
|
||||
except asyncio.CancelledError:
|
||||
# Task already cancelled; treat as expected during shutdown
|
||||
pass
|
||||
except Exception as e:
|
||||
logger.error(f"Scheduler task raised during stop: {e}")
|
||||
finally:
|
||||
self.is_running = False
|
||||
|
||||
logger.info("Async agent scheduler stopped")
|
||||
|
||||
# Log final stats with consistent snapshot
|
||||
if self._stats_lock is not None:
|
||||
async with self._stats_lock:
|
||||
total = self._execution_count
|
||||
ok = self._success_count
|
||||
fail = self._failure_count
|
||||
else:
|
||||
total = self._execution_count
|
||||
ok = self._success_count
|
||||
fail = self._failure_count
|
||||
logger.info(f"Execution stats - Total: {total}, Success: {ok}, Failed: {fail}")
|
||||
return True
|
||||
|
||||
async def get_stats(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current execution statistics (best-effort synchronous access).
|
||||
|
||||
Warning: This method provides a best-effort view of stats without
|
||||
guaranteeing atomicity. For consistent snapshots in async context,
|
||||
use get_stats_async() instead.
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
# Best-effort read without lock for backward compatibility
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"total_executions": self._execution_count,
|
||||
"successful_executions": self._success_count,
|
||||
"failed_executions": self._failure_count,
|
||||
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
|
||||
# TODO: Add cost tracking from sync version:
|
||||
# "total_cost": self._total_cost,
|
||||
# "remaining_budget": (self.max_cost - self._total_cost) if self.max_cost else None,
|
||||
}
|
||||
|
||||
async def get_stats_async(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Get current execution statistics with atomic snapshot (async).
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats
|
||||
"""
|
||||
if self._stats_lock is None:
|
||||
# Not yet started: stats are all zero, no lock needed
|
||||
execs, success, failed = 0, 0, 0
|
||||
else:
|
||||
# Take atomic snapshot of all counters
|
||||
async with self._stats_lock:
|
||||
execs = self._execution_count
|
||||
success = self._success_count
|
||||
failed = self._failure_count
|
||||
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"total_executions": execs,
|
||||
"successful_executions": success,
|
||||
"failed_executions": failed,
|
||||
"success_rate": (success / execs * 100) if execs > 0 else 0,
|
||||
# TODO: Add cost tracking from sync version:
|
||||
# "total_cost": self._total_cost,
|
||||
# "remaining_budget": (self.max_cost - self._total_cost) if self.max_cost else None,
|
||||
}
|
||||
|
||||
def get_stats_sync(self) -> Dict[str, Any]:
|
||||
"""
|
||||
Synchronous alias for get_stats() for clarity.
|
||||
|
||||
Returns:
|
||||
Dictionary with execution stats (best-effort)
|
||||
"""
|
||||
# Always do best-effort synchronous read for simplicity
|
||||
return {
|
||||
"is_running": self.is_running,
|
||||
"total_executions": self._execution_count,
|
||||
"successful_executions": self._success_count,
|
||||
"failed_executions": self._failure_count,
|
||||
"success_rate": (self._success_count / self._execution_count * 100) if self._execution_count > 0 else 0,
|
||||
}
|
||||
|
||||
async def _run_schedule(self, interval: int, max_retries: int):
|
||||
"""Internal method to run scheduled agent executions."""
|
||||
try:
|
||||
self._ensure_async_primitives()
|
||||
while not self._stop_event.is_set():
|
||||
logger.info(f"[{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}] Starting async scheduled agent execution")
|
||||
|
||||
await self._execute_with_retry(max_retries)
|
||||
|
||||
# Wait for next scheduled time or stop event
|
||||
logger.info(f"Next execution in {interval} seconds ({interval/3600:.1f} hours)")
|
||||
try:
|
||||
await asyncio.wait_for(self._stop_event.wait(), timeout=interval)
|
||||
break # Stop event was set
|
||||
except asyncio.TimeoutError:
|
||||
continue # Timeout reached, continue with next execution
|
||||
finally:
|
||||
self.is_running = False
|
||||
|
||||
async def _execute_with_retry(self, max_retries: int):
|
||||
"""Execute agent with retry logic.
|
||||
|
||||
TODO: Port missing features from sync version:
|
||||
- Timeout support per execution
|
||||
- Budget tracking and limits
|
||||
- Daemon state updates (_update_state_if_daemon)
|
||||
"""
|
||||
self._ensure_async_primitives() # guarantees _stats_lock is bound to current loop
|
||||
|
||||
async with self._stats_lock:
|
||||
self._execution_count += 1
|
||||
|
||||
# TODO: Add budget check from sync version:
|
||||
# if self.max_cost and self._total_cost >= self.max_cost:
|
||||
# logger.warning(f"Budget limit reached: ${self._total_cost:.4f} >= ${self.max_cost}")
|
||||
# return
|
||||
|
||||
last_exc: Optional[Exception] = None
|
||||
for attempt in range(max_retries):
|
||||
try:
|
||||
logger.info(f"Async attempt {attempt + 1}/{max_retries}")
|
||||
|
||||
# TODO: Add timeout support from sync version:
|
||||
# if self.timeout:
|
||||
# result = await asyncio.wait_for(
|
||||
# self._executor.execute(self.task),
|
||||
# timeout=self.timeout
|
||||
# )
|
||||
# else:
|
||||
result = await self._executor.execute(self.task)
|
||||
|
||||
logger.info(f"Async agent execution successful on attempt {attempt + 1}")
|
||||
logger.info(f"Result: {result}")
|
||||
|
||||
async with self._stats_lock:
|
||||
self._success_count += 1
|
||||
# TODO: Add cost tracking from sync version:
|
||||
# estimated_cost = self._estimate_cost(result)
|
||||
# self._total_cost += estimated_cost
|
||||
|
||||
safe_call(self.on_success, result)
|
||||
# TODO: Add daemon state update from sync version:
|
||||
# self._update_state_if_daemon()
|
||||
return
|
||||
|
||||
except asyncio.TimeoutError as e:
|
||||
last_exc = e
|
||||
logger.error(f"Async execution timeout on attempt {attempt + 1}: {e}")
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = backoff_delay(attempt)
|
||||
logger.info(f"Waiting {wait_time}s before async retry after timeout...")
|
||||
await asyncio.sleep(wait_time)
|
||||
except Exception as e:
|
||||
last_exc = e
|
||||
logger.error(f"Async agent execution failed on attempt {attempt + 1}: {e}")
|
||||
|
||||
if attempt < max_retries - 1:
|
||||
wait_time = backoff_delay(attempt)
|
||||
logger.info(f"Waiting {wait_time}s before async retry...")
|
||||
await asyncio.sleep(wait_time)
|
||||
|
||||
async with self._stats_lock:
|
||||
self._failure_count += 1
|
||||
logger.error(f"Async agent execution failed after {max_retries} attempts")
|
||||
safe_call(
|
||||
self.on_failure,
|
||||
last_exc if last_exc is not None
|
||||
else RuntimeError(f"Failed after {max_retries} attempts")
|
||||
)
|
||||
# TODO: Add daemon state update from sync version:
|
||||
# self._update_state_if_daemon()
|
||||
|
||||
async def execute_once(self) -> Any:
|
||||
"""
|
||||
Execute agent immediately (one-time execution).
|
||||
|
||||
Returns:
|
||||
Agent execution result
|
||||
"""
|
||||
logger.info("Executing agent once (async)")
|
||||
try:
|
||||
result = await self._executor.execute(self.task)
|
||||
logger.info(f"One-time async execution successful: {result}")
|
||||
return result
|
||||
except Exception as e:
|
||||
logger.error(f"One-time async execution failed: {e}")
|
||||
raise
|
||||
|
||||
# TODO: Add these missing factory methods from sync version:
|
||||
# @classmethod
|
||||
# async def from_yaml(cls, yaml_path: str, ...) -> "AsyncAgentScheduler":
|
||||
# """Create scheduler from YAML configuration."""
|
||||
# pass
|
||||
#
|
||||
# @classmethod
|
||||
# async def from_recipe(cls, recipe_name: str, ...) -> "AsyncAgentScheduler":
|
||||
# """Create scheduler from recipe configuration."""
|
||||
# pass
|
||||
|
||||
|
||||
def create_async_agent_scheduler(
|
||||
agent,
|
||||
task: str,
|
||||
config: Optional[Dict[str, Any]] = None
|
||||
) -> AsyncAgentScheduler:
|
||||
"""
|
||||
Factory function to create async agent scheduler.
|
||||
|
||||
Args:
|
||||
agent: PraisonAI Agent instance
|
||||
task: Task description
|
||||
config: Optional configuration
|
||||
|
||||
Returns:
|
||||
Configured AsyncAgentScheduler instance
|
||||
"""
|
||||
return AsyncAgentScheduler(agent, task, config)
|
||||
+44
-48
@@ -1,11 +1,10 @@
|
||||
"""
|
||||
DEPRECATED: This module is deprecated. Use praisonai.scheduler instead.
|
||||
Real deployment scheduler that integrates with the actual deployment system.
|
||||
|
||||
This file is kept for backward compatibility only.
|
||||
All new code should import from praisonai.scheduler.
|
||||
This module provides the actual deployment scheduler implementation that was
|
||||
previously shadowed by the mock in __init__.py.
|
||||
"""
|
||||
|
||||
import warnings
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
@@ -14,18 +13,7 @@ from abc import ABC, abstractmethod
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Issue deprecation warning
|
||||
warnings.warn(
|
||||
"praisonai.scheduler module (root level) is deprecated. "
|
||||
"Use 'from praisonai.scheduler import ...' instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
# Import from new scheduler module for backward compatibility
|
||||
from .scheduler.base import ScheduleParser
|
||||
|
||||
# Keep old classes for backward compatibility
|
||||
class DeployerInterface(ABC):
|
||||
"""Abstract interface for deployers to ensure provider compatibility."""
|
||||
|
||||
@@ -34,35 +22,56 @@ class DeployerInterface(ABC):
|
||||
"""Execute deployment. Returns True on success, False on failure."""
|
||||
pass
|
||||
|
||||
class CloudDeployerAdapter(DeployerInterface):
|
||||
"""Adapter for existing CloudDeployer to match interface."""
|
||||
|
||||
class DeployHandlerAdapter(DeployerInterface):
|
||||
"""Adapter for the real DeployHandler to match the scheduler interface."""
|
||||
|
||||
def __init__(self):
|
||||
from .deploy import CloudDeployer
|
||||
self._deployer = CloudDeployer()
|
||||
def __init__(self, provider: str = "gcp", config: Optional[Dict[str, Any]] = None):
|
||||
self.provider = provider
|
||||
self.config = config or {}
|
||||
|
||||
def deploy(self) -> bool:
|
||||
"""Execute deployment using CloudDeployer."""
|
||||
"""Execute deployment using the real DeployHandler."""
|
||||
try:
|
||||
self._deployer.run_commands()
|
||||
from praisonai.cli.features.deploy import DeployHandler
|
||||
|
||||
handler = DeployHandler()
|
||||
|
||||
# Create args object for handler
|
||||
class DeployArgs:
|
||||
def __init__(self):
|
||||
self.file = "agents.yaml"
|
||||
self.type = None
|
||||
self.provider = self.provider if hasattr(self, 'provider') else 'gcp'
|
||||
self.json = False
|
||||
self.background = False
|
||||
|
||||
# Create args with the provider from the scheduler
|
||||
deploy_args = DeployArgs()
|
||||
deploy_args.provider = self.provider
|
||||
|
||||
handler.handle_deploy(deploy_args)
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Deployment failed: {e}")
|
||||
return False
|
||||
|
||||
|
||||
class DeploymentScheduler:
|
||||
"""
|
||||
Minimal deployment scheduler with provider-agnostic design.
|
||||
Real deployment scheduler with provider-agnostic design.
|
||||
|
||||
Features:
|
||||
- Simple interval-based scheduling
|
||||
- Thread-safe operation
|
||||
- Extensible deployer factory pattern
|
||||
- Minimal dependencies
|
||||
- Integrates with actual DeployHandler
|
||||
- Provider dispatch support
|
||||
"""
|
||||
|
||||
def __init__(self, schedule_config: Optional[Dict[str, Any]] = None):
|
||||
self.config = schedule_config or {}
|
||||
def __init__(self, provider: str = "gcp", config: Optional[Dict[str, Any]] = None):
|
||||
self.provider = provider
|
||||
self.config = config or {}
|
||||
self.is_running = False
|
||||
self._stop_event = threading.Event()
|
||||
self._thread = None
|
||||
@@ -77,8 +86,8 @@ class DeploymentScheduler:
|
||||
if self._deployer:
|
||||
return self._deployer
|
||||
|
||||
# Default to CloudDeployer for backward compatibility
|
||||
return CloudDeployerAdapter()
|
||||
# Use real DeployHandler via adapter
|
||||
return DeployHandlerAdapter(self.provider, self.config)
|
||||
|
||||
def start(self, schedule_expr: str, max_retries: int = 3) -> bool:
|
||||
"""
|
||||
@@ -96,6 +105,7 @@ class DeploymentScheduler:
|
||||
return False
|
||||
|
||||
try:
|
||||
from .base import ScheduleParser
|
||||
interval = ScheduleParser.parse(schedule_expr)
|
||||
self.is_running = True
|
||||
self._stop_event.clear()
|
||||
@@ -165,30 +175,16 @@ class DeploymentScheduler:
|
||||
logger.error(f"One-time deployment failed: {e}")
|
||||
return False
|
||||
|
||||
def create_scheduler(provider: str = "gcp", config: Optional[Dict[str, Any]] = None) -> DeploymentScheduler:
|
||||
|
||||
def create_deployment_scheduler(provider: str = "gcp", config: Optional[Dict[str, Any]] = None) -> DeploymentScheduler:
|
||||
"""
|
||||
Factory function to create scheduler for different providers.
|
||||
Factory function to create a real deployment scheduler for different providers.
|
||||
|
||||
Args:
|
||||
provider: Deployment provider ("gcp", "aws", "azure", etc.)
|
||||
config: Optional configuration dict
|
||||
|
||||
Returns:
|
||||
Configured DeploymentScheduler instance
|
||||
Configured DeploymentScheduler instance that uses real deployment logic
|
||||
"""
|
||||
scheduler = DeploymentScheduler(config)
|
||||
|
||||
# Provider-specific deployer setup can be added here
|
||||
if provider == "gcp":
|
||||
# Default CloudDeployer for GCP
|
||||
pass
|
||||
elif provider == "aws":
|
||||
# Future: AWS deployer implementation
|
||||
logger.warning("AWS provider not yet implemented, using default")
|
||||
elif provider == "azure":
|
||||
# Future: Azure deployer implementation
|
||||
logger.warning("Azure provider not yet implemented, using default")
|
||||
else:
|
||||
logger.warning(f"Unknown provider {provider}, using default")
|
||||
|
||||
return scheduler
|
||||
return DeploymentScheduler(provider, config)
|
||||
@@ -6,6 +6,7 @@ Heavy implementations that follow StorageBackendProtocol for:
|
||||
- MongoDB (praisonai[mongodb])
|
||||
- PostgreSQL (praisonai[postgresql])
|
||||
- DynamoDB (praisonai[dynamodb])
|
||||
- Valkey (praisonai[valkey])
|
||||
|
||||
These implementations are kept in the wrapper to avoid bloating the core SDK.
|
||||
"""
|
||||
@@ -13,9 +14,12 @@ These implementations are kept in the wrapper to avoid bloating the core SDK.
|
||||
# Lazy imports - only import when needed
|
||||
__all__ = [
|
||||
"RedisStorageAdapter",
|
||||
"MongoDBStorageAdapter",
|
||||
"MongoDBStorageAdapter",
|
||||
"PostgreSQLStorageAdapter",
|
||||
"DynamoDBStorageAdapter",
|
||||
"ValkeyStorageAdapter",
|
||||
"ValkeySearchBackend",
|
||||
"ValkeyBackend",
|
||||
]
|
||||
|
||||
def __getattr__(name: str):
|
||||
@@ -32,5 +36,40 @@ def __getattr__(name: str):
|
||||
elif name == "DynamoDBStorageAdapter":
|
||||
from .dynamodb_adapter import DynamoDBStorageAdapter
|
||||
return DynamoDBStorageAdapter
|
||||
elif name == "ValkeyStorageAdapter":
|
||||
from .valkey_adapter import ValkeyStorageAdapter
|
||||
return ValkeyStorageAdapter
|
||||
elif name == "ValkeySearchBackend":
|
||||
from .valkey_adapter import ValkeySearchBackend
|
||||
return ValkeySearchBackend
|
||||
elif name == "ValkeyBackend":
|
||||
cls = _make_valkey_backend_class()
|
||||
globals()["ValkeyBackend"] = cls
|
||||
return cls
|
||||
else:
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
raise AttributeError(f"module '{__name__}' has no attribute '{name}'")
|
||||
|
||||
|
||||
def _make_valkey_backend_class():
|
||||
"""Return ValkeyBackend — ValkeyStorageAdapter pre-configured from env vars."""
|
||||
import os
|
||||
from .valkey_adapter import ValkeyStorageAdapter
|
||||
|
||||
class ValkeyBackend(ValkeyStorageAdapter):
|
||||
def __init__(
|
||||
self,
|
||||
host: str = None,
|
||||
port: int = None,
|
||||
prefix: str = None,
|
||||
ttl=None,
|
||||
password: str = None,
|
||||
):
|
||||
super().__init__(
|
||||
host=host if host is not None else os.environ.get("VALKEY_HOST", "localhost"),
|
||||
port=int(port if port is not None else os.environ.get("VALKEY_PORT", 6379)),
|
||||
prefix=prefix if prefix is not None else os.environ.get("VALKEY_PREFIX", "praisonai:"),
|
||||
ttl=ttl,
|
||||
password=password if password is not None else os.environ.get("VALKEY_PASSWORD"),
|
||||
)
|
||||
|
||||
return ValkeyBackend
|
||||
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
Valkey Storage Adapter for PraisonAI.
|
||||
|
||||
Implements StorageBackendProtocol using Valkey for high-speed storage.
|
||||
This is the wrapper implementation that contains the heavy valkey-glide-sync dependency.
|
||||
"""
|
||||
|
||||
import json
|
||||
import struct
|
||||
from typing import Dict, Any, List, Optional
|
||||
|
||||
from praisonai.persistence._valkey_client import create_valkey_client
|
||||
|
||||
try:
|
||||
from glide_sync import ExpirySet, ExpiryType
|
||||
except ImportError:
|
||||
ExpirySet = None
|
||||
ExpiryType = None
|
||||
|
||||
try:
|
||||
from glide_sync import ft
|
||||
from glide_sync import (
|
||||
VectorField, VectorFieldAttributesHnsw, VectorAlgorithm,
|
||||
DistanceMetricType, VectorType, FtCreateOptions, DataType,
|
||||
FtSearchOptions, ReturnField,
|
||||
)
|
||||
except ImportError:
|
||||
ft = None
|
||||
VectorField = VectorFieldAttributesHnsw = VectorAlgorithm = None
|
||||
DistanceMetricType = VectorType = FtCreateOptions = DataType = None
|
||||
FtSearchOptions = ReturnField = None
|
||||
|
||||
|
||||
class ValkeyStorageAdapter:
|
||||
"""
|
||||
Valkey-based storage backend adapter.
|
||||
|
||||
Uses Valkey for high-speed caching and ephemeral data storage.
|
||||
Implements StorageBackendProtocol from praisonaiagents.storage.protocols.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonai.storage import ValkeyStorageAdapter
|
||||
|
||||
adapter = ValkeyStorageAdapter(host="localhost", port=6379)
|
||||
adapter.save("session_123", {"messages": []})
|
||||
data = adapter.load("session_123")
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = 6379,
|
||||
prefix: str = "praisonai:",
|
||||
ttl: Optional[int] = None,
|
||||
password: Optional[str] = None,
|
||||
):
|
||||
"""
|
||||
Initialize the Valkey storage adapter.
|
||||
|
||||
Args:
|
||||
host: Valkey server host
|
||||
port: Valkey server port
|
||||
prefix: Key prefix for all stored data
|
||||
ttl: Optional TTL in seconds for all keys
|
||||
password: Optional Valkey password
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.prefix = prefix
|
||||
self.ttl = ttl
|
||||
self.password = password
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
"""Lazy initialize Valkey client."""
|
||||
if self._client is None:
|
||||
self._client = create_valkey_client(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
password=self.password,
|
||||
)
|
||||
return self._client
|
||||
|
||||
def _make_key(self, key: str) -> str:
|
||||
"""Create prefixed key."""
|
||||
return f"{self.prefix}{key}"
|
||||
|
||||
def _scan_keys(self, pattern: str) -> List[bytes]:
|
||||
"""Collect all keys matching pattern via SCAN."""
|
||||
client = self._get_client()
|
||||
cursor: bytes = b"0"
|
||||
all_keys: List[bytes] = []
|
||||
while True:
|
||||
result = client.scan(cursor, match=pattern, count=100)
|
||||
cursor = result[0]
|
||||
all_keys.extend(result[1] or [])
|
||||
if not cursor or cursor in (b"0", "0", 0):
|
||||
break
|
||||
return all_keys
|
||||
|
||||
def save(self, key: str, data: Dict[str, Any]) -> None:
|
||||
"""Save data with the given key."""
|
||||
client = self._get_client()
|
||||
full_key = self._make_key(key)
|
||||
json_data = json.dumps(data, default=str, ensure_ascii=False).encode('utf-8')
|
||||
|
||||
try:
|
||||
if self.ttl is not None:
|
||||
client.set(full_key, json_data, expiry=ExpirySet(ExpiryType.SEC, self.ttl))
|
||||
else:
|
||||
client.set(full_key, json_data)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to save data to Valkey: {e}") from e
|
||||
|
||||
def load(self, key: str) -> Optional[Dict[str, Any]]:
|
||||
"""Load data by key."""
|
||||
client = self._get_client()
|
||||
full_key = self._make_key(key)
|
||||
|
||||
try:
|
||||
value = client.get(full_key)
|
||||
if value is not None:
|
||||
try:
|
||||
if isinstance(value, bytes):
|
||||
value = value.decode('utf-8')
|
||||
return json.loads(value)
|
||||
except json.JSONDecodeError as e:
|
||||
raise ValueError(f"Invalid JSON data for key '{key}': {e}") from e
|
||||
return None
|
||||
except ValueError:
|
||||
raise
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to load data from Valkey: {e}") from e
|
||||
|
||||
def delete(self, key: str) -> bool:
|
||||
"""Delete data by key."""
|
||||
client = self._get_client()
|
||||
full_key = self._make_key(key)
|
||||
|
||||
try:
|
||||
return client.delete([full_key]) > 0
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to delete data from Valkey: {e}") from e
|
||||
|
||||
def list_keys(self, prefix: str = "") -> List[str]:
|
||||
"""List all keys, optionally filtered by prefix."""
|
||||
pattern = self._make_key(f"{prefix}*")
|
||||
|
||||
try:
|
||||
raw_keys = self._scan_keys(pattern)
|
||||
prefix_len = len(self.prefix)
|
||||
return sorted(
|
||||
(k.decode('utf-8') if isinstance(k, bytes) else k)[prefix_len:]
|
||||
for k in raw_keys
|
||||
)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to list keys from Valkey: {e}") from e
|
||||
|
||||
def exists(self, key: str) -> bool:
|
||||
"""Check if a key exists."""
|
||||
client = self._get_client()
|
||||
full_key = self._make_key(key)
|
||||
|
||||
try:
|
||||
return client.exists([full_key]) > 0
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to check key existence in Valkey: {e}") from e
|
||||
|
||||
def clear(self) -> int:
|
||||
"""Clear all data with our prefix. Returns number of items deleted."""
|
||||
client = self._get_client()
|
||||
pattern = self._make_key("*")
|
||||
batch_size = 500
|
||||
|
||||
try:
|
||||
all_keys = self._scan_keys(pattern)
|
||||
if not all_keys:
|
||||
return 0
|
||||
total = 0
|
||||
for i in range(0, len(all_keys), batch_size):
|
||||
total += client.delete(all_keys[i:i + batch_size])
|
||||
return total
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to clear data from Valkey: {e}") from e
|
||||
|
||||
def set_ttl(self, key: str, ttl: int) -> bool:
|
||||
"""Set TTL on a specific key."""
|
||||
client = self._get_client()
|
||||
full_key = self._make_key(key)
|
||||
|
||||
try:
|
||||
return client.expire(full_key, ttl)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to set TTL in Valkey: {e}") from e
|
||||
|
||||
def ping(self) -> bool:
|
||||
"""Test connection to Valkey."""
|
||||
try:
|
||||
client = self._get_client()
|
||||
result = client.ping()
|
||||
return result is not None
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the Valkey connection."""
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
finally:
|
||||
self._client = None
|
||||
|
||||
|
||||
class ValkeySearchBackend:
|
||||
"""
|
||||
Valkey vector search backend using RediSearch / ValkeySearch FT commands.
|
||||
|
||||
Example:
|
||||
```python
|
||||
from praisonai.storage import ValkeySearchBackend
|
||||
|
||||
backend = ValkeySearchBackend(host="localhost", port=6379, vector_dim=1536)
|
||||
backend.create_index()
|
||||
backend.add_document("doc1", "hello world", embedding)
|
||||
results = backend.search(query_embedding, k=5)
|
||||
```
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
host: str = "localhost",
|
||||
port: int = 6379,
|
||||
index_name: str = "praisonai_vectors",
|
||||
password: Optional[str] = None,
|
||||
vector_dim: int = 1536,
|
||||
):
|
||||
"""
|
||||
Initialize the Valkey search backend.
|
||||
|
||||
Args:
|
||||
host: Valkey server host
|
||||
port: Valkey server port
|
||||
index_name: Name of the FT index
|
||||
password: Optional Valkey password
|
||||
vector_dim: Dimension of embedding vectors
|
||||
"""
|
||||
self.host = host
|
||||
self.port = port
|
||||
self.index_name = index_name
|
||||
self.password = password
|
||||
self.vector_dim = vector_dim
|
||||
self._client = None
|
||||
|
||||
def _get_client(self):
|
||||
"""Lazy initialize Valkey client."""
|
||||
if self._client is None:
|
||||
self._client = create_valkey_client(
|
||||
host=self.host,
|
||||
port=self.port,
|
||||
password=self.password,
|
||||
)
|
||||
return self._client
|
||||
|
||||
def create_index(self, vector_dim: int = None) -> None:
|
||||
"""Create the vector search index (no-op if already exists)."""
|
||||
client = self._get_client()
|
||||
dim = vector_dim or self.vector_dim
|
||||
|
||||
try:
|
||||
schema = [VectorField(
|
||||
"embedding",
|
||||
VectorAlgorithm.HNSW,
|
||||
VectorFieldAttributesHnsw(dim, DistanceMetricType.COSINE, VectorType.FLOAT32),
|
||||
)]
|
||||
options = FtCreateOptions(DataType.HASH, prefixes=[f"{self.index_name}:"])
|
||||
ft.create(client, self.index_name, schema, options)
|
||||
except Exception as e:
|
||||
err = str(e)
|
||||
if "already exists" not in err.lower() and "index already exists" not in err.lower():
|
||||
raise RuntimeError(f"Failed to create Valkey search index: {e}") from e
|
||||
|
||||
def add_document(self, doc_id: str, text: str, embedding: List[float]) -> None:
|
||||
"""Add a document with its embedding to the index."""
|
||||
client = self._get_client()
|
||||
full_id = f"{self.index_name}:{doc_id}"
|
||||
packed = struct.pack(f"{len(embedding)}f", *embedding)
|
||||
|
||||
try:
|
||||
client.hset(full_id, {"text": text, "embedding": packed})
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to add document to Valkey: {e}") from e
|
||||
|
||||
def search(self, query_embedding: List[float], k: int = 5) -> List[Dict[str, Any]]:
|
||||
"""Search for nearest neighbors by embedding."""
|
||||
client = self._get_client()
|
||||
packed = struct.pack(f"{len(query_embedding)}f", *query_embedding)
|
||||
|
||||
try:
|
||||
options = FtSearchOptions(
|
||||
return_fields=[ReturnField("text"), ReturnField("score")],
|
||||
params={"vec": packed},
|
||||
)
|
||||
raw = ft.search(client, self.index_name, f"*=>[KNN {k} @embedding $vec AS score]", options)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to search Valkey: {e}") from e
|
||||
|
||||
# ft.search returns [count, {doc_id: {field: value, ...}, ...}]
|
||||
if not raw or len(raw) < 2:
|
||||
return []
|
||||
|
||||
results = []
|
||||
key_prefix = f"{self.index_name}:"
|
||||
for doc_id, fields in raw[1].items():
|
||||
doc: Dict[str, Any] = {}
|
||||
raw_id = doc_id.decode('utf-8') if isinstance(doc_id, bytes) else doc_id
|
||||
doc["id"] = raw_id.removeprefix(key_prefix)
|
||||
for fname, fval in fields.items():
|
||||
fname = fname.decode('utf-8') if isinstance(fname, bytes) else fname
|
||||
fval = fval.decode('utf-8') if isinstance(fval, bytes) else fval
|
||||
doc[fname] = fval
|
||||
results.append(doc)
|
||||
|
||||
return results
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the Valkey connection."""
|
||||
if self._client:
|
||||
try:
|
||||
self._client.close()
|
||||
finally:
|
||||
self._client = None
|
||||
@@ -34,6 +34,9 @@ from ._safe_loader import load_user_module
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Sentinel for cache - needed because None is a valid cached result (tool not found)
|
||||
_SENTINEL = object()
|
||||
|
||||
|
||||
class ToolResolver:
|
||||
"""Resolves tool names to callables from multiple sources.
|
||||
@@ -45,19 +48,34 @@ class ToolResolver:
|
||||
Attributes:
|
||||
_local_tools_cache: Cached tools from local tools.py
|
||||
_local_tools_loaded: Whether local tools have been loaded
|
||||
_registry: Optional ToolRegistry for wrapper-level tool registration
|
||||
_resolve_cache: Cached results from resolve() calls
|
||||
_resolve_cache_lock: Thread lock for resolve cache
|
||||
"""
|
||||
|
||||
def __init__(self, tools_py_path: Optional[str] = None):
|
||||
def __init__(
|
||||
self,
|
||||
tools_py_path: Optional[str] = None,
|
||||
registry: Optional["ToolRegistry"] = None,
|
||||
):
|
||||
"""Initialize the resolver.
|
||||
|
||||
Args:
|
||||
tools_py_path: Optional path to tools.py. If None, uses ./tools.py
|
||||
registry: Optional ToolRegistry to include in resolution chain
|
||||
"""
|
||||
self._tools_py_path = tools_py_path or "tools.py"
|
||||
from pathlib import Path
|
||||
# Resolve path eagerly in constructor to make binding explicit and inspectable
|
||||
self._tools_py_path = str(Path(tools_py_path or "tools.py").resolve())
|
||||
self._local_tools_cache: Mapping[str, Callable] = MappingProxyType({})
|
||||
self._local_tools_loaded: bool = False
|
||||
self._praisonai_tools_available: Optional[bool] = None
|
||||
self._local_tools_lock = threading.Lock()
|
||||
self._registry = registry
|
||||
|
||||
# Cache for resolved tools to avoid repeated resolution
|
||||
self._resolve_cache: Dict[str, Optional[Callable]] = {}
|
||||
self._resolve_cache_lock = threading.Lock()
|
||||
|
||||
def _load_local_tools(self) -> Mapping[str, Callable]:
|
||||
"""Load tools from local tools.py file.
|
||||
@@ -187,6 +205,25 @@ class ToolResolver:
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_from_wrapper_registry(self, name: str) -> Optional[Callable]:
|
||||
"""Resolve tool from the wrapper ToolRegistry.
|
||||
|
||||
Args:
|
||||
name: Tool name to resolve
|
||||
|
||||
Returns:
|
||||
Callable if found, None otherwise
|
||||
"""
|
||||
if self._registry is None:
|
||||
return None
|
||||
|
||||
tool = self._registry.get_function(name)
|
||||
if tool is not None:
|
||||
logger.debug(f"Resolved '{name}' from wrapper ToolRegistry")
|
||||
return tool
|
||||
|
||||
return None
|
||||
|
||||
def _resolve_from_registry(self, name: str) -> Optional[Callable]:
|
||||
"""Resolve tool from the global tool registry.
|
||||
|
||||
@@ -214,9 +251,10 @@ class ToolResolver:
|
||||
|
||||
Resolution order:
|
||||
1. Local tools.py (backward compat, custom tools)
|
||||
2. praisonaiagents.tools.TOOL_MAPPINGS (built-in)
|
||||
3. praisonai-tools package (external, optional)
|
||||
4. Tool registry (plugins)
|
||||
2. Wrapper ToolRegistry (register_function API)
|
||||
3. praisonaiagents.tools.TOOL_MAPPINGS (built-in)
|
||||
4. praisonai-tools package (external, optional)
|
||||
5. Core SDK tool registry (plugins)
|
||||
|
||||
Args:
|
||||
name: Tool name to resolve
|
||||
@@ -230,30 +268,56 @@ class ToolResolver:
|
||||
name = name.strip()
|
||||
if not name:
|
||||
return None
|
||||
|
||||
# 1. Check local tools.py first (highest priority)
|
||||
|
||||
# Fast path: cached result
|
||||
cached = self._resolve_cache.get(name, _SENTINEL)
|
||||
if cached is not _SENTINEL:
|
||||
return cached
|
||||
|
||||
# Load local tools outside the cache lock to prevent lock-order inversion
|
||||
local_tools = self._load_local_tools()
|
||||
if name in local_tools:
|
||||
logger.debug(f"Resolved '{name}' from local tools.py")
|
||||
return local_tools[name]
|
||||
|
||||
# 2. Check praisonaiagents.tools
|
||||
tool = self._resolve_from_praisonaiagents(name)
|
||||
if tool is not None:
|
||||
return tool
|
||||
|
||||
# 3. Check praisonai-tools package
|
||||
tool = self._resolve_from_praisonai_tools(name)
|
||||
if tool is not None:
|
||||
return tool
|
||||
|
||||
# 4. Check tool registry
|
||||
tool = self._resolve_from_registry(name)
|
||||
if tool is not None:
|
||||
return tool
|
||||
|
||||
logger.warning(f"Tool '{name}' not found in any source")
|
||||
return None
|
||||
|
||||
with self._resolve_cache_lock:
|
||||
# Double-check inside lock
|
||||
cached = self._resolve_cache.get(name, _SENTINEL)
|
||||
if cached is not _SENTINEL:
|
||||
return cached
|
||||
|
||||
# 1. Check local tools.py first (highest priority)
|
||||
if name in local_tools:
|
||||
logger.debug(f"Resolved '{name}' from local tools.py")
|
||||
tool = local_tools[name]
|
||||
self._resolve_cache[name] = tool
|
||||
return tool
|
||||
|
||||
# 2. Check wrapper ToolRegistry (NEW - ahead of SDK paths)
|
||||
tool = self._resolve_from_wrapper_registry(name)
|
||||
if tool is not None:
|
||||
self._resolve_cache[name] = tool
|
||||
return tool
|
||||
|
||||
# 3. Check praisonaiagents.tools
|
||||
tool = self._resolve_from_praisonaiagents(name)
|
||||
if tool is not None:
|
||||
self._resolve_cache[name] = tool
|
||||
return tool
|
||||
|
||||
# 4. Check praisonai-tools package
|
||||
tool = self._resolve_from_praisonai_tools(name)
|
||||
if tool is not None:
|
||||
self._resolve_cache[name] = tool
|
||||
return tool
|
||||
|
||||
# 5. Check core SDK tool registry
|
||||
tool = self._resolve_from_registry(name)
|
||||
if tool is not None:
|
||||
self._resolve_cache[name] = tool
|
||||
return tool
|
||||
|
||||
# Cache the None result to avoid repeated failed lookups
|
||||
logger.warning(f"Tool '{name}' not found in any source")
|
||||
self._resolve_cache[name] = None
|
||||
return None
|
||||
|
||||
def resolve_many(self, names: List[str]) -> List[Callable]:
|
||||
"""Resolve multiple tool names to callables.
|
||||
@@ -362,13 +426,15 @@ class ToolResolver:
|
||||
return list(set(missing)) # Remove duplicates
|
||||
|
||||
def clear_cache(self) -> None:
|
||||
"""Clear the local tools cache.
|
||||
"""Clear both the local tools cache and resolve cache.
|
||||
|
||||
Useful when tools.py has been modified and needs to be reloaded.
|
||||
"""
|
||||
with self._local_tools_lock:
|
||||
self._local_tools_cache = MappingProxyType({})
|
||||
self._local_tools_loaded = False
|
||||
with self._resolve_cache_lock:
|
||||
self._resolve_cache.clear()
|
||||
|
||||
def get_local_callables(self) -> List[Callable]:
|
||||
"""Get functions exposed by tools.py (path A semantics).
|
||||
@@ -390,59 +456,99 @@ class ToolResolver:
|
||||
module = load_user_module(self._tools_py_path, name="tools_module")
|
||||
if module is None:
|
||||
return {}
|
||||
|
||||
# Import the necessary classes (matching agents_generator.py logic)
|
||||
BaseTool = None
|
||||
PRAISONAI_TOOLS_AVAILABLE = False
|
||||
try:
|
||||
from praisonai_tools import BaseTool
|
||||
PRAISONAI_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
try:
|
||||
from praisonai.tools import BaseTool
|
||||
PRAISONAI_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
result = {}
|
||||
for name, obj in inspect.getmembers(module,
|
||||
lambda x: inspect.isclass(x) and (
|
||||
x.__module__.startswith('langchain_community.tools') or
|
||||
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
|
||||
) and x is not BaseTool):
|
||||
try:
|
||||
result[name] = obj()
|
||||
logger.debug(f"Loaded local tool class: {name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error instantiating tool class {name}: {e}")
|
||||
continue
|
||||
|
||||
return result
|
||||
return self._extract_tool_classes(module)
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading tool classes from {self._tools_py_path}: {e}")
|
||||
return {}
|
||||
|
||||
def get_local_tool_classes_from_dir(self, tools_dir: "os.PathLike|str") -> Dict[str, Any]:
|
||||
"""Load BaseTool/langchain classes from every *.py in a tools/ directory.
|
||||
|
||||
Args:
|
||||
tools_dir: Path to the tools directory
|
||||
|
||||
Returns:
|
||||
Dictionary mapping class names to instantiated tool objects
|
||||
"""
|
||||
from pathlib import Path
|
||||
from ._safe_loader import load_user_module
|
||||
|
||||
classes: Dict[str, Any] = {}
|
||||
for py_file in Path(tools_dir).glob("*.py"):
|
||||
if py_file.name.startswith("__"):
|
||||
continue
|
||||
try:
|
||||
module = load_user_module(py_file, name=f"tools_{py_file.stem}")
|
||||
if module is not None:
|
||||
classes.update(self._extract_tool_classes(module))
|
||||
except Exception as e:
|
||||
logger.warning(f"Error loading tool classes from file {py_file}: {e}")
|
||||
return classes
|
||||
|
||||
# Process-level lazy singleton for performance (matches profiler.py pattern)
|
||||
_default_resolver: Optional[ToolResolver] = None
|
||||
_default_resolver_lock = threading.Lock()
|
||||
def _extract_tool_classes(self, module):
|
||||
"""Extract tool classes from a loaded module that inherit from BaseTool
|
||||
or are part of langchain_community.tools package.
|
||||
"""
|
||||
# Import the necessary classes (matching agents_generator.py logic)
|
||||
BaseTool = None
|
||||
PRAISONAI_TOOLS_AVAILABLE = False
|
||||
try:
|
||||
from praisonai_tools import BaseTool
|
||||
PRAISONAI_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
try:
|
||||
from praisonai.tools import BaseTool
|
||||
PRAISONAI_TOOLS_AVAILABLE = True
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
result = {}
|
||||
for name, obj in inspect.getmembers(module,
|
||||
lambda x: inspect.isclass(x) and (
|
||||
x.__module__.startswith('langchain_community.tools') or
|
||||
(PRAISONAI_TOOLS_AVAILABLE and BaseTool and issubclass(x, BaseTool))
|
||||
) and x is not BaseTool):
|
||||
try:
|
||||
result[name] = obj()
|
||||
logger.debug(f"Loaded tool class: {name}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Error instantiating tool class {name}: {e}")
|
||||
continue
|
||||
|
||||
return result
|
||||
|
||||
|
||||
# Context-local resolver for multi-project safety
|
||||
import contextvars
|
||||
|
||||
_resolver_var: contextvars.ContextVar[Optional[ToolResolver]] = contextvars.ContextVar(
|
||||
"tool_resolver", default=None,
|
||||
)
|
||||
|
||||
def _get_default_resolver() -> ToolResolver:
|
||||
"""Process-default ToolResolver (double-checked lazy init).
|
||||
"""Per-context resolver. Falls back to a fresh ToolResolver per context,
|
||||
so changing CWD between agents / requests is honoured.
|
||||
|
||||
Returns cached ToolResolver that is anchored to the working directory
|
||||
at first call. Local tools.py resolution is CWD-dependent and cached
|
||||
for the lifetime of the process.
|
||||
Each context (agent/task/request) gets its own resolver anchored to the
|
||||
working directory at the time of first use in that context. This prevents
|
||||
the singleton bug where the first caller locks in their CWD for the entire process.
|
||||
|
||||
For test isolation or multi-project CLIs, create explicit resolver
|
||||
instances instead of using this cached default.
|
||||
"""
|
||||
global _default_resolver
|
||||
if _default_resolver is None:
|
||||
with _default_resolver_lock:
|
||||
if _default_resolver is None:
|
||||
_default_resolver = ToolResolver()
|
||||
return _default_resolver
|
||||
resolver = _resolver_var.get()
|
||||
if resolver is None:
|
||||
resolver = ToolResolver()
|
||||
_resolver_var.set(resolver)
|
||||
return resolver
|
||||
|
||||
def reset_default_resolver() -> None:
|
||||
"""Explicit invalidation hook for daemons / IDE plugins switching projects.
|
||||
|
||||
Call this when changing working directories or switching between projects
|
||||
to ensure the next tool resolution uses the new CWD.
|
||||
"""
|
||||
_resolver_var.set(None)
|
||||
|
||||
|
||||
# Convenience functions that use cached default resolver for performance
|
||||
@@ -457,8 +563,8 @@ def resolve_tool(name: str, resolver: Optional[ToolResolver] = None) -> Optional
|
||||
Callable if found, None otherwise
|
||||
|
||||
Note:
|
||||
When resolver=None, uses a process-level cached resolver that is anchored
|
||||
to the working directory at first call. For test isolation or multi-project
|
||||
When resolver=None, uses a context-local cached resolver anchored to the
|
||||
working directory for that context. For test isolation or multi-project
|
||||
CLIs, pass an explicit resolver instance.
|
||||
"""
|
||||
return (resolver or _get_default_resolver()).resolve(name)
|
||||
@@ -513,3 +619,17 @@ def validate_yaml_tools(yaml_config: Dict[str, Any], resolver: Optional[ToolReso
|
||||
List of missing tool names
|
||||
"""
|
||||
return (resolver or _get_default_resolver()).validate_yaml_tools(yaml_config)
|
||||
|
||||
|
||||
def reset_default_resolver() -> None:
|
||||
"""Clear the process-default resolver.
|
||||
|
||||
Call this between tenants, on CWD change, or in test setup to ensure
|
||||
that local tools.py resolution is not affected by previous calls.
|
||||
|
||||
This follows the same pattern as _framework_availability.invalidate()
|
||||
for resetting cached state.
|
||||
"""
|
||||
global _default_resolver
|
||||
with _default_resolver_lock:
|
||||
_default_resolver = None
|
||||
@@ -549,12 +549,13 @@ class TrainModel:
|
||||
"""
|
||||
|
||||
def create_and_push_ollama_model(self):
|
||||
from ._ollama import create_and_push_ollama_model
|
||||
modelfile_content = self.prepare_modelfile_content()
|
||||
with open("Modelfile", "w") as file:
|
||||
file.write(modelfile_content)
|
||||
subprocess.run(["ollama", "serve"])
|
||||
subprocess.run(["ollama", "create", f"{self.config['ollama_model']}:{self.config['model_parameters']}", "-f", "Modelfile"])
|
||||
subprocess.run(["ollama", "push", f"{self.config['ollama_model']}:{self.config['model_parameters']}"])
|
||||
create_and_push_ollama_model(
|
||||
self.config['ollama_model'],
|
||||
self.config['model_parameters'],
|
||||
modelfile_content
|
||||
)
|
||||
|
||||
def run(self):
|
||||
self.print_system_info()
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Shared Ollama daemon management utilities.
|
||||
|
||||
This module provides utilities to start and check Ollama daemon status,
|
||||
fixing the blocking subprocess.run(["ollama", "serve"]) issue present
|
||||
in multiple files.
|
||||
"""
|
||||
import contextlib
|
||||
import shutil
|
||||
import socket
|
||||
import subprocess
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def _ollama_ready(host: str = "127.0.0.1", port: int = 11434, timeout: float = 0.2) -> bool:
|
||||
"""Check if Ollama daemon is ready to accept connections.
|
||||
|
||||
Args:
|
||||
host: Ollama host (default 127.0.0.1)
|
||||
port: Ollama port (default 11434)
|
||||
timeout: Connection timeout in seconds
|
||||
|
||||
Returns:
|
||||
True if Ollama is ready, False otherwise
|
||||
"""
|
||||
with contextlib.suppress(OSError):
|
||||
with socket.create_connection((host, port), timeout):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def ensure_ollama_running(max_wait_seconds: float = 5.0) -> Optional[subprocess.Popen]:
|
||||
"""Ensure Ollama daemon is running, start it if necessary.
|
||||
|
||||
Args:
|
||||
max_wait_seconds: Maximum time to wait for daemon to become ready
|
||||
|
||||
Returns:
|
||||
Process object if we started the daemon, None if it was already running
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ollama CLI not found or daemon doesn't become ready
|
||||
"""
|
||||
# Check if already running
|
||||
if _ollama_ready():
|
||||
return None
|
||||
|
||||
# Check if ollama CLI is available
|
||||
if shutil.which("ollama") is None:
|
||||
raise RuntimeError("`ollama` CLI not found; install from https://ollama.com")
|
||||
|
||||
# Start daemon in detached mode
|
||||
proc = subprocess.Popen(
|
||||
["ollama", "serve"],
|
||||
stdout=subprocess.DEVNULL,
|
||||
stderr=subprocess.DEVNULL,
|
||||
start_new_session=True, # Detach from parent
|
||||
)
|
||||
|
||||
# Poll until ready or timeout
|
||||
wait_interval = 0.1
|
||||
max_polls = int(max_wait_seconds / wait_interval)
|
||||
|
||||
for _ in range(max_polls):
|
||||
if _ollama_ready():
|
||||
return proc
|
||||
time.sleep(wait_interval)
|
||||
|
||||
# If we get here, daemon didn't become ready in time
|
||||
proc.terminate()
|
||||
raise RuntimeError(f"ollama serve did not become ready in {max_wait_seconds} seconds")
|
||||
|
||||
|
||||
def create_and_push_ollama_model(ollama_model: str, model_parameters: str, modelfile_content: str) -> None:
|
||||
"""Create and push an Ollama model with proper daemon management.
|
||||
|
||||
Args:
|
||||
ollama_model: Name of the Ollama model
|
||||
model_parameters: Model parameters/tag
|
||||
modelfile_content: Content for the Modelfile
|
||||
|
||||
Raises:
|
||||
RuntimeError: If ollama operations fail
|
||||
subprocess.CalledProcessError: If create/push commands fail
|
||||
"""
|
||||
# Write Modelfile
|
||||
with open("Modelfile", "w") as f:
|
||||
f.write(modelfile_content)
|
||||
|
||||
# Ensure daemon is running
|
||||
ensure_ollama_running()
|
||||
|
||||
# Create and push model
|
||||
tag = f"{ollama_model}:{model_parameters}"
|
||||
|
||||
subprocess.run(["ollama", "create", tag, "-f", "Modelfile"], check=True)
|
||||
subprocess.run(["ollama", "push", tag], check=True)
|
||||
@@ -522,12 +522,13 @@ class TrainModel:
|
||||
"""
|
||||
|
||||
def create_and_push_ollama_model(self):
|
||||
from .._ollama import create_and_push_ollama_model
|
||||
modelfile_content = self.prepare_modelfile_content()
|
||||
with open("Modelfile", "w") as file:
|
||||
file.write(modelfile_content)
|
||||
subprocess.run(["ollama", "serve"])
|
||||
subprocess.run(["ollama", "create", f"{self.config['ollama_model']}:{self.config['model_parameters']}", "-f", "Modelfile"])
|
||||
subprocess.run(["ollama", "push", f"{self.config['ollama_model']}:{self.config['model_parameters']}"])
|
||||
create_and_push_ollama_model(
|
||||
self.config['ollama_model'],
|
||||
self.config['model_parameters'],
|
||||
modelfile_content
|
||||
)
|
||||
|
||||
def run(self):
|
||||
self.print_system_info()
|
||||
|
||||
@@ -285,12 +285,13 @@ PARAMETER top_p 0.9
|
||||
"""
|
||||
|
||||
def create_and_push_ollama_model(self):
|
||||
from .train._ollama import create_and_push_ollama_model
|
||||
modelfile_content = self.prepare_modelfile_content()
|
||||
with open("Modelfile", "w") as file:
|
||||
file.write(modelfile_content)
|
||||
subprocess.run(["ollama", "serve"])
|
||||
subprocess.run(["ollama", "create", f"{self.config['ollama_model']}:{self.config['model_parameters']}", "-f", "Modelfile"])
|
||||
subprocess.run(["ollama", "push", f"{self.config['ollama_model']}:{self.config['model_parameters']}"])
|
||||
create_and_push_ollama_model(
|
||||
self.config['ollama_model'],
|
||||
self.config['model_parameters'],
|
||||
modelfile_content
|
||||
)
|
||||
|
||||
def run(self):
|
||||
self.print_system_info()
|
||||
|
||||
@@ -82,26 +82,15 @@ class UploadVisionModel:
|
||||
|
||||
def create_and_push_ollama_model(self):
|
||||
"""Create and push model to Ollama."""
|
||||
from .train._ollama import create_and_push_ollama_model
|
||||
print(f"DEBUG: Creating Ollama model: {self.config['ollama_model']}:{self.config['model_parameters']}")
|
||||
modelfile_content = self.prepare_modelfile_content()
|
||||
with open("Modelfile", "w") as file:
|
||||
file.write(modelfile_content)
|
||||
|
||||
print("DEBUG: Starting Ollama server...")
|
||||
subprocess.run(["ollama", "serve"])
|
||||
|
||||
print("DEBUG: Creating Ollama model...")
|
||||
subprocess.run([
|
||||
"ollama", "create",
|
||||
f"{self.config['ollama_model']}:{self.config['model_parameters']}",
|
||||
"-f", "Modelfile"
|
||||
])
|
||||
|
||||
print("DEBUG: Pushing model to Ollama...")
|
||||
subprocess.run([
|
||||
"ollama", "push",
|
||||
f"{self.config['ollama_model']}:{self.config['model_parameters']}"
|
||||
])
|
||||
print("DEBUG: Starting Ollama server and creating model...")
|
||||
create_and_push_ollama_model(
|
||||
self.config['ollama_model'],
|
||||
self.config['model_parameters'],
|
||||
modelfile_content
|
||||
)
|
||||
print("DEBUG: Model pushed to Ollama successfully.")
|
||||
|
||||
def upload(self, target="all"):
|
||||
|
||||
@@ -1 +1 @@
|
||||
__version__ = "4.6.45"
|
||||
__version__ = "4.6.48"
|
||||
Loaded 100 of 116 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user