mirror of
https://github.com/MervinPraison/PraisonAI.git
synced 2026-11-12 01:41:26 -10:00
Compare commits
33
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 |
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>
|
||||
@@ -767,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
|
||||
@@ -904,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>
|
||||
@@ -1066,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.46" \
|
||||
"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.46" \
|
||||
"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.46" \
|
||||
"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
|
||||
Binary file not shown.
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -291,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."""
|
||||
@@ -440,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,
|
||||
@@ -455,45 +521,31 @@ class DefaultSessionStore:
|
||||
user_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Set agent info for a session."""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
|
||||
def _apply(session: SessionData) -> None:
|
||||
if agent_name:
|
||||
session.agent_name = agent_name
|
||||
if user_id:
|
||||
session.user_id = user_id
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
|
||||
return self._modify_session_locked(
|
||||
session_id, _apply, error_label="set agent info for session"
|
||||
)
|
||||
|
||||
def clear_session(self, session_id: str) -> bool:
|
||||
"""Clear all messages from a session."""
|
||||
session = self._load_session(session_id)
|
||||
|
||||
with self._lock:
|
||||
session.messages.clear()
|
||||
self._cache[session_id] = session
|
||||
|
||||
return self._save_session(session)
|
||||
return self._modify_session_locked(
|
||||
session_id,
|
||||
lambda session: session.messages.clear(),
|
||||
error_label="clear session",
|
||||
)
|
||||
|
||||
def update_session_metadata(self, session_id: str, **fields: Any) -> bool:
|
||||
"""Merge run stats / metadata fields into a persisted session."""
|
||||
if not fields:
|
||||
return True
|
||||
|
||||
filepath = self._get_session_path(session_id)
|
||||
|
||||
with FileLock(filepath, self.lock_timeout):
|
||||
if os.path.exists(filepath):
|
||||
try:
|
||||
with open(filepath, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
session = SessionData.from_dict(data)
|
||||
except (json.JSONDecodeError, IOError):
|
||||
session = SessionData(session_id=session_id)
|
||||
else:
|
||||
session = SessionData(session_id=session_id)
|
||||
|
||||
def _apply(session: SessionData) -> None:
|
||||
for key, value in fields.items():
|
||||
if value is None:
|
||||
continue
|
||||
@@ -501,35 +553,9 @@ class DefaultSessionStore:
|
||||
if key in ("agent_id", "agent_name", "user_id"):
|
||||
setattr(session, key, value)
|
||||
|
||||
session.updated_at = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
try:
|
||||
dir_path = os.path.dirname(filepath) or "."
|
||||
os.makedirs(dir_path, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="w",
|
||||
encoding="utf-8",
|
||||
dir=dir_path,
|
||||
delete=False,
|
||||
suffix=".tmp",
|
||||
) as f:
|
||||
json.dump(session.to_dict(), f, indent=2, ensure_ascii=False)
|
||||
temp_path = f.name
|
||||
|
||||
os.replace(temp_path, filepath)
|
||||
|
||||
with self._lock:
|
||||
self._cache[session_id] = session
|
||||
|
||||
return True
|
||||
except (IOError, OSError) as e:
|
||||
logger.error(f"Failed to update session metadata {session_id}: {e}")
|
||||
try:
|
||||
if "temp_path" in locals():
|
||||
os.remove(temp_path)
|
||||
except (IOError, OSError):
|
||||
pass
|
||||
return False
|
||||
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."""
|
||||
@@ -629,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,
|
||||
@@ -683,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.
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.46"
|
||||
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
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
"""Tests for Agent.__deepcopy__ and Agent.clone_for_channel() (issue #1746).
|
||||
|
||||
Validates:
|
||||
- copy.deepcopy(agent) works without threading.RLock errors
|
||||
- clone_for_channel() returns an independent instance
|
||||
- Cloned agent has its own lock objects (not shared)
|
||||
- Key configuration attributes are preserved in the clone
|
||||
- Multiple clones are isolated from each other
|
||||
"""
|
||||
import copy
|
||||
import threading
|
||||
import pytest
|
||||
|
||||
|
||||
class TestAgentDeepCopy:
|
||||
"""Test Agent.__deepcopy__ and clone_for_channel()."""
|
||||
|
||||
def _make_agent(self, **kwargs):
|
||||
from praisonaiagents.agent.agent import Agent
|
||||
return Agent(name="TestAgent", instructions="test", **kwargs)
|
||||
|
||||
def test_deepcopy_does_not_raise(self):
|
||||
"""copy.deepcopy(agent) must not raise TypeError for RLock."""
|
||||
agent = self._make_agent()
|
||||
cloned = copy.deepcopy(agent)
|
||||
assert cloned is not agent
|
||||
|
||||
def test_clone_for_channel_returns_new_instance(self):
|
||||
agent = self._make_agent()
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned is not agent
|
||||
|
||||
def test_clone_has_independent_cache_lock(self):
|
||||
"""__cache_lock must be a new RLock, not shared with original."""
|
||||
agent = self._make_agent()
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned._Agent__cache_lock is not agent._Agent__cache_lock
|
||||
assert isinstance(cloned._Agent__cache_lock, type(threading.RLock()))
|
||||
|
||||
def test_clone_has_independent_cost_lock(self):
|
||||
"""_cost_lock must be a new Lock, not shared with original."""
|
||||
agent = self._make_agent()
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned._cost_lock is not agent._cost_lock
|
||||
|
||||
def test_clone_preserves_name(self):
|
||||
agent = self._make_agent(name="MyAgent")
|
||||
assert agent.clone_for_channel().name == "MyAgent"
|
||||
|
||||
def test_clone_preserves_instructions(self):
|
||||
agent = self._make_agent(instructions="Do the thing")
|
||||
assert agent.clone_for_channel().instructions == "Do the thing"
|
||||
|
||||
def test_clone_preserves_role_goal_backstory(self):
|
||||
agent = self._make_agent(
|
||||
role="Analyst",
|
||||
goal="Analyse",
|
||||
backstory="Expert analyst",
|
||||
)
|
||||
cloned = agent.clone_for_channel()
|
||||
assert cloned.role == "Analyst"
|
||||
assert cloned.goal == "Analyse"
|
||||
assert cloned.backstory == "Expert analyst"
|
||||
|
||||
def test_clone_preserves_llm(self):
|
||||
agent = self._make_agent(llm="gpt-4o-mini")
|
||||
assert agent.clone_for_channel().llm == "gpt-4o-mini"
|
||||
|
||||
def test_multiple_clones_are_isolated(self):
|
||||
"""Regression: creating two clones (simulating 2nd+ gateway channel) must work."""
|
||||
agent = self._make_agent()
|
||||
clone1 = agent.clone_for_channel()
|
||||
clone2 = agent.clone_for_channel()
|
||||
assert clone1 is not clone2
|
||||
assert clone1._Agent__cache_lock is not clone2._Agent__cache_lock
|
||||
|
||||
def test_deepcopy_multiple_times(self):
|
||||
"""Successive deepcopies must all succeed (no cumulative state corruption)."""
|
||||
agent = self._make_agent()
|
||||
for _ in range(3):
|
||||
cloned = copy.deepcopy(agent)
|
||||
assert cloned is not agent
|
||||
@@ -0,0 +1,45 @@
|
||||
from types import SimpleNamespace
|
||||
import sys
|
||||
|
||||
|
||||
def test_start_verbose_composed_callbacks_forward_all_arguments(monkeypatch):
|
||||
from praisonaiagents import Agent, AgentTeam
|
||||
|
||||
on_start_calls = []
|
||||
on_complete_calls = []
|
||||
debug_messages = []
|
||||
|
||||
def on_task_start(task, task_id):
|
||||
on_start_calls.append((task, task_id))
|
||||
|
||||
def on_task_complete(task, task_output):
|
||||
on_complete_calls.append((task, task_output))
|
||||
|
||||
team = AgentTeam(
|
||||
agents=[
|
||||
Agent(name="Researcher", instructions="Research"),
|
||||
Agent(name="Writer", instructions="Write"),
|
||||
],
|
||||
hooks={
|
||||
"on_task_start": on_task_start,
|
||||
"on_task_complete": on_task_complete,
|
||||
},
|
||||
)
|
||||
|
||||
def fake_run_all_tasks():
|
||||
task_id, task = next(iter(team.tasks.items()))
|
||||
task_output = SimpleNamespace(raw="done")
|
||||
team.on_task_start(task, task_id)
|
||||
team.on_task_complete(task, task_output)
|
||||
|
||||
monkeypatch.setattr(team, "run_all_tasks", fake_run_all_tasks)
|
||||
monkeypatch.setattr(sys.stdout, "isatty", lambda: True)
|
||||
monkeypatch.setattr("praisonaiagents.agents.agents.logging.debug", lambda msg: debug_messages.append(str(msg)))
|
||||
|
||||
team.start(output="verbose")
|
||||
|
||||
assert len(on_start_calls) == 1
|
||||
assert len(on_complete_calls) == 1
|
||||
assert on_start_calls[0][1] == next(iter(team.tasks.keys()))
|
||||
assert on_complete_calls[0][1].raw == "done"
|
||||
assert all("Error in verbose task" not in message for message in debug_messages)
|
||||
@@ -5,6 +5,8 @@ Tests Mem0Adapter lazy loading and normalization.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import importlib
|
||||
from types import SimpleNamespace
|
||||
import pytest
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
@@ -174,3 +176,54 @@ class TestProtocolCompliance:
|
||||
|
||||
for method in required_methods:
|
||||
assert hasattr(Mem0Adapter, method), f"Missing method: {method}"
|
||||
|
||||
|
||||
class TestChromaKnowledgeAdapterWhereFilters:
|
||||
"""Tests for ChromaKnowledgeAdapter where-filter formatting."""
|
||||
|
||||
def _make_adapter(self):
|
||||
from praisonaiagents.knowledge.adapters.factories import ChromaKnowledgeAdapter
|
||||
|
||||
adapter = ChromaKnowledgeAdapter.__new__(ChromaKnowledgeAdapter)
|
||||
adapter.collection = MagicMock()
|
||||
adapter.collection.query.return_value = {
|
||||
"ids": [[]],
|
||||
"documents": [[]],
|
||||
"metadatas": [[]],
|
||||
"distances": [[]],
|
||||
}
|
||||
return adapter
|
||||
|
||||
def test_search_wraps_multiple_where_filters_with_and(self, monkeypatch):
|
||||
embedding_module = importlib.import_module("praisonaiagents.embedding")
|
||||
|
||||
adapter = self._make_adapter()
|
||||
monkeypatch.setattr(
|
||||
embedding_module,
|
||||
"embedding",
|
||||
lambda *args, **kwargs: SimpleNamespace(embeddings=[[0.1, 0.2]]),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
adapter.search("q", user_id="u1", agent_id="a1", run_id="r1")
|
||||
|
||||
where = adapter.collection.query.call_args.kwargs["where"]
|
||||
assert where == {
|
||||
"$and": [{"user_id": "u1"}, {"agent_id": "a1"}, {"run_id": "r1"}]
|
||||
}
|
||||
|
||||
def test_search_keeps_single_where_filter_unwrapped(self, monkeypatch):
|
||||
embedding_module = importlib.import_module("praisonaiagents.embedding")
|
||||
|
||||
adapter = self._make_adapter()
|
||||
monkeypatch.setattr(
|
||||
embedding_module,
|
||||
"embedding",
|
||||
lambda *args, **kwargs: SimpleNamespace(embeddings=[[0.1, 0.2]]),
|
||||
raising=False,
|
||||
)
|
||||
|
||||
adapter.search("q", user_id="u1")
|
||||
|
||||
where = adapter.collection.query.call_args.kwargs["where"]
|
||||
assert where == {"user_id": "u1"}
|
||||
@@ -223,3 +223,188 @@ class TestDefaultContextBuilder:
|
||||
|
||||
context = builder.build(results)
|
||||
assert "\n\n" in context
|
||||
|
||||
|
||||
class TestExtractValue:
|
||||
"""Tests for the _extract_value helper function."""
|
||||
|
||||
def test_extract_value_from_dict(self):
|
||||
"""Test extracting value from dictionary."""
|
||||
from praisonaiagents.rag.context import _extract_value
|
||||
|
||||
item = {"text": "test content", "metadata": {"source": "test.pdf"}}
|
||||
|
||||
assert _extract_value(item, "text") == "test content"
|
||||
assert _extract_value(item, "metadata")["source"] == "test.pdf"
|
||||
assert _extract_value(item, "nonexistent", "default") == "default"
|
||||
|
||||
def test_extract_value_from_searchresultitem(self):
|
||||
"""Test extracting value from SearchResultItem object."""
|
||||
from praisonaiagents.rag.context import _extract_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(
|
||||
text="test content",
|
||||
source="test.pdf",
|
||||
filename="test.pdf",
|
||||
metadata={"extra": "data"}
|
||||
)
|
||||
|
||||
assert _extract_value(item, "text") == "test content"
|
||||
assert _extract_value(item, "source") == "test.pdf"
|
||||
assert _extract_value(item, "filename") == "test.pdf"
|
||||
assert _extract_value(item, "metadata")["extra"] == "data"
|
||||
assert _extract_value(item, "nonexistent", "default") == "default"
|
||||
|
||||
|
||||
class TestExtractMetadataValue:
|
||||
"""Tests for the _extract_metadata_value helper function."""
|
||||
|
||||
def test_extract_metadata_value_from_metadata(self):
|
||||
"""Test extracting value from metadata dict."""
|
||||
from praisonaiagents.rag.context import _extract_metadata_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(text="content", source="fallback.pdf")
|
||||
metadata = {"source": "metadata.pdf"}
|
||||
|
||||
# Should prefer metadata over top-level
|
||||
result = _extract_metadata_value(item, metadata, "source", "")
|
||||
assert result == "metadata.pdf"
|
||||
|
||||
def test_extract_metadata_value_fallback_to_toplevel(self):
|
||||
"""Test fallback to top-level when metadata doesn't have key."""
|
||||
from praisonaiagents.rag.context import _extract_metadata_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(text="content", source="toplevel.pdf")
|
||||
metadata = {} # Empty metadata
|
||||
|
||||
# Should fall back to top-level source
|
||||
result = _extract_metadata_value(item, metadata, "source", "")
|
||||
assert result == "toplevel.pdf"
|
||||
|
||||
def test_extract_metadata_value_with_default(self):
|
||||
"""Test fallback to default when neither metadata nor top-level have key."""
|
||||
from praisonaiagents.rag.context import _extract_metadata_value
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
item = SearchResultItem(text="content") # No source
|
||||
metadata = {} # No source in metadata
|
||||
|
||||
# Should use default
|
||||
result = _extract_metadata_value(item, metadata, "source", "default.pdf")
|
||||
assert result == "default.pdf"
|
||||
|
||||
|
||||
class TestSearchResultItemSupport:
|
||||
"""Tests for SearchResultItem object support in context functions."""
|
||||
|
||||
def test_deduplicate_chunks_with_searchresultitem(self):
|
||||
"""Test deduplication with SearchResultItem objects."""
|
||||
from praisonaiagents.rag.context import deduplicate_chunks
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
SearchResultItem(text="Same content", source="a.pdf"),
|
||||
SearchResultItem(text="Same content", source="a.pdf"), # Duplicate
|
||||
SearchResultItem(text="Different content", source="b.pdf"),
|
||||
]
|
||||
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2 # One duplicate should be removed
|
||||
|
||||
def test_deduplicate_chunks_mixed_formats(self):
|
||||
"""Test deduplication with mixed dict and SearchResultItem formats."""
|
||||
from praisonaiagents.rag.context import deduplicate_chunks
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
{"text": "Content A", "metadata": {"source": "a.pdf"}},
|
||||
SearchResultItem(text="Content A", source="a.pdf"), # Should be deduped
|
||||
SearchResultItem(text="Content B", source="b.pdf"),
|
||||
]
|
||||
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2 # One duplicate should be removed
|
||||
|
||||
def test_deduplicate_with_top_level_source(self):
|
||||
"""Test deduplication considers top-level source from SearchResultItem."""
|
||||
from praisonaiagents.rag.context import deduplicate_chunks
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
SearchResultItem(text="Same text", source="source1.pdf"),
|
||||
SearchResultItem(text="Same text", source="source2.pdf"),
|
||||
]
|
||||
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2 # Different sources should not be deduped
|
||||
|
||||
def test_build_context_with_searchresultitem(self):
|
||||
"""Test building context with SearchResultItem objects."""
|
||||
from praisonaiagents.rag.context import build_context
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
SearchResultItem(
|
||||
text="First content",
|
||||
source="source1.pdf",
|
||||
filename="file1.pdf"
|
||||
),
|
||||
SearchResultItem(
|
||||
text="Second content",
|
||||
source="source2.pdf",
|
||||
filename="file2.pdf"
|
||||
),
|
||||
]
|
||||
|
||||
context, used = build_context(results)
|
||||
|
||||
assert "First content" in context
|
||||
assert "Second content" in context
|
||||
assert "file1.pdf" in context # Should use filename
|
||||
assert "file2.pdf" in context
|
||||
assert len(used) == 2
|
||||
|
||||
def test_build_context_source_fallback(self):
|
||||
"""Test that build_context falls back to top-level source when metadata is empty."""
|
||||
from praisonaiagents.rag.context import build_context
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
# SearchResultItem with source but empty metadata
|
||||
results = [
|
||||
SearchResultItem(
|
||||
text="Content with source",
|
||||
source="fallback.pdf",
|
||||
metadata={} # Empty metadata, should fall back to top-level source
|
||||
)
|
||||
]
|
||||
|
||||
context, used = build_context(results, include_source=True)
|
||||
|
||||
assert "Content with source" in context
|
||||
assert "fallback.pdf" in context # Should use top-level source
|
||||
assert len(used) == 1
|
||||
|
||||
def test_build_context_mixed_formats_with_sources(self):
|
||||
"""Test building context with mixed dict/SearchResultItem formats."""
|
||||
from praisonaiagents.rag.context import build_context
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
results = [
|
||||
{"text": "Dict content", "metadata": {"filename": "dict.pdf"}},
|
||||
SearchResultItem(
|
||||
text="Object content",
|
||||
filename="object.pdf",
|
||||
metadata={}
|
||||
),
|
||||
]
|
||||
|
||||
context, used = build_context(results, include_source=True)
|
||||
|
||||
assert "Dict content" in context
|
||||
assert "Object content" in context
|
||||
assert "dict.pdf" in context
|
||||
assert "object.pdf" in context # Should use top-level filename
|
||||
assert len(used) == 2
|
||||
@@ -8,6 +8,7 @@ from praisonaiagents.rag.context import (
|
||||
deduplicate_chunks,
|
||||
build_context,
|
||||
)
|
||||
from praisonaiagents.knowledge.models import SearchResultItem
|
||||
|
||||
|
||||
class TestDeduplicateChunksNormalization:
|
||||
@@ -122,3 +123,25 @@ class TestEdgeCases:
|
||||
context, used = build_context(results)
|
||||
assert len(used) == 1
|
||||
assert "valid content" in context
|
||||
|
||||
|
||||
class TestSearchResultItemCompatibility:
|
||||
"""Tests for SearchResultItem object compatibility."""
|
||||
|
||||
def test_deduplicate_uses_source_attribute_fallback(self):
|
||||
"""Different source attributes should produce unique chunks."""
|
||||
results = [
|
||||
SearchResultItem(text="same content", source="a.pdf"),
|
||||
SearchResultItem(text="same content", source="b.pdf"),
|
||||
]
|
||||
deduped = deduplicate_chunks(results)
|
||||
assert len(deduped) == 2
|
||||
|
||||
def test_build_context_uses_filename_attribute_fallback(self):
|
||||
"""Source label should use object filename/source attributes."""
|
||||
results = [
|
||||
SearchResultItem(text="chunk", filename="doc.md"),
|
||||
]
|
||||
context, used = build_context(results, include_source=True)
|
||||
assert len(used) == 1
|
||||
assert "doc.md" in context
|
||||
@@ -103,6 +103,35 @@ class TestHierarchicalSessionStore:
|
||||
"""Clean up test fixtures."""
|
||||
shutil.rmtree(self.temp_dir, ignore_errors=True)
|
||||
|
||||
def test_add_message_preserves_concurrent_writes(self):
|
||||
"""Concurrent add_message must not lose messages (same as DefaultSessionStore)."""
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
writer = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
reader = HierarchicalSessionStore(session_dir=tmpdir)
|
||||
|
||||
writer.add_user_message("session-1", "first")
|
||||
reader._load_session("session-1")
|
||||
writer.add_user_message("session-1", "second")
|
||||
|
||||
history = writer.get_chat_history("session-1")
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
def test_update_session_metadata_preserves_extended_fields(self):
|
||||
"""Metadata updates must not strip parent_id, snapshots, etc."""
|
||||
session_id = self.store.create_session(title="Parent")
|
||||
self.store.add_message(session_id, "user", "hello")
|
||||
parent_id = self.store.create_session(title="Child", parent_id=session_id)
|
||||
snapshot_id = self.store.create_snapshot(session_id, label="checkpoint")
|
||||
|
||||
assert self.store.update_session_metadata(session_id, model="gpt-4o-mini")
|
||||
|
||||
session = self.store.get_extended_session(session_id)
|
||||
assert session.metadata.get("model") == "gpt-4o-mini"
|
||||
assert session.title == "Parent"
|
||||
assert parent_id in session.children_ids
|
||||
assert any(s.id == snapshot_id for s in session.snapshots)
|
||||
|
||||
def test_create_session(self):
|
||||
"""Test creating a session."""
|
||||
session_id = self.store.create_session(title="Test Session")
|
||||
|
||||
@@ -305,6 +305,78 @@ class TestDefaultSessionStore:
|
||||
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."""
|
||||
@@ -336,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
|
||||
@@ -347,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,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.46"
|
||||
version = "1.6.48"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-21",
|
||||
"lastUpdated": "2026-05-30",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"status": "PARITY_ACHIEVED",
|
||||
@@ -1105,6 +1105,7 @@
|
||||
"WebSearchProvider"
|
||||
],
|
||||
"config_loader": [
|
||||
"AutoRagConfig",
|
||||
"SessionConfig"
|
||||
],
|
||||
"context": [
|
||||
@@ -1740,9 +1741,6 @@
|
||||
"sandbox": [],
|
||||
"session": [],
|
||||
"skills": [],
|
||||
"specialized": [
|
||||
"AutoRagConfig"
|
||||
],
|
||||
"specialized_agents": [],
|
||||
"streaming": [],
|
||||
"task": [],
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"version": "1.5.87",
|
||||
"lastUpdated": "2026-05-21",
|
||||
"lastUpdated": "2026-05-30",
|
||||
"generatedBy": "praisonai._dev.parity.generator",
|
||||
"sourceOfTruth": "Python SDK (praisonaiagents)",
|
||||
"summary": {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# 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
|
||||
|
||||
@@ -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.46.tar.gz"
|
||||
sha256 `curl -sL https://github.com/MervinPraison/PraisonAI/archive/refs/tags/v4.6.46.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."""
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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.46 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__":
|
||||
|
||||
@@ -85,27 +85,30 @@ class PraisonAIAdapter(BaseFrameworkAdapter):
|
||||
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})")
|
||||
|
||||
# Start the runtime on the shared background loop where it stays alive
|
||||
# and its asyncio primitives remain valid for the duration of this call
|
||||
run_sync(interactive_runtime.start())
|
||||
|
||||
centric_tools = create_agent_centric_tools(interactive_runtime)
|
||||
logger.info(f"Loaded {len(centric_tools)} InteractiveRuntime tools")
|
||||
|
||||
# Merge with tools_dict
|
||||
if tools_dict:
|
||||
tools_dict.update(centric_tools)
|
||||
else:
|
||||
tools_dict = centric_tools
|
||||
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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -24,6 +24,8 @@ _MISSING_MSG = (
|
||||
"Install with: pip install 'praisonai[valkey]'"
|
||||
)
|
||||
|
||||
_CLIENT_NAME = "praisonai_persistence_client"
|
||||
|
||||
|
||||
def create_valkey_client(
|
||||
host: str = "localhost",
|
||||
@@ -40,5 +42,6 @@ def create_valkey_client(
|
||||
addresses=addresses,
|
||||
credentials=creds,
|
||||
database_id=db,
|
||||
client_name=_CLIENT_NAME,
|
||||
)
|
||||
return GlideClientSync.create(config)
|
||||
@@ -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(
|
||||
|
||||
@@ -39,6 +39,12 @@ def __getattr__(name):
|
||||
elif name == 'create_deployment_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)
|
||||
@@ -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.
|
||||
@@ -46,6 +49,8 @@ class ToolResolver:
|
||||
_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__(
|
||||
@@ -59,12 +64,18 @@ class ToolResolver:
|
||||
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.
|
||||
@@ -257,35 +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 wrapper ToolRegistry (NEW - ahead of SDK paths)
|
||||
tool = self._resolve_from_wrapper_registry(name)
|
||||
if tool is not None:
|
||||
return tool
|
||||
|
||||
# 3. Check praisonaiagents.tools
|
||||
tool = self._resolve_from_praisonaiagents(name)
|
||||
if tool is not None:
|
||||
return tool
|
||||
|
||||
# 4. Check praisonai-tools package
|
||||
tool = self._resolve_from_praisonai_tools(name)
|
||||
if tool is not None:
|
||||
return tool
|
||||
|
||||
# 5. Check core SDK 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.
|
||||
@@ -394,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).
|
||||
@@ -422,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
|
||||
@@ -489,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)
|
||||
@@ -545,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.46"
|
||||
__version__ = "4.6.48"
|
||||
@@ -12,7 +12,7 @@ dependencies = [
|
||||
"rich>=13.7",
|
||||
"markdown>=3.5",
|
||||
"pyparsing>=3.0.0",
|
||||
"praisonaiagents>=1.6.46",
|
||||
"praisonaiagents>=1.6.48",
|
||||
"python-dotenv>=0.19.0",
|
||||
"litellm>=1.83.14,<2",
|
||||
"PyYAML>=6.0",
|
||||
@@ -28,7 +28,7 @@ praisonai-call = "praisonai.api.call:main"
|
||||
|
||||
[project.optional-dependencies]
|
||||
ui = [
|
||||
"aiui>=0.3.100",
|
||||
"aiui>=0.3.121,<0.4",
|
||||
]
|
||||
gradio = ["gradio>=4.26.0"]
|
||||
api = [
|
||||
@@ -47,14 +47,14 @@ agentops = ["agentops>=0.3.12"]
|
||||
langfuse = ["langfuse>=3.0.0"]
|
||||
langextract = ["langextract>=1.0.0"]
|
||||
chat = [
|
||||
"aiui>=0.3.100",
|
||||
"aiui>=0.3.121,<0.4",
|
||||
"tavily-python==0.5.0",
|
||||
"crawl4ai>=0.7.0",
|
||||
"playwright>=1.47.0",
|
||||
"rich"
|
||||
]
|
||||
code = [
|
||||
"aiui>=0.3.100",
|
||||
"aiui>=0.3.121,<0.4",
|
||||
"tavily-python==0.5.0",
|
||||
"crawl4ai>=0.7.0",
|
||||
"playwright>=1.47.0"
|
||||
@@ -73,7 +73,7 @@ daytona = [
|
||||
# Install via: pip install git+https://github.com/daytonaio/daytona-python
|
||||
]
|
||||
realtime = [
|
||||
"aiui>=0.3.100",
|
||||
"aiui>=0.3.121,<0.4",
|
||||
"tavily-python==0.5.0",
|
||||
"crawl4ai>=0.7.0",
|
||||
"playwright>=1.47.0", # required by crawl4ai — browsers still need: playwright install chromium
|
||||
@@ -135,7 +135,7 @@ all = [
|
||||
"slack-bolt>=1.18.0",
|
||||
"brotlicffi>=1.0.0",
|
||||
"aiohttp>=3.8.0",
|
||||
"aiui>=0.3.100", # PraisonAI UI — dashboard + chat for onboarded bots
|
||||
"aiui>=0.3.121,<0.4", # PraisonAI UI — dashboard + chat for onboarded bots
|
||||
# Storage backends
|
||||
"redis>=5.0.0",
|
||||
"pymongo>=4.6.0",
|
||||
@@ -158,7 +158,7 @@ bot = [
|
||||
"slack-bolt>=1.18.0",
|
||||
"brotlicffi>=1.0.0",
|
||||
"aiohttp>=3.8.0",
|
||||
"aiui>=0.3.100", # PraisonAI UI — dashboard + chat for onboarded bots
|
||||
"aiui>=0.3.121,<0.4", # PraisonAI UI — dashboard + chat for onboarded bots
|
||||
"ddgs>=9.0.0", # DuckDuckGo search for agent web tools
|
||||
"psutil>=5.9.0", # Shell tools (execute_command, list_processes, get_system_info)
|
||||
]
|
||||
@@ -199,7 +199,7 @@ xata = [
|
||||
claw = [
|
||||
# PraisonAI 🦞 — Full dashboard + bots + memory + knowledge + gateway
|
||||
# Install: pip install "praisonai[claw]"
|
||||
"aiui[all]>=0.3.100",
|
||||
"aiui[all]>=0.3.121,<0.4",
|
||||
"praisonaiagents[all]>=1.5.40",
|
||||
# UI + persistence
|
||||
"flask>=3.0.0",
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""Unit tests for the typing indicator renewal utility."""
|
||||
|
||||
import asyncio
|
||||
import sys
|
||||
import os
|
||||
import pytest
|
||||
|
||||
# Ensure the praisonai package is importable
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", ".."))
|
||||
|
||||
from praisonai.bots._typing_indicator import TypingIndicator, with_typing_renewal
|
||||
|
||||
|
||||
class TestTypingIndicator:
|
||||
"""Tests for the TypingIndicator class."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_creates_background_task(self):
|
||||
calls = []
|
||||
|
||||
async def _typing():
|
||||
calls.append(1)
|
||||
|
||||
indicator = TypingIndicator(interval=0.05)
|
||||
await indicator.start(_typing)
|
||||
assert indicator._task is not None
|
||||
assert not indicator._task.done()
|
||||
indicator.cancel()
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.allow_sleep
|
||||
async def test_cancel_stops_loop(self):
|
||||
calls = []
|
||||
|
||||
async def _typing():
|
||||
calls.append(1)
|
||||
|
||||
indicator = TypingIndicator(interval=0.05)
|
||||
await indicator.start(_typing)
|
||||
await asyncio.sleep(0.12) # Let it fire ~2 times
|
||||
count_at_cancel = len(calls)
|
||||
indicator.cancel()
|
||||
await asyncio.sleep(0.15) # Extra time after cancel
|
||||
assert len(calls) == count_at_cancel or len(calls) == count_at_cancel + 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_start_idempotent_when_already_running(self):
|
||||
"""Calling start() while already running should be a no-op."""
|
||||
calls = []
|
||||
|
||||
async def _typing():
|
||||
calls.append(1)
|
||||
|
||||
indicator = TypingIndicator(interval=1.0)
|
||||
await indicator.start(_typing)
|
||||
first_task = indicator._task
|
||||
|
||||
await indicator.start(_typing) # Second start — should not replace task
|
||||
assert indicator._task is first_task
|
||||
|
||||
indicator.cancel()
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_sync_typing_func_is_called(self):
|
||||
calls = []
|
||||
|
||||
def _sync_typing():
|
||||
calls.append(1)
|
||||
|
||||
indicator = TypingIndicator(interval=0.001)
|
||||
await indicator.start(_sync_typing)
|
||||
await asyncio.sleep(0.05) # fast_sleep makes this near-instant, still lets loop iterate
|
||||
indicator.cancel()
|
||||
assert len(calls) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.allow_sleep
|
||||
async def test_loop_continues_after_typing_func_exception(self):
|
||||
"""Errors in the typing func should not stop the renewal loop."""
|
||||
error_calls = 0
|
||||
ok_calls = []
|
||||
|
||||
async def _flaky():
|
||||
nonlocal error_calls
|
||||
if error_calls < 2:
|
||||
error_calls += 1
|
||||
raise RuntimeError("network blip")
|
||||
ok_calls.append(1)
|
||||
|
||||
indicator = TypingIndicator(interval=0.04)
|
||||
await indicator.start(_flaky)
|
||||
await asyncio.sleep(0.30)
|
||||
indicator.cancel()
|
||||
assert len(ok_calls) >= 1, "Loop should have continued after errors"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_is_done_after_cancel_and_await(self):
|
||||
async def _typing():
|
||||
pass
|
||||
|
||||
indicator = TypingIndicator(interval=0.05)
|
||||
await indicator.start(_typing)
|
||||
indicator.cancel()
|
||||
if indicator._task is not None:
|
||||
try:
|
||||
await indicator._task
|
||||
except (asyncio.CancelledError, Exception):
|
||||
pass
|
||||
assert indicator._task is None or indicator._task.done()
|
||||
|
||||
|
||||
class TestWithTypingRenewal:
|
||||
"""Tests for the with_typing_renewal helper."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_returns_operation_result(self):
|
||||
async def _typing():
|
||||
pass
|
||||
|
||||
async def _op():
|
||||
return "hello"
|
||||
|
||||
result = await with_typing_renewal(
|
||||
typing_func=_typing,
|
||||
operation_coro=_op(),
|
||||
interval=0.05,
|
||||
)
|
||||
assert result == "hello"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_called_at_least_once(self):
|
||||
calls = []
|
||||
|
||||
async def _typing():
|
||||
calls.append(1)
|
||||
|
||||
async def _op():
|
||||
await asyncio.sleep(0.001) # yield to let typing task run first
|
||||
|
||||
await with_typing_renewal(
|
||||
typing_func=_typing,
|
||||
operation_coro=_op(),
|
||||
interval=0.001,
|
||||
)
|
||||
assert len(calls) >= 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.allow_sleep
|
||||
async def test_typing_called_multiple_times_for_long_op(self):
|
||||
calls = []
|
||||
|
||||
async def _typing():
|
||||
calls.append(1)
|
||||
|
||||
await with_typing_renewal(
|
||||
typing_func=_typing,
|
||||
operation_coro=asyncio.sleep(0.25),
|
||||
interval=0.05,
|
||||
)
|
||||
assert len(calls) >= 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_exception_propagates_and_task_cleaned_up(self):
|
||||
calls = []
|
||||
|
||||
async def _typing():
|
||||
calls.append(1)
|
||||
|
||||
async def _failing():
|
||||
await asyncio.sleep(0.001)
|
||||
raise ValueError("agent error")
|
||||
|
||||
with pytest.raises(ValueError, match="agent error"):
|
||||
await with_typing_renewal(
|
||||
typing_func=_typing,
|
||||
operation_coro=_failing(),
|
||||
interval=0.02,
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_task_fully_cleaned_up_after_completion(self):
|
||||
"""After with_typing_renewal returns, the background task must be done."""
|
||||
created_indicators = []
|
||||
|
||||
original_init = TypingIndicator.__init__
|
||||
|
||||
def _tracking_init(self, interval=4.0):
|
||||
original_init(self, interval=interval)
|
||||
created_indicators.append(self)
|
||||
|
||||
TypingIndicator.__init__ = _tracking_init
|
||||
try:
|
||||
async def _typing():
|
||||
pass
|
||||
|
||||
async def _op():
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
await with_typing_renewal(
|
||||
typing_func=_typing,
|
||||
operation_coro=_op(),
|
||||
interval=0.02,
|
||||
)
|
||||
|
||||
await asyncio.sleep(0.02) # Give event loop a tick to clean up
|
||||
for indicator in created_indicators:
|
||||
assert indicator._task is None or indicator._task.done(), \
|
||||
"Background task must be done after with_typing_renewal returns"
|
||||
finally:
|
||||
TypingIndicator.__init__ = original_init
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.allow_sleep
|
||||
async def test_interval_parameter_respected(self):
|
||||
call_times = []
|
||||
|
||||
async def _typing():
|
||||
call_times.append(asyncio.get_event_loop().time())
|
||||
|
||||
await with_typing_renewal(
|
||||
typing_func=_typing,
|
||||
operation_coro=asyncio.sleep(0.30),
|
||||
interval=0.10,
|
||||
)
|
||||
|
||||
assert len(call_times) >= 2
|
||||
for i in range(1, len(call_times)):
|
||||
gap = call_times[i] - call_times[i - 1]
|
||||
# Allow ±50% tolerance for CI timing jitter
|
||||
assert gap >= 0.05, f"Gap {gap:.3f}s too small (expected ~0.10s)"
|
||||
assert gap <= 0.20, f"Gap {gap:.3f}s too large (expected ~0.10s)"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_none_result_is_returned(self):
|
||||
"""Coroutines that return None should work correctly."""
|
||||
async def _typing():
|
||||
pass
|
||||
|
||||
async def _op():
|
||||
pass # returns None
|
||||
|
||||
result = await with_typing_renewal(
|
||||
typing_func=_typing,
|
||||
operation_coro=_op(),
|
||||
interval=0.05,
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@@ -13,6 +13,22 @@ from praisonaiagents.gateway.config import GatewayConfig
|
||||
from praisonai.gateway.auth import assert_external_bind_safe, GatewayStartupError
|
||||
|
||||
|
||||
class TestGatewayAuthTokenEnvSync:
|
||||
"""Test gateway auth token env synchronization."""
|
||||
|
||||
def test_config_token_overrides_stale_env_token(self, monkeypatch):
|
||||
"""Gateway should export configured token for all auth paths."""
|
||||
from praisonai.gateway.server import WebSocketGateway
|
||||
|
||||
monkeypatch.setenv("GATEWAY_AUTH_TOKEN", "stale-token")
|
||||
config = GatewayConfig(bind_host="127.0.0.1", auth_token="fresh-token")
|
||||
|
||||
gateway = WebSocketGateway(config=config)
|
||||
|
||||
assert gateway.config.auth_token == "fresh-token"
|
||||
assert os.environ["GATEWAY_AUTH_TOKEN"] == "fresh-token"
|
||||
|
||||
|
||||
class TestLoopbackDetection:
|
||||
"""Test loopback interface detection."""
|
||||
|
||||
|
||||
@@ -0,0 +1,140 @@
|
||||
"""
|
||||
Unit tests for unicode_utils.py — ASCII-safe error message helpers.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
|
||||
from praisonai.gateway.unicode_utils import (
|
||||
safe_error_message,
|
||||
safe_log_message,
|
||||
extract_root_cause_from_error,
|
||||
)
|
||||
|
||||
|
||||
class TestSafeErrorMessage:
|
||||
"""Tests for safe_error_message()."""
|
||||
|
||||
def test_plain_ascii_unchanged(self):
|
||||
assert safe_error_message("hello world") == "hello world"
|
||||
|
||||
def test_exception_converted(self):
|
||||
exc = ValueError("something went wrong")
|
||||
result = safe_error_message(exc)
|
||||
assert result == "something went wrong"
|
||||
|
||||
def test_warning_symbol_replaced(self):
|
||||
# U+26A0 WARNING SIGN -> '!'
|
||||
result = safe_error_message("\u26a0 quota exceeded")
|
||||
assert result == "! quota exceeded"
|
||||
|
||||
def test_smart_quotes_replaced(self):
|
||||
result = safe_error_message("\u201chello\u201d")
|
||||
assert result == '"hello"'
|
||||
|
||||
def test_em_dash_replaced(self):
|
||||
result = safe_error_message("error\u2014details")
|
||||
assert result == "error--details"
|
||||
|
||||
def test_accented_chars_mapped(self):
|
||||
result = safe_error_message("caf\u00e9") # café
|
||||
assert result == "cafe"
|
||||
|
||||
def test_accented_uppercase_mapped(self):
|
||||
result = safe_error_message("\u00c9l\u00e8ve") # Élève
|
||||
assert result == "Eleve"
|
||||
|
||||
def test_nfkd_fallback_for_unlisted_accents(self):
|
||||
# 'ő' (U+0151, o with double acute) is not in _LATIN1_MAP but NFKD
|
||||
# decomposes it to 'o' + combining double acute -> base letter 'o'
|
||||
result = safe_error_message("\u0151")
|
||||
assert result == "o"
|
||||
|
||||
def test_emoji_replaced_with_question_mark(self):
|
||||
result = safe_error_message("bot \U0001f916 ready") # 🤖
|
||||
assert "?" in result
|
||||
|
||||
def test_truncation(self):
|
||||
long_msg = "a" * 600
|
||||
result = safe_error_message(long_msg, max_len=100)
|
||||
assert len(result) == 100
|
||||
assert result.endswith("...")
|
||||
|
||||
def test_empty_string_returns_default(self):
|
||||
result = safe_error_message(" ")
|
||||
assert result == "Error occurred"
|
||||
|
||||
def test_none_type_exception(self):
|
||||
exc = Exception("")
|
||||
result = safe_error_message(exc)
|
||||
# str(Exception("")) is "" -> falls back to type name
|
||||
assert result # not empty
|
||||
|
||||
def test_output_is_ascii(self):
|
||||
"""Output must be entirely 7-bit ASCII for all tested inputs."""
|
||||
inputs = [
|
||||
"\u26a0 API Error 429",
|
||||
"caf\u00e9 timeout",
|
||||
"Unicode \u03b1\u03b2\u03b3 chars",
|
||||
"\U0001f4a5 explosion",
|
||||
]
|
||||
for text in inputs:
|
||||
result = safe_error_message(text)
|
||||
result.encode("ascii") # raises UnicodeEncodeError if not ASCII
|
||||
|
||||
|
||||
class TestSafeLogMessage:
|
||||
"""Tests for safe_log_message()."""
|
||||
|
||||
def test_plain_ascii_unchanged(self):
|
||||
assert safe_log_message("plain text") == "plain text"
|
||||
|
||||
def test_unicode_preserved(self):
|
||||
# Regular Unicode (non-surrogate) must survive unchanged
|
||||
assert safe_log_message("caf\u00e9") == "caf\u00e9"
|
||||
|
||||
def test_exception_stringified(self):
|
||||
exc = RuntimeError("oops")
|
||||
assert safe_log_message(exc) == "oops"
|
||||
|
||||
def test_lone_surrogate_replaced(self):
|
||||
# Lone surrogates cannot be encoded in UTF-8
|
||||
text = "bad\ud800char"
|
||||
result = safe_log_message(text)
|
||||
assert "\ud800" not in result
|
||||
|
||||
|
||||
class TestExtractRootCause:
|
||||
"""Tests for extract_root_cause_from_error()."""
|
||||
|
||||
def test_openai_error_code_pattern(self):
|
||||
msg = "Error code: 429 - You exceeded your current quota"
|
||||
result = extract_root_cause_from_error(msg)
|
||||
assert "429" in result
|
||||
assert "exceeded" in result.lower()
|
||||
|
||||
def test_quota_keyword(self):
|
||||
result = extract_root_cause_from_error("insufficient_quota for this model")
|
||||
assert "quota" in result.lower()
|
||||
|
||||
def test_rate_limit_keyword(self):
|
||||
result = extract_root_cause_from_error("Rate limit exceeded, slow down")
|
||||
assert "rate limit" in result.lower() or "Rate limit" in result
|
||||
|
||||
def test_authentication_keyword(self):
|
||||
result = extract_root_cause_from_error("Authentication failed: bad key")
|
||||
assert "auth" in result.lower()
|
||||
|
||||
def test_timeout_keyword(self):
|
||||
result = extract_root_cause_from_error("connection timeout reached")
|
||||
assert "timeout" in result.lower()
|
||||
|
||||
def test_unrecognised_returns_original(self):
|
||||
original = "some random error text"
|
||||
assert extract_root_cause_from_error(original) == original
|
||||
|
||||
def test_result_goes_through_safe_error_message(self):
|
||||
"""Callers always pass result to safe_error_message; ensure chain is safe."""
|
||||
raw = "Error code: 429 - \u26a0 quota exceeded"
|
||||
root = extract_root_cause_from_error(raw)
|
||||
safe = safe_error_message(root)
|
||||
safe.encode("ascii") # must not raise
|
||||
@@ -298,6 +298,38 @@ def test_backward_compatible_aliases():
|
||||
assert ManagedBackendConfig == ManagedConfig
|
||||
|
||||
|
||||
def test_local_managed_persist_state_preserves_messages():
|
||||
"""_persist_state must not overwrite messages added concurrently on disk."""
|
||||
import tempfile
|
||||
|
||||
from praisonaiagents.session.store import DefaultSessionStore
|
||||
from praisonai.integrations.managed_local import LocalManagedAgent
|
||||
|
||||
with tempfile.TemporaryDirectory() as session_dir:
|
||||
writer = DefaultSessionStore(session_dir=session_dir)
|
||||
reader = DefaultSessionStore(session_dir=session_dir)
|
||||
|
||||
session_id = "managed-race-session"
|
||||
writer.add_user_message(session_id, "first")
|
||||
reader._load_session(session_id)
|
||||
writer.add_user_message(session_id, "second")
|
||||
|
||||
agent = LocalManagedAgent(session_store=reader)
|
||||
agent._session_id = session_id
|
||||
agent.agent_id = "agent-race"
|
||||
agent.total_input_tokens = 42
|
||||
agent._persist_state()
|
||||
|
||||
writer.invalidate_cache(session_id)
|
||||
history = writer.get_chat_history(session_id)
|
||||
assert len(history) == 2
|
||||
assert history[1]["content"] == "second"
|
||||
|
||||
session = writer.get_session(session_id)
|
||||
assert session.metadata.get("agent_id") == "agent-race"
|
||||
assert session.metadata.get("total_input_tokens") == 42
|
||||
|
||||
|
||||
@patch('praisonai.integrations.managed_agents.logger')
|
||||
def test_logging_integration(mock_logger):
|
||||
"""Test that managed agents include proper logging."""
|
||||
|
||||
@@ -240,6 +240,29 @@ class TestAgentSchedulerExecution:
|
||||
# Should stop after first failure + backoff signal, not complete all retries
|
||||
assert mock_agent.start.call_count == 1
|
||||
|
||||
def test_execute_with_retry_timeout_returns_quickly(self):
|
||||
"""Test timeout handling does not block until worker completion."""
|
||||
mock_agent = Mock()
|
||||
|
||||
def slow_start(_task):
|
||||
delay_event = threading.Event()
|
||||
delay_event.wait(timeout=1.2)
|
||||
return "late"
|
||||
|
||||
mock_agent.start = Mock(side_effect=slow_start)
|
||||
scheduler = AgentScheduler(mock_agent, "Test task", timeout=0.1)
|
||||
scheduler._stop_event.wait = Mock(return_value=False)
|
||||
|
||||
start = time.time()
|
||||
scheduler._execute_with_retry(max_retries=1)
|
||||
duration = time.time() - start
|
||||
timing_buffer_seconds = 1.0
|
||||
max_expected_duration = scheduler.timeout + timing_buffer_seconds
|
||||
|
||||
assert scheduler._failure_count == 1
|
||||
assert scheduler._success_count == 0
|
||||
assert duration < max_expected_duration
|
||||
|
||||
|
||||
class TestAgentSchedulerCallbacks:
|
||||
"""Test AgentScheduler callback functionality."""
|
||||
|
||||
@@ -102,7 +102,7 @@ class TestStopAlwaysClearsIsRunning:
|
||||
scheduler._stop_event.set()
|
||||
|
||||
# The exception should be caught, logged, and stop should return True
|
||||
with patch("praisonai.async_agent_scheduler.logger") as mock_logger:
|
||||
with patch("praisonai.scheduler.async_agent_scheduler.logger") as mock_logger:
|
||||
result = await scheduler.stop()
|
||||
|
||||
assert result is True
|
||||
@@ -176,7 +176,7 @@ class TestStopTimeoutCancelPath:
|
||||
scheduler._stop_event.set()
|
||||
|
||||
with patch("asyncio.wait_for", side_effect=asyncio.TimeoutError), \
|
||||
patch("praisonai.async_agent_scheduler.logger") as mock_logger:
|
||||
patch("praisonai.scheduler.async_agent_scheduler.logger") as mock_logger:
|
||||
await scheduler.stop()
|
||||
|
||||
# The error should have been logged
|
||||
|
||||
@@ -19,6 +19,7 @@ parameter and would fail fixture resolution.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import unittest
|
||||
|
||||
from praisonai._async_bridge import _BG, run_sync
|
||||
@@ -68,6 +69,23 @@ class TestRunSync(unittest.TestCase):
|
||||
|
||||
self.assertTrue(run_sync(nested_call()))
|
||||
|
||||
def test_timeout_cancels_coroutine_and_runs_finally(self):
|
||||
cleanup_done = threading.Event()
|
||||
|
||||
async def never_finishes() -> None:
|
||||
try:
|
||||
await asyncio.Event().wait()
|
||||
finally:
|
||||
cleanup_done.set()
|
||||
|
||||
with self.assertRaises(TimeoutError):
|
||||
run_sync(never_finishes(), timeout=0.01)
|
||||
|
||||
self.assertTrue(
|
||||
cleanup_done.wait(timeout=2.0),
|
||||
"timed out coroutine should be cancelled and run its cleanup",
|
||||
)
|
||||
|
||||
|
||||
class TestBackgroundThread(unittest.TestCase):
|
||||
def test_thread_is_daemon_and_alive(self):
|
||||
|
||||
@@ -244,3 +244,39 @@ class TestThreadSafety:
|
||||
assert main_mod._typer_commands_cache is None, (
|
||||
"Cache must remain None after failure to allow retries"
|
||||
)
|
||||
|
||||
async def test_aclose_releases_both_sync_and_async_clients(self):
|
||||
"""aclose() should close both client types for mixed-mode usage."""
|
||||
import praisonai.auto as auto
|
||||
|
||||
class DummyOpenAI:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
def close(self):
|
||||
self.closed = True
|
||||
|
||||
class DummyAsyncOpenAI:
|
||||
def __init__(self):
|
||||
self.closed = False
|
||||
|
||||
async def close(self):
|
||||
self.closed = True
|
||||
|
||||
generator = auto.BaseAutoGenerator(config_list=[{
|
||||
"model": "gpt-4o-mini",
|
||||
"api_key": "test-key",
|
||||
"base_url": None,
|
||||
}])
|
||||
|
||||
sync_client = DummyOpenAI()
|
||||
async_client = DummyAsyncOpenAI()
|
||||
generator._openai_client = sync_client
|
||||
generator._async_openai_client = async_client
|
||||
|
||||
await generator.aclose()
|
||||
|
||||
assert sync_client.closed is True
|
||||
assert async_client.closed is True
|
||||
assert generator._openai_client is None
|
||||
assert generator._async_openai_client is None
|
||||
@@ -459,6 +459,47 @@ class TestCLIArgumentParsing:
|
||||
with patch.object(sys, 'argv', ['praisonai', '--image', '/path/to/image.png']):
|
||||
PraisonAI() # Just verify no exception is raised
|
||||
|
||||
def test_extract_cli_config_includes_new_yaml_parity_flags(self):
|
||||
"""Test YAML CLI parity extraction includes planning/web/handoff flags."""
|
||||
from types import SimpleNamespace
|
||||
from praisonai.cli import PraisonAI
|
||||
|
||||
app = PraisonAI()
|
||||
app.args = SimpleNamespace(
|
||||
trust=False,
|
||||
tool_timeout=None,
|
||||
planning_tools=None,
|
||||
planning=True,
|
||||
web=True,
|
||||
web_fetch=True,
|
||||
acp=False,
|
||||
lsp=False,
|
||||
autonomy=None,
|
||||
guardrail=None,
|
||||
approval=None,
|
||||
approve_all_tools=None,
|
||||
approval_timeout=None,
|
||||
stream=False,
|
||||
stream_metrics=False,
|
||||
handoff='writer,reviewer',
|
||||
handoff_policy='summary',
|
||||
handoff_timeout=12.0,
|
||||
handoff_max_depth=4,
|
||||
handoff_max_concurrent=2,
|
||||
handoff_detect_cycles='false',
|
||||
)
|
||||
cli_config = app._extract_cli_config_for_yaml()
|
||||
|
||||
assert cli_config['planning'] is True
|
||||
assert cli_config['web'] is True
|
||||
assert cli_config['web_fetch'] is True
|
||||
assert cli_config['handoff'] == 'writer,reviewer'
|
||||
assert cli_config['handoff_policy'] == 'summary'
|
||||
assert cli_config['handoff_timeout'] == 12.0
|
||||
assert cli_config['handoff_max_depth'] == 4
|
||||
assert cli_config['handoff_max_concurrent'] == 2
|
||||
assert cli_config['handoff_detect_cycles'] == 'false'
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v", "--tb=short", "-x"])
|
||||
@@ -0,0 +1,20 @@
|
||||
import inspect
|
||||
|
||||
from praisonai.integration.host_app import configure_host
|
||||
from praisonai.integration.gateway_host import run_integrated_gateway
|
||||
|
||||
EXPECTED_DEFAULT_GATEWAY_PORT = 8765
|
||||
|
||||
|
||||
def test_run_integrated_gateway_default_port_matches_cli():
|
||||
assert (
|
||||
inspect.signature(run_integrated_gateway).parameters["port"].default
|
||||
== EXPECTED_DEFAULT_GATEWAY_PORT
|
||||
)
|
||||
|
||||
|
||||
def test_configure_host_accepts_agents_file_kwarg():
|
||||
assert any(
|
||||
param.kind is inspect.Parameter.VAR_KEYWORD
|
||||
for param in inspect.signature(configure_host).parameters.values()
|
||||
)
|
||||
@@ -84,6 +84,15 @@ class TestServeUnifiedCommands:
|
||||
|
||||
# Should show help for ui command
|
||||
assert "ui" in result.output.lower() or "chainlit" in result.output.lower() or result.exit_code == 0
|
||||
|
||||
def test_serve_ui_gateway_command_exists(self):
|
||||
"""Test serve ui-gateway command is registered."""
|
||||
from praisonai.cli.commands.serve import app
|
||||
|
||||
result = runner.invoke(app, ["ui-gateway", "--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "ui-gateway" in result.output.lower()
|
||||
|
||||
def test_serve_rag_command_exists(self):
|
||||
"""Test serve rag command is registered."""
|
||||
@@ -189,6 +198,14 @@ class TestServeCommandOptions:
|
||||
result = runner.invoke(app, ["mcp", "--help"])
|
||||
assert "--transport" in result.output or "-T" in result.output
|
||||
|
||||
def test_serve_ui_gateway_has_agents_option(self):
|
||||
"""Test serve ui-gateway has --agents option."""
|
||||
from praisonai.cli.commands.serve import app
|
||||
|
||||
result = runner.invoke(app, ["ui-gateway", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "--agents" in result.output or "-a" in result.output
|
||||
|
||||
|
||||
class TestDeprecationWarnings:
|
||||
"""Test deprecation warnings for old commands."""
|
||||
|
||||
Generated
+627
-15
@@ -302,7 +302,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "aiui"
|
||||
version = "0.3.100"
|
||||
version = "0.3.121"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pydantic" },
|
||||
@@ -314,26 +314,36 @@ dependencies = [
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
{ name = "watchfiles" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a6/24/e9576dedb1ed7af67eb07d2d9a3b9932d4ecf25f023f6e4962a967bfc2cc/aiui-0.3.100.tar.gz", hash = "sha256:91ded6563f46b8c0656f4d7a97c133f1e290902031e123d205a0ccf6c83ce4fb", size = 4286499, upload-time = "2026-03-31T21:55:37.037Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/11/ca7022b31b2c13a0178691bf8bce30419d2bfd75fe1c90bbafac29bcd4e7/aiui-0.3.121.tar.gz", hash = "sha256:1cb9cb8578a1bda2e9f80ee4d8f820f69db65954feb819bca68f110178dd9b29", size = 4747662, upload-time = "2026-05-21T10:43:54.16Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/ec/e6953c6117054a47430a3f23a818af0d668de5bd6cb7988a5d03ded9c84b/aiui-0.3.100-py3-none-any.whl", hash = "sha256:ec9e28a72c42efe82a84e1c98d558dbf03c50c4c463a2fb3a78de856fffa6d72", size = 815004, upload-time = "2026-03-31T21:55:35.191Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/43/ae2e14e8dde131a75bb278dd91179b42697b44299c63424e3ec4077c612c/aiui-0.3.121-py3-none-any.whl", hash = "sha256:4e4222460ec01206e435c28ee2c6799ce4d3ad8ddb63d188ed7c69c5efcd8226", size = 1020565, upload-time = "2026-05-21T10:43:56.232Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
all = [
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "asyncpg" },
|
||||
{ name = "botbuilder-core" },
|
||||
{ name = "botbuilder-schema" },
|
||||
{ name = "chonkie" },
|
||||
{ name = "chromadb" },
|
||||
{ name = "discord-py", extra = ["voice"] },
|
||||
{ name = "fastapi" },
|
||||
{ name = "langchain" },
|
||||
{ name = "langchain-core" },
|
||||
{ name = "litellm" },
|
||||
{ name = "llama-index" },
|
||||
{ name = "markitdown", extra = ["all"] },
|
||||
{ name = "mcp" },
|
||||
{ name = "mem0ai", extra = ["graph"] },
|
||||
{ name = "praisonai", extra = ["api", "bot", "os"] },
|
||||
{ name = "praisonaiagents", extra = ["crawl", "knowledge", "search"] },
|
||||
{ name = "praisonaiagents", extra = ["a2ui", "crawl", "knowledge", "search"] },
|
||||
{ name = "psutil" },
|
||||
{ name = "python-telegram-bot" },
|
||||
{ name = "semantic-kernel" },
|
||||
{ name = "slack-bolt" },
|
||||
{ name = "slack-sdk" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
{ name = "sse-starlette" },
|
||||
]
|
||||
|
||||
@@ -421,6 +431,65 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/fa/e01228c2938de91d47b307831c62ab9e4001e747789d0b05baf779a6488c/async_timeout-4.0.3-py3-none-any.whl", hash = "sha256:7405140ff1230c310e51dc27b3145b9092d659ce68ff733fb0cefe3ee42be028", size = 5721, upload-time = "2023-08-10T16:35:55.203Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncpg"
|
||||
version = "0.31.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "async-timeout", marker = "python_full_version < '3.11'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/fe/cc/d18065ce2380d80b1bcce927c24a2642efd38918e33fd724bc4bca904877/asyncpg-0.31.0.tar.gz", hash = "sha256:c989386c83940bfbd787180f2b1519415e2d3d6277a70d9d0f0145ac73500735", size = 993667, upload-time = "2025-11-24T23:27:00.812Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/d9/507c80bdac2e95e5a525644af94b03fa7f9a44596a84bd48a6e80f854f92/asyncpg-0.31.0-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:831712dd3cf117eec68575a9b50da711893fd63ebe277fc155ecae1c6c9f0f61", size = 644865, upload-time = "2025-11-24T23:25:23.527Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/03/f93b5e543f65c5f504e91405e8d21bb9e600548be95032951a754781a41d/asyncpg-0.31.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:0b17c89312c2f4ccea222a3a6571f7df65d4ba2c0e803339bfc7bed46a96d3be", size = 639297, upload-time = "2025-11-24T23:25:25.192Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/1e/de2177e57e03a06e697f6c1ddf2a9a7fcfdc236ce69966f54ffc830fd481/asyncpg-0.31.0-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3faa62f997db0c9add34504a68ac2c342cfee4d57a0c3062fcf0d86c7f9cb1e8", size = 2816679, upload-time = "2025-11-24T23:25:26.718Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d0/98/1a853f6870ac7ad48383a948c8ff3c85dc278066a4d69fc9af7d3d4b1106/asyncpg-0.31.0-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ea599d45c361dfbf398cb67da7fd052affa556a401482d3ff1ee99bd68808a1", size = 2867087, upload-time = "2025-11-24T23:25:28.399Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/29/7e76f2a51f2360a7c90d2cf6d0d9b210c8bb0ae342edebd16173611a55c2/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:795416369c3d284e1837461909f58418ad22b305f955e625a4b3a2521d80a5f3", size = 2747631, upload-time = "2025-11-24T23:25:30.154Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/3f/716e10cb57c4f388248db46555e9226901688fbfabd0afb85b5e1d65d5a7/asyncpg-0.31.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:a8d758dac9d2e723e173d286ef5e574f0b350ec00e9186fce84d0fc5f6a8e6b8", size = 2855107, upload-time = "2025-11-24T23:25:31.888Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/ec/3ebae9dfb23a1bd3f68acfd4f795983b65b413291c0e2b0d982d6ae6c920/asyncpg-0.31.0-cp310-cp310-win32.whl", hash = "sha256:2d076d42eb583601179efa246c5d7ae44614b4144bc1c7a683ad1222814ed095", size = 521990, upload-time = "2025-11-24T23:25:33.402Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/20/b4/9fbb4b0af4e36d96a61d026dd37acab3cf521a70290a09640b215da5ab7c/asyncpg-0.31.0-cp310-cp310-win_amd64.whl", hash = "sha256:9ea33213ac044171f4cac23740bed9a3805abae10e7025314cfbd725ec670540", size = 581629, upload-time = "2025-11-24T23:25:34.846Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/17/cc02bc49bc350623d050fa139e34ea512cd6e020562f2a7312a7bcae4bc9/asyncpg-0.31.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:eee690960e8ab85063ba93af2ce128c0f52fd655fdff9fdb1a28df01329f031d", size = 643159, upload-time = "2025-11-24T23:25:36.443Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/62/4ded7d400a7b651adf06f49ea8f73100cca07c6df012119594d1e3447aa6/asyncpg-0.31.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:2657204552b75f8288de08ca60faf4a99a65deef3a71d1467454123205a88fab", size = 638157, upload-time = "2025-11-24T23:25:37.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/5b/4179538a9a72166a0bf60ad783b1ef16efb7960e4d7b9afe9f77a5551680/asyncpg-0.31.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a429e842a3a4b4ea240ea52d7fe3f82d5149853249306f7ff166cb9948faa46c", size = 2918051, upload-time = "2025-11-24T23:25:39.461Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e6/35/c27719ae0536c5b6e61e4701391ffe435ef59539e9360959240d6e47c8c8/asyncpg-0.31.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0807be46c32c963ae40d329b3a686356e417f674c976c07fa49f1b30303f109", size = 2972640, upload-time = "2025-11-24T23:25:41.512Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/43/f4/01ebb9207f29e645a64699b9ce0eefeff8e7a33494e1d29bb53736f7766b/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e5d5098f63beeae93512ee513d4c0c53dc12e9aa2b7a1af5a81cddf93fe4e4da", size = 2851050, upload-time = "2025-11-24T23:25:43.153Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/f4/03ff1426acc87be0f4e8d40fa2bff5c3952bef0080062af9efc2212e3be8/asyncpg-0.31.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37fc6c00a814e18eef51833545d1891cac9aa69140598bb076b4cd29b3e010b9", size = 2962574, upload-time = "2025-11-24T23:25:44.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c7/39/cc788dfca3d4060f9d93e67be396ceec458dfc429e26139059e58c2c244d/asyncpg-0.31.0-cp311-cp311-win32.whl", hash = "sha256:5a4af56edf82a701aece93190cc4e094d2df7d33f6e915c222fb09efbb5afc24", size = 521076, upload-time = "2025-11-24T23:25:46.486Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/fc/735af5384c029eb7f1ca60ccb8fa95521dbdaeef788edf4cecfc604c3cab/asyncpg-0.31.0-cp311-cp311-win_amd64.whl", hash = "sha256:480c4befbdf079c14c9ca43c8c5e1fe8b6296c96f1f927158d4f1e750aacc047", size = 584980, upload-time = "2025-11-24T23:25:47.938Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/a6/59d0a146e61d20e18db7396583242e32e0f120693b67a8de43f1557033e2/asyncpg-0.31.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:b44c31e1efc1c15188ef183f287c728e2046abb1d26af4d20858215d50d91fad", size = 662042, upload-time = "2025-11-24T23:25:49.578Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/36/01/ffaa189dcb63a2471720615e60185c3f6327716fdc0fc04334436fbb7c65/asyncpg-0.31.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0c89ccf741c067614c9b5fc7f1fc6f3b61ab05ae4aaa966e6fd6b93097c7d20d", size = 638504, upload-time = "2025-11-24T23:25:51.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9f/62/3f699ba45d8bd24c5d65392190d19656d74ff0185f42e19d0bbd973bb371/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:12b3b2e39dc5470abd5e98c8d3373e4b1d1234d9fbdedf538798b2c13c64460a", size = 3426241, upload-time = "2025-11-24T23:25:53.278Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8c/d1/a867c2150f9c6e7af6462637f613ba67f78a314b00db220cd26ff559d532/asyncpg-0.31.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:aad7a33913fb8bcb5454313377cc330fbb19a0cd5faa7272407d8a0c4257b671", size = 3520321, upload-time = "2025-11-24T23:25:54.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7a/1a/cce4c3f246805ecd285a3591222a2611141f1669d002163abef999b60f98/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:3df118d94f46d85b2e434fd62c84cb66d5834d5a890725fe625f498e72e4d5ec", size = 3316685, upload-time = "2025-11-24T23:25:57.43Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/40/ae/0fc961179e78cc579e138fad6eb580448ecae64908f95b8cb8ee2f241f67/asyncpg-0.31.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:bd5b6efff3c17c3202d4b37189969acf8927438a238c6257f66be3c426beba20", size = 3471858, upload-time = "2025-11-24T23:25:59.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/52/b2/b20e09670be031afa4cbfabd645caece7f85ec62d69c312239de568e058e/asyncpg-0.31.0-cp312-cp312-win32.whl", hash = "sha256:027eaa61361ec735926566f995d959ade4796f6a49d3bde17e5134b9964f9ba8", size = 527852, upload-time = "2025-11-24T23:26:01.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/f0/f2ed1de154e15b107dc692262395b3c17fc34eafe2a78fc2115931561730/asyncpg-0.31.0-cp312-cp312-win_amd64.whl", hash = "sha256:72d6bdcbc93d608a1158f17932de2321f68b1a967a13e014998db87a72ed3186", size = 597175, upload-time = "2025-11-24T23:26:02.564Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/11/97b5c2af72a5d0b9bc3fa30cd4b9ce22284a9a943a150fdc768763caf035/asyncpg-0.31.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c204fab1b91e08b0f47e90a75d1b3c62174dab21f670ad6c5d0f243a228f015b", size = 661111, upload-time = "2025-11-24T23:26:04.467Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/71/157d611c791a5e2d0423f09f027bd499935f0906e0c2a416ce712ba51ef3/asyncpg-0.31.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:54a64f91839ba59008eccf7aad2e93d6e3de688d796f35803235ea1c4898ae1e", size = 636928, upload-time = "2025-11-24T23:26:05.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/fc/9e3486fb2bbe69d4a867c0b76d68542650a7ff1574ca40e84c3111bb0c6e/asyncpg-0.31.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c0e0822b1038dc7253b337b0f3f676cadc4ac31b126c5d42691c39691962e403", size = 3424067, upload-time = "2025-11-24T23:26:07.957Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/c6/8c9d076f73f07f995013c791e018a1cd5f31823c2a3187fc8581706aa00f/asyncpg-0.31.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bef056aa502ee34204c161c72ca1f3c274917596877f825968368b2c33f585f4", size = 3518156, upload-time = "2025-11-24T23:26:09.591Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ae/3b/60683a0baf50fbc546499cfb53132cb6835b92b529a05f6a81471ab60d0c/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:0bfbcc5b7ffcd9b75ab1558f00db2ae07db9c80637ad1b2469c43df79d7a5ae2", size = 3319636, upload-time = "2025-11-24T23:26:11.168Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/dc/8487df0f69bd398a61e1792b3cba0e47477f214eff085ba0efa7eac9ce87/asyncpg-0.31.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:22bc525ebbdc24d1261ecbf6f504998244d4e3be1721784b5f64664d61fbe602", size = 3472079, upload-time = "2025-11-24T23:26:13.164Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/a1/c5bbeeb8531c05c89135cb8b28575ac2fac618bcb60119ee9696c3faf71c/asyncpg-0.31.0-cp313-cp313-win32.whl", hash = "sha256:f890de5e1e4f7e14023619399a471ce4b71f5418cd67a51853b9910fdfa73696", size = 527606, upload-time = "2025-11-24T23:26:14.78Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/91/66/b25ccb84a246b470eb943b0107c07edcae51804912b824054b3413995a10/asyncpg-0.31.0-cp313-cp313-win_amd64.whl", hash = "sha256:dc5f2fa9916f292e5c5c8b2ac2813763bcd7f58e130055b4ad8a0531314201ab", size = 596569, upload-time = "2025-11-24T23:26:16.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/36/e9450d62e84a13aea6580c83a47a437f26c7ca6fa0f0fd40b6670793ea30/asyncpg-0.31.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f6b56b91bb0ffc328c4e3ed113136cddd9deefdf5f79ab448598b9772831df44", size = 660867, upload-time = "2025-11-24T23:26:17.631Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/82/4b/1d0a2b33b3102d210439338e1beea616a6122267c0df459ff0265cd5807a/asyncpg-0.31.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:334dec28cf20d7f5bb9e45b39546ddf247f8042a690bff9b9573d00086e69cb5", size = 638349, upload-time = "2025-11-24T23:26:19.689Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/aa/e7f7ac9a7974f08eff9183e392b2d62516f90412686532d27e196c0f0eeb/asyncpg-0.31.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:98cc158c53f46de7bb677fd20c417e264fc02b36d901cc2a43bd6cb0dc6dbfd2", size = 3410428, upload-time = "2025-11-24T23:26:21.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/de/bf1b60de3dede5c2731e6788617a512bc0ebd9693eac297ee74086f101d7/asyncpg-0.31.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9322b563e2661a52e3cdbc93eed3be7748b289f792e0011cb2720d278b366ce2", size = 3471678, upload-time = "2025-11-24T23:26:23.627Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/78/fc3ade003e22d8bd53aaf8f75f4be48f0b460fa73738f0391b9c856a9147/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19857a358fc811d82227449b7ca40afb46e75b33eb8897240c3839dd8b744218", size = 3313505, upload-time = "2025-11-24T23:26:25.235Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bf/e9/73eb8a6789e927816f4705291be21f2225687bfa97321e40cd23055e903a/asyncpg-0.31.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ba5f8886e850882ff2c2ace5732300e99193823e8107e2c53ef01c1ebfa1e85d", size = 3434744, upload-time = "2025-11-24T23:26:26.944Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/08/4b/f10b880534413c65c5b5862f79b8e81553a8f364e5238832ad4c0af71b7f/asyncpg-0.31.0-cp314-cp314-win32.whl", hash = "sha256:cea3a0b2a14f95834cee29432e4ddc399b95700eb1d51bbc5bfee8f31fa07b2b", size = 532251, upload-time = "2025-11-24T23:26:28.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d3/2d/7aa40750b7a19efa5d66e67fc06008ca0f27ba1bd082e457ad82f59aba49/asyncpg-0.31.0-cp314-cp314-win_amd64.whl", hash = "sha256:04d19392716af6b029411a0264d92093b6e5e8285ae97a39957b9a9c14ea72be", size = 604901, upload-time = "2025-11-24T23:26:30.34Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/fe/b9dfe349b83b9dee28cc42360d2c86b2cdce4cb551a2c2d27e156bcac84d/asyncpg-0.31.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:bdb957706da132e982cc6856bb2f7b740603472b54c3ebc77fe60ea3e57e1bd2", size = 702280, upload-time = "2025-11-24T23:26:32Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/81/e6be6e37e560bd91e6c23ea8a6138a04fd057b08cf63d3c5055c98e81c1d/asyncpg-0.31.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:6d11b198111a72f47154fa03b85799f9be63701e068b43f84ac25da0bda9cb31", size = 682931, upload-time = "2025-11-24T23:26:33.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a6/45/6009040da85a1648dd5bc75b3b0a062081c483e75a1a29041ae63a0bf0dc/asyncpg-0.31.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:18c83b03bc0d1b23e6230f5bf8d4f217dc9bc08644ce0502a9d91dc9e634a9c7", size = 3581608, upload-time = "2025-11-24T23:26:35.638Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/06/2e3d4d7608b0b2b3adbee0d0bd6a2d29ca0fc4d8a78f8277df04e2d1fd7b/asyncpg-0.31.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e009abc333464ff18b8f6fd146addffd9aaf63e79aa3bb40ab7a4c332d0c5e9e", size = 3498738, upload-time = "2025-11-24T23:26:37.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/aa/7d75ede780033141c51d83577ea23236ba7d3a23593929b32b49db8ed36e/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3b1fbcb0e396a5ca435a8826a87e5c2c2cc0c8c68eb6fadf82168056b0e53a8c", size = 3401026, upload-time = "2025-11-24T23:26:39.423Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ba/7a/15e37d45e7f7c94facc1e9148c0e455e8f33c08f0b8a0b1deb2c5171771b/asyncpg-0.31.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:8df714dba348efcc162d2adf02d213e5fab1bd9f557e1305633e851a61814a7a", size = 3429426, upload-time = "2025-11-24T23:26:41.032Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/13/d5/71437c5f6ae5f307828710efbe62163974e71237d5d46ebd2869ea052d10/asyncpg-0.31.0-cp314-cp314t-win32.whl", hash = "sha256:1b41f1afb1033f2b44f3234993b15096ddc9cd71b21a42dbd87fc6a57b43d65d", size = 614495, upload-time = "2025-11-24T23:26:42.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3c/d7/8fb3044eaef08a310acfe23dae9a8e2e07d305edc29a53497e52bc76eca7/asyncpg-0.31.0-cp314-cp314t-win_amd64.whl", hash = "sha256:bd4107bb7cdd0e9e65fae66a62afd3a249663b844fa34d479f6d5b3bef9c04c3", size = 706062, upload-time = "2025-11-24T23:26:44.086Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "asyncssh"
|
||||
version = "2.22.0"
|
||||
@@ -620,6 +689,23 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/59/76ab57e3fe74484f48a53f8e337171b4a2349e506eabe136d7e01d059086/backports_asyncio_runner-1.2.0-py3-none-any.whl", hash = "sha256:0da0a936a8aeb554eccb426dc55af3ba63bcdc69fa1a600b5bb305413a4477b5", size = 12313, upload-time = "2025-07-02T02:27:14.263Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "banks"
|
||||
version = "2.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
{ name = "filetype" },
|
||||
{ name = "griffe" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/51/08fb68d23f4b0f6256fe85dc86e9576941550f890b079352fba719e07b39/banks-2.4.2.tar.gz", hash = "sha256:cda6013bd377ea7b701933578bfb9370fc21ad70bc13cedfc3f5cb2c034ca3dc", size = 188633, upload-time = "2026-04-27T12:15:22.021Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/00/b6/8dc5477681b782e2f99de703e7a99828883364b9e03a60d3e2c47053d56a/banks-2.4.2-py3-none-any.whl", hash = "sha256:5fe407cc48c101f3e13d1cf732b83b8246003337612f13c0705d2e81f6faffb7", size = 35050, upload-time = "2026-04-27T12:15:20.785Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bcrypt"
|
||||
version = "4.2.1"
|
||||
@@ -674,6 +760,58 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/10/cb/f2ad4230dc2eb1a74edf38f1a38b9b52277f75bef262d8908e60d957e13c/blinker-1.9.0-py3-none-any.whl", hash = "sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc", size = 8458, upload-time = "2024-11-08T17:25:46.184Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botbuilder-core"
|
||||
version = "4.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botbuilder-schema" },
|
||||
{ name = "botframework-connector" },
|
||||
{ name = "botframework-streaming" },
|
||||
{ name = "jsonpickle" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/74/11/cd231b48525e6b34a11cf211db110a95e901204f141c1633bdcbfe1866f1/botbuilder_core-4.17.1-py3-none-any.whl", hash = "sha256:b0a5eea6ce9759dba0847adeed0587fec83ce069f3b4938eb348bb44becde8d7", size = 116145, upload-time = "2026-01-05T19:49:41.946Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botbuilder-schema"
|
||||
version = "4.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "msrest" },
|
||||
{ name = "urllib3" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/abac81a80ed8aea977afbf8c40c22dec11610bbc1e4bd5bff4a7d7386b94/botbuilder_schema-4.17.1-py2.py3-none-any.whl", hash = "sha256:34da28d721b0635fc41bbe3b368225b9883f527d6f17337f858efe80f74656eb", size = 38196, upload-time = "2026-01-05T19:49:45.501Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botframework-connector"
|
||||
version = "4.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botbuilder-schema" },
|
||||
{ name = "msal" },
|
||||
{ name = "msrest" },
|
||||
{ name = "pyjwt" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/9a/37/6c325f4e6441cba2b3184e6c9a15c3ddc00b5e5aa47966398c3d9836b598/botframework_connector-4.17.1-py2.py3-none-any.whl", hash = "sha256:929d242a242f16c7c637eec7c239a84fd7f5f5bb7f95968407d3a9aa9d7bdf8d", size = 100883, upload-time = "2026-01-05T19:49:48.285Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "botframework-streaming"
|
||||
version = "4.17.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "botbuilder-schema" },
|
||||
{ name = "botframework-connector" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/67/16b1a40b9bdc6288d90f17a464fca457e1e50cc36f1c25bc6265bcdffefe/botframework_streaming-4.17.1-py3-none-any.whl", hash = "sha256:66ad1cfe24c8c6b1fde8f95131e39e01197158e7e0bab797dfa709b4f905deb3", size = 41951, upload-time = "2026-01-05T19:49:50.245Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "boto3"
|
||||
version = "1.42.66"
|
||||
@@ -1271,6 +1409,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/58/257350f7db99b4ae12b614a36256d9cc870d71d9e451e79c2dc3b23d7c3c/cssselect-1.3.0-py3-none-any.whl", hash = "sha256:56d1bf3e198080cc1667e137bc51de9cadfca259f03c2d4e09037b3e01e30f0d", size = 18786, upload-time = "2025-03-10T09:30:28.048Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dataclasses-json"
|
||||
version = "0.6.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "marshmallow" },
|
||||
{ name = "typing-inspect" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/64/a4/f71d9cf3a5ac257c993b5ca3f93df5f7fb395c725e7f1e6479d2514173c3/dataclasses_json-0.6.7.tar.gz", hash = "sha256:b6b3e528266ea45b9535223bc53ca645f5208833c29229e847b3f26a1cc55fc0", size = 32227, upload-time = "2024-06-09T16:20:19.103Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/be/d0d44e092656fe7a06b55e6103cbce807cdbdee17884a5367c68c9860853/dataclasses_json-0.6.7-py3-none-any.whl", hash = "sha256:0dbf33f26c8d5305befd61b39d2b3414e8a407bedc2834dea9b8d642666fb40a", size = 28686, upload-time = "2024-06-09T16:20:16.715Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ddgs"
|
||||
version = "9.10.0"
|
||||
@@ -1296,6 +1447,27 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "deprecated"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/49/85/12f0a49a7c4ffb70572b6c2ef13c90c88fd190debda93b23f026b25f9634/deprecated-1.3.1.tar.gz", hash = "sha256:b1b50e0ff0c1fddaa5708a2c6b0a6588bb09b892825ab2b214ac9ea9d92a5223", size = 2932523, upload-time = "2025-10-30T08:19:02.757Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/84/d0/205d54408c08b13550c733c4b85429e7ead111c7f0014309637425520a9a/deprecated-1.3.1-py2.py3-none-any.whl", hash = "sha256:597bfef186b6f60181535a29fbe44865ce137a5079f295b479886c82729d5f3f", size = 11298, upload-time = "2025-10-30T08:19:00.758Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "dirtyjson"
|
||||
version = "1.0.8"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/db/04/d24f6e645ad82ba0ef092fa17d9ef7a21953781663648a01c9371d9e8e98/dirtyjson-1.0.8.tar.gz", hash = "sha256:90ca4a18f3ff30ce849d100dcf4a003953c79d3a2348ef056f1d9c22231a25fd", size = 30782, upload-time = "2022-11-28T23:32:33.319Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/68/69/1bcf70f81de1b4a9f21b3a62ec0c83bdff991c88d6cc2267d02408457e88/dirtyjson-1.0.8-py3-none-any.whl", hash = "sha256:125e27248435a58acace26d5c2c4c11a1c0de0a9c5124c5a94ba78e517d74f53", size = 25197, upload-time = "2022-11-28T23:32:31.219Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "discord-py"
|
||||
version = "2.6.4"
|
||||
@@ -1580,6 +1752,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b9/f8/feced7779d755758a52d1f6635d990b8d98dc0a29fa568bbe0625f18fdf3/filelock-3.16.1-py3-none-any.whl", hash = "sha256:2082e5703d51fbf98ea75855d9d5527e33d8ff23099bec374a134febee6946b0", size = 16163, upload-time = "2024-09-17T19:02:00.268Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "filetype"
|
||||
version = "1.2.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bb/29/745f7d30d47fe0f251d3ad3dc2978a23141917661998763bebb6da007eb1/filetype-1.2.0.tar.gz", hash = "sha256:66b56cd6474bf41d8c54660347d37afcc3f7d1970648de365c102ef77548aadb", size = 998020, upload-time = "2022-11-02T17:34:04.141Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/79/1b8fa1bb3568781e84c9200f951c735f3f157429f44be0495da55894d620/filetype-1.2.0-py2.py3-none-any.whl", hash = "sha256:7ce71b6880181241cf7ac8697a2f1eb6a8bd9b429f7ad6d27b8db9ba5f1c2d25", size = 19970, upload-time = "2022-11-02T17:34:01.425Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "flaml"
|
||||
version = "2.3.3"
|
||||
@@ -2020,6 +2201,41 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/31/df/b7d17d66c8d0f578d2885a3d8f565e9e4725eacc9d3fdc946d0031c055c4/greenlet-3.2.2-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:9ea5231428af34226c05f927e16fc7f6fa5e39e3ad3cd24ffa48ba53a47f4240", size = 269899, upload-time = "2025-05-09T14:54:01.581Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffe"
|
||||
version = "2.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "griffecli" },
|
||||
{ name = "griffelib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4a/49/eb6d2935e27883af92c930ed40cc4c69bcd32c402be43b8ca4ab20510f67/griffe-2.0.2.tar.gz", hash = "sha256:c5d56326d159f274492e9bf93a9895cec101155d944caa66d0fc4e0c13751b92", size = 293757, upload-time = "2026-03-27T11:34:52.205Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/94/c0/2bb018eecf9a83c68db9cd9fffd9dab25f102ad30ed869451046e46d1187/griffe-2.0.2-py3-none-any.whl", hash = "sha256:2b31816460aee1996af26050a1fc6927a2e5936486856707f55508e4c9b5960b", size = 5141, upload-time = "2026-03-27T11:34:47.721Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffecli"
|
||||
version = "2.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama" },
|
||||
{ name = "griffelib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/79/e0/6a7d661d71bb043656a109b91d84a42b5342752542074ec83b16a6eb97f0/griffecli-2.0.2.tar.gz", hash = "sha256:40a1ad4181fc39685d025e119ae2c5b669acdc1f19b705fb9bf971f4e6f6dffb", size = 56281, upload-time = "2026-03-27T11:34:50.087Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/e8/90d93356c88ac34c20cb5edffca68138df55ca9bbd1a06eccfbcec8fdbe5/griffecli-2.0.2-py3-none-any.whl", hash = "sha256:0d44d39e59afa81e288a3e1c3bf352cc4fa537483326ac06b8bb6a51fd8303a0", size = 9500, upload-time = "2026-03-27T11:34:48.81Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "griffelib"
|
||||
version = "2.0.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/82/74f4a3310cdabfbb10da554c3a672847f1ed33c6f61dd472681ce7f1fe67/griffelib-2.0.2.tar.gz", hash = "sha256:3cf20b3bc470e83763ffbf236e0076b1211bac1bc67de13daf494640f2de707e", size = 166461, upload-time = "2026-03-27T11:34:51.091Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/11/8c/c9138d881c79aa0ea9ed83cbd58d5ca75624378b38cee225dcf5c42cc91f/griffelib-2.0.2-py3-none-any.whl", hash = "sha256:925c857658fb1ba40c0772c37acbc2ab650bd794d9c1b9726922e36ea4117ea1", size = 142357, upload-time = "2026-03-27T11:34:46.275Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "groovy"
|
||||
version = "0.1.2"
|
||||
@@ -2669,6 +2885,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/73/07/02e16ed01e04a374e644b575638ec7987ae846d25ad97bcc9945a3ee4b0e/jsonpatch-1.33-py2.py3-none-any.whl", hash = "sha256:0ae28c0cd062bbd8b8ecc26d7d164fbbea9652a1a3693f3b956c1eae5145dade", size = 12898, upload-time = "2023-06-16T21:01:28.466Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonpickle"
|
||||
version = "3.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/eb/d9/05365407d3312653498001adcebe64a14024f7189691b728610209991c46/jsonpickle-3.4.2.tar.gz", hash = "sha256:2efa2778859b6397d5804b0a98d52cd2a7d9a70fcb873bc5a3ca5acca8f499ba", size = 314339, upload-time = "2024-11-06T07:48:25.479Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/a3/e610ae0feba3e7374da08ab6cc9bb76c8bfa84b4e502aa357bda0ef6dcae/jsonpickle-3.4.2-py3-none-any.whl", hash = "sha256:fd6c273278a02b3b66e3405db3dd2f4dbc8f4a4a3123bfcab3045177c6feb9c3", size = 46256, upload-time = "2024-11-06T07:48:22.923Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonpointer"
|
||||
version = "3.0.0"
|
||||
@@ -2702,6 +2927,21 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/69/4a/4f9dbeb84e8850557c02365a0eee0649abe5eb1d84af92a25731c6c0f922/jsonschema-4.23.0-py3-none-any.whl", hash = "sha256:fbadb6f8b144a8f8cf9f0b89ba94501d143e50411a1278633f56a7acf7fd5566", size = 88462, upload-time = "2024-07-08T18:40:00.165Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-path"
|
||||
version = "0.3.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pathable" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "referencing" },
|
||||
{ name = "requests" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/6e/45/41ebc679c2a4fced6a722f624c18d658dee42612b83ea24c1caf7c0eb3a8/jsonschema_path-0.3.4.tar.gz", hash = "sha256:8365356039f16cc65fddffafda5f58766e34bebab7d6d105616ab52bc4297001", size = 11159, upload-time = "2025-01-24T14:33:16.547Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/58/3485da8cb93d2f393bce453adeef16896751f14ba3e2024bc21dc9597646/jsonschema_path-0.3.4-py3-none-any.whl", hash = "sha256:f502191fdc2b22050f9a81c9237be9d27145b9001c55842bece5e94e382e52f8", size = 14810, upload-time = "2025-01-24T14:33:14.652Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "jsonschema-specifications"
|
||||
version = "2024.10.1"
|
||||
@@ -3014,6 +3254,51 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2d/00/d90b10b962b4277f5e64a78b6609968859ff86889f5b898c1a778c06ec00/lark-1.2.2-py3-none-any.whl", hash = "sha256:c2276486b02f0f1b90be155f2c8ba4a8e194d42775786db622faccd652d8e80c", size = 111036, upload-time = "2024-08-13T19:48:58.603Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lazy-object-proxy"
|
||||
version = "1.12.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/08/a2/69df9c6ba6d316cfd81fe2381e464db3e6de5db45f8c43c6a23504abf8cb/lazy_object_proxy-1.12.0.tar.gz", hash = "sha256:1f5a462d92fd0cfb82f1fab28b51bfb209fabbe6aabf7f0d51472c0c124c0c61", size = 43681, upload-time = "2025-08-22T13:50:06.783Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/2b/d5e8915038acbd6c6a9fcb8aaf923dc184222405d3710285a1fec6e262bc/lazy_object_proxy-1.12.0-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:61d5e3310a4aa5792c2b599a7a78ccf8687292c8eb09cf187cca8f09cf6a7519", size = 26658, upload-time = "2025-08-22T13:42:23.373Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/8f/91fc00eeea46ee88b9df67f7c5388e60993341d2a406243d620b2fdfde57/lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c1ca33565f698ac1aece152a10f432415d1a2aa9a42dfe23e5ba2bc255ab91f6", size = 68412, upload-time = "2025-08-22T13:42:24.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/d2/b7189a0e095caedfea4d42e6b6949d2685c354263bdf18e19b21ca9b3cd6/lazy_object_proxy-1.12.0-cp310-cp310-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d01c7819a410f7c255b20799b65d36b414379a30c6f1684c7bd7eb6777338c1b", size = 67559, upload-time = "2025-08-22T13:42:25.875Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/ad/b013840cc43971582ff1ceaf784d35d3a579650eb6cc348e5e6ed7e34d28/lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:029d2b355076710505c9545aef5ab3f750d89779310e26ddf2b7b23f6ea03cd8", size = 66651, upload-time = "2025-08-22T13:42:27.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/6f/b7368d301c15612fcc4cd00412b5d6ba55548bde09bdae71930e1a81f2ab/lazy_object_proxy-1.12.0-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:cc6e3614eca88b1c8a625fc0a47d0d745e7c3255b21dac0e30b3037c5e3deeb8", size = 66901, upload-time = "2025-08-22T13:42:28.585Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/61/1b/c6b1865445576b2fc5fa0fbcfce1c05fee77d8979fd1aa653dd0f179aefc/lazy_object_proxy-1.12.0-cp310-cp310-win_amd64.whl", hash = "sha256:be5fe974e39ceb0d6c9db0663c0464669cf866b2851c73971409b9566e880eab", size = 26536, upload-time = "2025-08-22T13:42:29.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/01/b3/4684b1e128a87821e485f5a901b179790e6b5bc02f89b7ee19c23be36ef3/lazy_object_proxy-1.12.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:1cf69cd1a6c7fe2dbcc3edaa017cf010f4192e53796538cc7d5e1fedbfa4bcff", size = 26656, upload-time = "2025-08-22T13:42:30.605Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3a/03/1bdc21d9a6df9ff72d70b2ff17d8609321bea4b0d3cffd2cea92fb2ef738/lazy_object_proxy-1.12.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:efff4375a8c52f55a145dc8487a2108c2140f0bec4151ab4e1843e52eb9987ad", size = 68832, upload-time = "2025-08-22T13:42:31.675Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/4b/5788e5e8bd01d19af71e50077ab020bc5cce67e935066cd65e1215a09ff9/lazy_object_proxy-1.12.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1192e8c2f1031a6ff453ee40213afa01ba765b3dc861302cd91dbdb2e2660b00", size = 69148, upload-time = "2025-08-22T13:42:32.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/0e/090bf070f7a0de44c61659cb7f74c2fe02309a77ca8c4b43adfe0b695f66/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:3605b632e82a1cbc32a1e5034278a64db555b3496e0795723ee697006b980508", size = 67800, upload-time = "2025-08-22T13:42:34.054Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cf/d2/b320325adbb2d119156f7c506a5fbfa37fcab15c26d13cf789a90a6de04e/lazy_object_proxy-1.12.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a61095f5d9d1a743e1e20ec6d6db6c2ca511961777257ebd9b288951b23b44fa", size = 68085, upload-time = "2025-08-22T13:42:35.197Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/48/4b718c937004bf71cd82af3713874656bcb8d0cc78600bf33bb9619adc6c/lazy_object_proxy-1.12.0-cp311-cp311-win_amd64.whl", hash = "sha256:997b1d6e10ecc6fb6fe0f2c959791ae59599f41da61d652f6c903d1ee58b7370", size = 26535, upload-time = "2025-08-22T13:42:36.521Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/1b/b5f5bd6bda26f1e15cd3232b223892e4498e34ec70a7f4f11c401ac969f1/lazy_object_proxy-1.12.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:8ee0d6027b760a11cc18281e702c0309dd92da458a74b4c15025d7fc490deede", size = 26746, upload-time = "2025-08-22T13:42:37.572Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/64/314889b618075c2bfc19293ffa9153ce880ac6153aacfd0a52fcabf21a66/lazy_object_proxy-1.12.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4ab2c584e3cc8be0dfca422e05ad30a9abe3555ce63e9ab7a559f62f8dbc6ff9", size = 71457, upload-time = "2025-08-22T13:42:38.743Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/53/857fc2827fc1e13fbdfc0ba2629a7d2579645a06192d5461809540b78913/lazy_object_proxy-1.12.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:14e348185adbd03ec17d051e169ec45686dcd840a3779c9d4c10aabe2ca6e1c0", size = 71036, upload-time = "2025-08-22T13:42:40.184Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/24/e581ffed864cd33c1b445b5763d617448ebb880f48675fc9de0471a95cbc/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c4fcbe74fb85df8ba7825fa05eddca764138da752904b378f0ae5ab33a36c308", size = 69329, upload-time = "2025-08-22T13:42:41.311Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/be/15f8f5a0b0b2e668e756a152257d26370132c97f2f1943329b08f057eff0/lazy_object_proxy-1.12.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:563d2ec8e4d4b68ee7848c5ab4d6057a6d703cb7963b342968bb8758dda33a23", size = 70690, upload-time = "2025-08-22T13:42:42.51Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/5d/aa/f02be9bbfb270e13ee608c2b28b8771f20a5f64356c6d9317b20043c6129/lazy_object_proxy-1.12.0-cp312-cp312-win_amd64.whl", hash = "sha256:53c7fd99eb156bbb82cbc5d5188891d8fdd805ba6c1e3b92b90092da2a837073", size = 26563, upload-time = "2025-08-22T13:42:43.685Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/26/b74c791008841f8ad896c7f293415136c66cc27e7c7577de4ee68040c110/lazy_object_proxy-1.12.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:86fd61cb2ba249b9f436d789d1356deae69ad3231dc3c0f17293ac535162672e", size = 26745, upload-time = "2025-08-22T13:42:44.982Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9b/52/641870d309e5d1fb1ea7d462a818ca727e43bfa431d8c34b173eb090348c/lazy_object_proxy-1.12.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:81d1852fb30fab81696f93db1b1e55a5d1ff7940838191062f5f56987d5fcc3e", size = 71537, upload-time = "2025-08-22T13:42:46.141Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/b6/919118e99d51c5e76e8bf5a27df406884921c0acf2c7b8a3b38d847ab3e9/lazy_object_proxy-1.12.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be9045646d83f6c2664c1330904b245ae2371b5c57a3195e4028aedc9f999655", size = 71141, upload-time = "2025-08-22T13:42:47.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e5/47/1d20e626567b41de085cf4d4fb3661a56c159feaa73c825917b3b4d4f806/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:67f07ab742f1adfb3966c40f630baaa7902be4222a17941f3d85fd1dae5565ff", size = 69449, upload-time = "2025-08-22T13:42:48.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/58/8d/25c20ff1a1a8426d9af2d0b6f29f6388005fc8cd10d6ee71f48bff86fdd0/lazy_object_proxy-1.12.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:75ba769017b944fcacbf6a80c18b2761a1795b03f8899acdad1f1c39db4409be", size = 70744, upload-time = "2025-08-22T13:42:49.608Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/67/8ec9abe15c4f8a4bcc6e65160a2c667240d025cbb6591b879bea55625263/lazy_object_proxy-1.12.0-cp313-cp313-win_amd64.whl", hash = "sha256:7b22c2bbfb155706b928ac4d74c1a63ac8552a55ba7fff4445155523ea4067e1", size = 26568, upload-time = "2025-08-22T13:42:57.719Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/12/cd2235463f3469fd6c62d41d92b7f120e8134f76e52421413a0ad16d493e/lazy_object_proxy-1.12.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4a79b909aa16bde8ae606f06e6bbc9d3219d2e57fb3e0076e17879072b742c65", size = 27391, upload-time = "2025-08-22T13:42:50.62Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/60/9e/f1c53e39bbebad2e8609c67d0830cc275f694d0ea23d78e8f6db526c12d3/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:338ab2f132276203e404951205fe80c3fd59429b3a724e7b662b2eb539bb1be9", size = 80552, upload-time = "2025-08-22T13:42:51.731Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4c/b6/6c513693448dcb317d9d8c91d91f47addc09553613379e504435b4cc8b3e/lazy_object_proxy-1.12.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8c40b3c9faee2e32bfce0df4ae63f4e73529766893258eca78548bac801c8f66", size = 82857, upload-time = "2025-08-22T13:42:53.225Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/1c/d9c4aaa4c75da11eb7c22c43d7c90a53b4fca0e27784a5ab207768debea7/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:717484c309df78cedf48396e420fa57fc8a2b1f06ea889df7248fdd156e58847", size = 80833, upload-time = "2025-08-22T13:42:54.391Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/ae/29117275aac7d7d78ae4f5a4787f36ff33262499d486ac0bf3e0b97889f6/lazy_object_proxy-1.12.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a6b7ea5ea1ffe15059eb44bcbcb258f97bcb40e139b88152c40d07b1a1dfc9ac", size = 79516, upload-time = "2025-08-22T13:42:55.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/19/40/b4e48b2c38c69392ae702ae7afa7b6551e0ca5d38263198b7c79de8b3bdf/lazy_object_proxy-1.12.0-cp313-cp313t-win_amd64.whl", hash = "sha256:08c465fb5cd23527512f9bd7b4c7ba6cec33e28aad36fbbe46bf7b858f9f3f7f", size = 27656, upload-time = "2025-08-22T13:42:56.793Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ef/3a/277857b51ae419a1574557c0b12e0d06bf327b758ba94cafc664cb1e2f66/lazy_object_proxy-1.12.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:c9defba70ab943f1df98a656247966d7729da2fe9c2d5d85346464bf320820a3", size = 26582, upload-time = "2025-08-22T13:49:49.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1a/b6/c5e0fa43535bb9c87880e0ba037cdb1c50e01850b0831e80eb4f4762f270/lazy_object_proxy-1.12.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6763941dbf97eea6b90f5b06eb4da9418cc088fce0e3883f5816090f9afcde4a", size = 71059, upload-time = "2025-08-22T13:49:50.488Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/06/8a/7dcad19c685963c652624702f1a968ff10220b16bfcc442257038216bf55/lazy_object_proxy-1.12.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fdc70d81235fc586b9e3d1aeef7d1553259b62ecaae9db2167a5d2550dcc391a", size = 71034, upload-time = "2025-08-22T13:49:54.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/12/ac/34cbfb433a10e28c7fd830f91c5a348462ba748413cbb950c7f259e67aa7/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0a83c6f7a6b2bfc11ef3ed67f8cbe99f8ff500b05655d8e7df9aab993a6abc95", size = 69529, upload-time = "2025-08-22T13:49:55.29Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/6a/11ad7e349307c3ca4c0175db7a77d60ce42a41c60bcb11800aabd6a8acb8/lazy_object_proxy-1.12.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:256262384ebd2a77b023ad02fbcc9326282bcfd16484d5531154b02bc304f4c5", size = 70391, upload-time = "2025-08-22T13:49:56.35Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/97/9b410ed8fbc6e79c1ee8b13f8777a80137d4bc189caf2c6202358e66192c/lazy_object_proxy-1.12.0-cp314-cp314-win_amd64.whl", hash = "sha256:7601ec171c7e8584f8ff3f4e440aa2eebf93e854f04639263875b8c2971f819f", size = 26988, upload-time = "2025-08-22T13:49:57.302Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/41/a0/b91504515c1f9a299fc157967ffbd2f0321bce0516a3d5b89f6f4cad0355/lazy_object_proxy-1.12.0-pp39.pp310.pp311.graalpy311-none-any.whl", hash = "sha256:c3b2e0af1f7f77c4263759c4824316ce458fabe0fceadcd24ef8ca08b2d1e402", size = 15072, upload-time = "2025-08-22T13:50:05.498Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "libsql-experimental"
|
||||
version = "0.0.55"
|
||||
@@ -3110,6 +3395,113 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/5c/1b5691575420135e90578543b2bf219497caa33cfd0af64cb38f30288450/litellm-1.83.14-py3-none-any.whl", hash = "sha256:92b11ba2a32cf80707ddf388d18526696c7999a21b418c5e3b6eda1243d2cfdb", size = 16457054, upload-time = "2026-04-26T03:16:05.72Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llama-index"
|
||||
version = "0.14.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "llama-index-core" },
|
||||
{ name = "llama-index-embeddings-openai" },
|
||||
{ name = "llama-index-llms-openai" },
|
||||
{ name = "nltk" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/89/3b6f3318ea2249158daab3ff22777ef5ffa87a63c011659a6cfc55e54c35/llama_index-0.14.22.tar.gz", hash = "sha256:c2c9b31f50d2815abdc191085db4acaf96b7c01851ac66b2e4cc82be8cde589e", size = 8565, upload-time = "2026-05-14T20:22:21.006Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/59/fd/f0837c4ce049d8ece7525bbf64564e93e3f16333856c2a0b47fecb58f317/llama_index-0.14.22-py3-none-any.whl", hash = "sha256:14b4bdd799112062e38288eab6aa16643f29d7532505ab174b0b6d5b0817fe94", size = 7115, upload-time = "2026-05-14T20:22:19.611Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llama-index-core"
|
||||
version = "0.14.22"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "aiosqlite" },
|
||||
{ name = "banks" },
|
||||
{ name = "dataclasses-json" },
|
||||
{ name = "deprecated" },
|
||||
{ name = "dirtyjson" },
|
||||
{ name = "filetype" },
|
||||
{ name = "fsspec" },
|
||||
{ name = "httpx" },
|
||||
{ name = "llama-index-workflows" },
|
||||
{ name = "nest-asyncio" },
|
||||
{ name = "networkx" },
|
||||
{ name = "nltk" },
|
||||
{ name = "numpy" },
|
||||
{ name = "pillow" },
|
||||
{ name = "platformdirs" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "requests" },
|
||||
{ name = "setuptools" },
|
||||
{ name = "sqlalchemy", extra = ["asyncio"] },
|
||||
{ name = "tenacity" },
|
||||
{ name = "tiktoken" },
|
||||
{ name = "tinytag" },
|
||||
{ name = "tqdm" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspect" },
|
||||
{ name = "wrapt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/7f/94a4b940ef0d069840df0fd6d361a2aa832a2dd73b4cecdf86e8f8c353c8/llama_index_core-0.14.22.tar.gz", hash = "sha256:1384410f89bdbd32349aab444ef4f5c828c338787bc65bd1ffd8e86dfb44ac41", size = 11584786, upload-time = "2026-05-14T20:21:37.271Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/39/15/e1a26d8d56aa55fa07587a3e9c7e85294d2df5af6c2229193019bc549ef6/llama_index_core-0.14.22-py3-none-any.whl", hash = "sha256:9cfffde46fd5b7937101e1c0c9bb5c21bd7ff8c8a56937810b87ba3542f31225", size = 11920774, upload-time = "2026-05-14T20:21:40.409Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llama-index-embeddings-openai"
|
||||
version = "0.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "llama-index-core" },
|
||||
{ name = "openai" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/52/eb56a4887501651fb17400f7f571c1878109ff698efbe0bbac9165a5603d/llama_index_embeddings_openai-0.6.0.tar.gz", hash = "sha256:eb3e6606be81cb89125073e23c97c0a6119dabb4827adbd14697c2029ad73f29", size = 7629, upload-time = "2026-03-12T20:21:27.234Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/d1/4bb0b80f4057903110060f617ef519197194b3ff5dd6153d850c8f5676fa/llama_index_embeddings_openai-0.6.0-py3-none-any.whl", hash = "sha256:039bb1007ad4267e25ddb89a206dfdab862bfb87d58da4271a3919e4f9df4d61", size = 7666, upload-time = "2026-03-12T20:21:28.079Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llama-index-instrumentation"
|
||||
version = "0.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "deprecated" },
|
||||
{ name = "pydantic" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/4e/d0/671b23ccff255c9bce132a84ffd5a6f4541ceefdeab9c1786b08c9722f2e/llama_index_instrumentation-0.5.0.tar.gz", hash = "sha256:eeb724648b25d149de882a5ac9e21c5acb1ce780da214bda2b075341af29ad8e", size = 43831, upload-time = "2026-03-12T20:17:06.742Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/45/6dcaccef44e541ffa138e4b45e33e0d40ab2a7d845338483954fcf77bc75/llama_index_instrumentation-0.5.0-py3-none-any.whl", hash = "sha256:aaab83cddd9dd434278891012d8995f47a3bc7ed1736a371db90965348c56a21", size = 16444, upload-time = "2026-03-12T20:17:05.957Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llama-index-llms-openai"
|
||||
version = "0.7.9"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "llama-index-core" },
|
||||
{ name = "openai" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b2/85/f07466d0f5c3f1e1f295a60f2d2e9d32163635ba39ae2fda22ce2fbac1c9/llama_index_llms_openai-0.7.9.tar.gz", hash = "sha256:f54a24b717134c86e724007057a06a84394f019d1f01e918b624894e208a86df", size = 27564, upload-time = "2026-05-29T15:32:37.141Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/62/c2/cfa341cff00154b58abed3fc559fb43c2531d5dd70eefb75f230704d4c3c/llama_index_llms_openai-0.7.9-py3-none-any.whl", hash = "sha256:0bc8f59faddf8dbc9f90c5576127ab2fa6d368be5078885dc7e611af129b5a79", size = 28648, upload-time = "2026-05-29T15:32:35.997Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "llama-index-workflows"
|
||||
version = "2.20.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "llama-index-instrumentation" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c4/ec/05f3db99a2e6e252e3939e7751cad2fb1322dc6d32f4cf5c795cf7ddcad3/llama_index_workflows-2.20.0.tar.gz", hash = "sha256:df2760fea9e100c97a4e919d255461e344413acac4382d17d8217337806e4772", size = 97410, upload-time = "2026-04-24T14:54:41.524Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/71/5f/385231406d777cb4b608fd8ebe3577dbd90962770717181e6b91b44fb1b8/llama_index_workflows-2.20.0-py3-none-any.whl", hash = "sha256:36f6b6ace77f837d9907078aea7e830251afe96a58daecff5ed090c88c55095d", size = 121238, upload-time = "2026-04-24T14:54:40.455Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "lxml"
|
||||
version = "5.3.0"
|
||||
@@ -3336,6 +3728,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/14/c3554d512d5f9100a95e737502f4a2323a1959f6d0d01e0d0997b35f7b10/MarkupSafe-2.1.5-cp312-cp312-win_amd64.whl", hash = "sha256:823b65d8706e32ad2df51ed89496147a42a2a6e01c13cfb6ffb8b1e92bc910bb", size = 17127, upload-time = "2024-02-02T16:30:44.418Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "marshmallow"
|
||||
version = "3.26.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/79/de6c16cc902f4fc372236926b0ce2ab7845268dcc30fb2fbb7f71b418631/marshmallow-3.26.2.tar.gz", hash = "sha256:bbe2adb5a03e6e3571b573f42527c6fe926e17467833660bebd11593ab8dfd57", size = 222095, upload-time = "2025-12-22T06:53:53.309Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/be/2f/5108cb3ee4ba6501748c4908b908e55f42a5b66245b4cfe0c99326e1ef6e/marshmallow-3.26.2-py3-none-any.whl", hash = "sha256:013fa8a3c4c276c24d26d84ce934dc964e2aa794345a0f8c7e5a7191482c8a73", size = 50964, upload-time = "2025-12-22T06:53:51.801Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mcp"
|
||||
version = "1.25.0"
|
||||
@@ -3660,6 +4064,22 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/81/f2/08ace4142eb281c12701fc3b93a10795e4d4dc7f753911d836675050f886/msgpack-1.1.2-cp314-cp314t-win_arm64.whl", hash = "sha256:d99ef64f349d5ec3293688e91486c5fdb925ed03807f64d98d205d2713c60b46", size = 70868, upload-time = "2025-10-08T09:15:44.959Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "msrest"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "azure-core" },
|
||||
{ name = "certifi" },
|
||||
{ name = "isodate" },
|
||||
{ name = "requests" },
|
||||
{ name = "requests-oauthlib" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/68/77/8397c8fb8fc257d8ea0fa66f8068e073278c65f05acb17dcb22a02bfdc42/msrest-0.7.1.zip", hash = "sha256:6e7661f46f3afd88b75667b7187a92829924446c7ea1d169be8c4bb7eeb788b9", size = 175332, upload-time = "2022-06-13T22:41:25.111Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/cf/f2966a2638144491f8696c27320d5219f48a072715075d168b31d3237720/msrest-0.7.1-py3-none-any.whl", hash = "sha256:21120a810e1233e5e6cc7fe40b474eeb4ec6f757a15d7cf86702c369f9567c32", size = 85384, upload-time = "2022-06-13T22:41:22.42Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "multidict"
|
||||
version = "6.1.0"
|
||||
@@ -3741,6 +4161,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3e/8a/bb3160e76e844db9e69a413f055818969c8acade64e1a9ac5ce9dfdcf6c1/multitasking-0.0.11-py3-none-any.whl", hash = "sha256:1e5b37a5f8fc1e6cfaafd1a82b6b1cc6d2ed20037d3b89c25a84f499bd7b3dd4", size = 8533, upload-time = "2022-06-28T08:40:44.524Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "mypy-extensions"
|
||||
version = "1.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a2/6e/371856a3fb9d31ca8dac321cda606860fa4548858c0cc45d9d1d4ca2628b/mypy_extensions-1.1.0.tar.gz", hash = "sha256:52e68efc3284861e772bbcd66823fde5ae21fd2fdb51c62a211403730b916558", size = 6343, upload-time = "2025-04-22T14:54:24.164Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/2c79738432f5c924bef5071f933bcc9efd0473bac3b4aa584a6f7c1c8df8/mypy_extensions-1.1.0-py3-none-any.whl", hash = "sha256:1be4cccdb0f2482337c4743e60421de3a356cd97508abadd57d47403e94f5505", size = 4963, upload-time = "2025-04-22T14:54:22.983Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "neo4j"
|
||||
version = "5.28.3"
|
||||
@@ -3808,6 +4237,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/1d/182c0723a1055716139ca35a68d5dddc429d88c992e1a01b56ed019c12c0/neonize-0.3.10.post6-py3-none-any.whl", hash = "sha256:fb9823b6be77a07707ac6d2a4f627086bf10ba4d38d2e44cbabc0c2a2ff1a965", size = 6058686, upload-time = "2025-05-21T17:22:11.589Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "nest-asyncio"
|
||||
version = "1.6.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/83/f8/51569ac65d696c8ecbee95938f89d4abf00f47d58d48f6fbabfe8f0baefe/nest_asyncio-1.6.0.tar.gz", hash = "sha256:6f172d5449aca15afd6c646851f4e31e02c598d553a667e38cafa997cfec55fe", size = 7418, upload-time = "2024-01-21T14:25:19.227Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a0/c4/c2971a3ba4c6103a3d10c4b0f24f461ddc027f0f09763220cf35ca1401b3/nest_asyncio-1.6.0-py3-none-any.whl", hash = "sha256:87af6efd6b5e897c81050477ef65c62e2b2f35d51703cae01aff2905b1852e1c", size = 5195, upload-time = "2024-01-21T14:25:17.223Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "networkx"
|
||||
version = "3.4.2"
|
||||
@@ -3819,7 +4257,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "nltk"
|
||||
version = "3.9.1"
|
||||
version = "3.9.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -3827,9 +4265,9 @@ dependencies = [
|
||||
{ name = "regex" },
|
||||
{ name = "tqdm" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3c/87/db8be88ad32c2d042420b6fd9ffd4a149f9a0d7f0e86b3f543be2eeeedd2/nltk-3.9.1.tar.gz", hash = "sha256:87d127bd3de4bd89a4f81265e5fa59cb1b199b27440175370f7417d2bc7ae868", size = 2904691, upload-time = "2024-08-18T19:48:37.769Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/74/a1/b3b4adf15585a5bc4c357adde150c01ebeeb642173ded4d871e89468767c/nltk-3.9.4.tar.gz", hash = "sha256:ed03bc098a40481310320808b2db712d95d13ca65b27372f8a403949c8b523d0", size = 2946864, upload-time = "2026-03-24T06:13:40.641Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/66/7d9e26593edda06e8cb531874633f7c2372279c3b0f46235539fe546df8b/nltk-3.9.1-py3-none-any.whl", hash = "sha256:4fa26829c5b00715afe3061398a8989dc643b92ce7dd93fb4585a70930d168a1", size = 1505442, upload-time = "2024-08-18T19:48:21.909Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/91/04e965f8e717ba0ab4bdca5c112deeab11c9e750d94c4d4602f050295d39/nltk-3.9.4-py3-none-any.whl", hash = "sha256:f2fa301c3a12718ce4a0e9305c5675299da5ad9e26068218b69d692fda84828f", size = 1552087, upload-time = "2026-03-24T06:13:38.47Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4074,6 +4512,54 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/30/844dc675ee6902579b8eef01ed23917cc9319a1c9c0c14ec6e39340c96d0/openai-2.24.0-py3-none-any.whl", hash = "sha256:fed30480d7d6c884303287bde864980a4b137b60553ffbcf9ab4a233b7a73d94", size = 1120122, upload-time = "2026-02-24T20:02:05.669Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openapi-core"
|
||||
version = "0.19.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "isodate" },
|
||||
{ name = "jsonschema" },
|
||||
{ name = "jsonschema-path" },
|
||||
{ name = "more-itertools" },
|
||||
{ name = "openapi-schema-validator" },
|
||||
{ name = "openapi-spec-validator" },
|
||||
{ name = "parse" },
|
||||
{ name = "werkzeug" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/34/b9/a769ae516c7f016465b2d9abc6e8dc4d5a1b54c57ab99b3cc95e9587955f/openapi_core-0.19.4.tar.gz", hash = "sha256:1150d9daa5e7b4cacfd7d7e097333dc89382d7d72703934128dcf8a1a4d0df49", size = 109095, upload-time = "2024-09-02T14:10:26.937Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/b3/4534adc8bac68a5d743caa786f1443545faed4d7cc7a5650b2d49255adfc/openapi_core-0.19.4-py3-none-any.whl", hash = "sha256:38e8347b6ebeafe8d3beb588214ecf0171874bb65411e9d4efd23cb011687201", size = 103714, upload-time = "2024-09-02T14:10:25.408Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openapi-schema-validator"
|
||||
version = "0.6.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonschema" },
|
||||
{ name = "jsonschema-specifications" },
|
||||
{ name = "rfc3339-validator" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8b/f3/5507ad3325169347cd8ced61c232ff3df70e2b250c49f0fe140edb4973c6/openapi_schema_validator-0.6.3.tar.gz", hash = "sha256:f37bace4fc2a5d96692f4f8b31dc0f8d7400fd04f3a937798eaf880d425de6ee", size = 11550, upload-time = "2025-01-10T18:08:22.268Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/21/c6/ad0fba32775ae749016829dace42ed80f4407b171da41313d1a3a5f102e4/openapi_schema_validator-0.6.3-py3-none-any.whl", hash = "sha256:f3b9870f4e556b5a62a1c39da72a6b4b16f3ad9c73dc80084b1b11e74ba148a3", size = 8755, upload-time = "2025-01-10T18:08:19.758Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openapi-spec-validator"
|
||||
version = "0.7.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "jsonschema" },
|
||||
{ name = "jsonschema-path" },
|
||||
{ name = "lazy-object-proxy" },
|
||||
{ name = "openapi-schema-validator" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/82/af/fe2d7618d6eae6fb3a82766a44ed87cd8d6d82b4564ed1c7cfb0f6378e91/openapi_spec_validator-0.7.2.tar.gz", hash = "sha256:cc029309b5c5dbc7859df0372d55e9d1ff43e96d678b9ba087f7c56fc586f734", size = 36855, upload-time = "2025-06-07T14:48:56.299Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/27/dd/b3fd642260cb17532f66cc1e8250f3507d1e580483e209dc1e9d13bd980d/openapi_spec_validator-0.7.2-py3-none-any.whl", hash = "sha256:4bbdc0894ec85f1d1bea1d6d9c8b2c3c8d7ccaa13577ef40da9c006c9fd0eb60", size = 39713, upload-time = "2025-06-07T14:48:54.077Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "openpyxl"
|
||||
version = "3.1.5"
|
||||
@@ -4388,6 +4874,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ab/5f/b38085618b950b79d2d9164a711c52b10aefc0ae6833b96f626b7021b2ed/pandas-2.2.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:ad5b65698ab28ed8d7f18790a0dc58005c7629f227be9ecc1072aa74c0c1d43a", size = 13098436, upload-time = "2024-09-20T13:09:48.112Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "parse"
|
||||
version = "1.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a4/f2/0b504486c2a5564798607d3860e48ed19c6443d5e9cc3ec61cc6b8b4ef58/parse-1.22.1.tar.gz", hash = "sha256:d3a4740ec3da338e2b258b2d69741b731eadfddca59e24a14bc4ee5fce38c911", size = 36970, upload-time = "2026-05-26T03:44:52.624Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/c5/7c16e99869e1f422629092cfd23e3b58e461988c3f9c36fd3624bb4142e6/parse-1.22.1-py2.py3-none-any.whl", hash = "sha256:20f0925a46f06602485ac90d751764d0697fd8455aaa97489ba8953a4b66de32", size = 20925, upload-time = "2026-05-26T03:44:51.156Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "passlib"
|
||||
version = "1.7.4"
|
||||
@@ -4402,6 +4897,15 @@ bcrypt = [
|
||||
{ name = "bcrypt" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pathable"
|
||||
version = "0.4.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/93/8f2c2075b180c12c1e9f6a09d1a985bc2036906b13dff1d8917e395f2048/pathable-0.4.4.tar.gz", hash = "sha256:6905a3cd17804edfac7875b5f6c9142a218c7caef78693c2dbbbfbac186d88b2", size = 8124, upload-time = "2025-01-10T18:43:13.247Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7d/eb/b6260b31b1a96386c0a880edebe26f89669098acea8e0318bff6adb378fd/pathable-0.4.4-py3-none-any.whl", hash = "sha256:5ae9e94793b6ef5a4cbe0a7ce9dbbefc1eec38df253763fd0aeeacf2762dbbc2", size = 9592, upload-time = "2025-01-10T18:43:11.88Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pdfminer-six"
|
||||
version = "20251230"
|
||||
@@ -4843,13 +5347,13 @@ requires-dist = [
|
||||
{ name = "aiohttp", marker = "extra == 'all'", specifier = ">=3.8.0" },
|
||||
{ name = "aiohttp", marker = "extra == 'bot'", specifier = ">=3.8.0" },
|
||||
{ name = "aiohttp", marker = "extra == 'claw'", specifier = ">=3.8.0" },
|
||||
{ name = "aiui", marker = "extra == 'all'", specifier = ">=0.3.100" },
|
||||
{ name = "aiui", marker = "extra == 'bot'", specifier = ">=0.3.100" },
|
||||
{ name = "aiui", marker = "extra == 'chat'", specifier = ">=0.3.100" },
|
||||
{ name = "aiui", marker = "extra == 'code'", specifier = ">=0.3.100" },
|
||||
{ name = "aiui", marker = "extra == 'realtime'", specifier = ">=0.3.100" },
|
||||
{ name = "aiui", marker = "extra == 'ui'", specifier = ">=0.3.100" },
|
||||
{ name = "aiui", extras = ["all"], marker = "extra == 'claw'", specifier = ">=0.3.100" },
|
||||
{ name = "aiui", marker = "extra == 'all'", specifier = ">=0.3.121,<0.4" },
|
||||
{ name = "aiui", marker = "extra == 'bot'", specifier = ">=0.3.121,<0.4" },
|
||||
{ name = "aiui", marker = "extra == 'chat'", specifier = ">=0.3.121,<0.4" },
|
||||
{ name = "aiui", marker = "extra == 'code'", specifier = ">=0.3.121,<0.4" },
|
||||
{ name = "aiui", marker = "extra == 'realtime'", specifier = ">=0.3.121,<0.4" },
|
||||
{ name = "aiui", marker = "extra == 'ui'", specifier = ">=0.3.121,<0.4" },
|
||||
{ name = "aiui", extras = ["all"], marker = "extra == 'claw'", specifier = ">=0.3.121,<0.4" },
|
||||
{ name = "asyncssh", marker = "extra == 'ssh'", specifier = ">=2.14.0" },
|
||||
{ name = "autogen-agentchat", marker = "extra == 'autogen-v4'", specifier = ">=0.4.0" },
|
||||
{ name = "autogen-core", marker = "extra == 'autogen-v4'", specifier = ">=0.4.0" },
|
||||
@@ -4987,7 +5491,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "praisonaiagents"
|
||||
version = "1.6.46"
|
||||
version = "1.6.48"
|
||||
source = { directory = "../praisonai-agents" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
@@ -4999,6 +5503,10 @@ dependencies = [
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
a2ui = [
|
||||
{ name = "a2a-sdk" },
|
||||
{ name = "a2ui-agent-sdk" },
|
||||
]
|
||||
all = [
|
||||
{ name = "a2a-sdk" },
|
||||
{ name = "a2ui-agent-sdk" },
|
||||
@@ -5099,6 +5607,22 @@ requires-dist = [
|
||||
]
|
||||
provides-extras = ["mcp", "memory", "knowledge", "graph", "llm", "api", "os", "telemetry", "mongodb", "auth", "autonomy", "search", "crawl", "sandbox", "a2ui", "sandbox-docker", "all"]
|
||||
|
||||
[[package]]
|
||||
name = "prance"
|
||||
version = "23.6.21.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "chardet" },
|
||||
{ name = "packaging" },
|
||||
{ name = "requests" },
|
||||
{ name = "ruamel-yaml" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/f0/bcb5ffc8b7ab8e3d02dbef3bd945cf8fd6e12c146774f900659406b9fce1/prance-23.6.21.0.tar.gz", hash = "sha256:d8c15f8ac34019751cc4945f866d8d964d7888016d10de3592e339567177cabe", size = 2798776, upload-time = "2023-06-21T20:01:57.142Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c9/db/4fb4901ee61274d0ab97746461fc5f2637e5d73aa73f34ee28e941a699a1/prance-23.6.21.0-py3-none-any.whl", hash = "sha256:6a4276fa07ed9f22feda4331097d7503c4adc3097e46ffae97425f2c1026bd9f", size = 36279, upload-time = "2023-06-21T20:01:54.936Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pre-commit"
|
||||
version = "4.5.1"
|
||||
@@ -5351,6 +5875,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/29/c7/9cc94e5084bc859a4ac2a1a08e15fc3456705d5c76a98ed7222972650357/pyautogen-0.2.29-py3-none-any.whl", hash = "sha256:633ed65cadf9580db31f653762f9d796003e4208befdb33a19d9624fb478222c", size = 290204, upload-time = "2024-06-15T21:56:48.214Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pybars4"
|
||||
version = "0.9.13"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "pymeta3" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ee/52/9aa428633ef5aba4b096b2b2f8d046ece613cecab28b4ceed54126d25ea5/pybars4-0.9.13.tar.gz", hash = "sha256:425817da20d4ad320bc9b8e77a60cab1bb9d3c677df3dce224925c3310fcd635", size = 29907, upload-time = "2021-04-04T15:07:10.661Z" }
|
||||
|
||||
[[package]]
|
||||
name = "pybase64"
|
||||
version = "1.4.3"
|
||||
@@ -5724,6 +6257,12 @@ crypto = [
|
||||
{ name = "cryptography" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pymeta3"
|
||||
version = "0.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ce/af/409edba35fc597f1e386e3860303791ab5a28d6cc9a8aecbc567051b19a9/PyMeta3-0.5.1.tar.gz", hash = "sha256:18bda326d9a9bbf587bfc0ee0bc96864964d78b067288bcf55d4d98681d05bcb", size = 29566, upload-time = "2015-02-22T16:30:06.858Z" }
|
||||
|
||||
[[package]]
|
||||
name = "pymongo"
|
||||
version = "4.16.0"
|
||||
@@ -6280,6 +6819,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/3f/51/d4db610ef29373b879047326cbf6fa98b6c1969d6f6dc423279de2b1be2c/requests_toolbelt-1.0.0-py2.py3-none-any.whl", hash = "sha256:cccfdd665f0a24fcf4726e690f65639d272bb0637b9b92dfd91a5568ccf6bd06", size = 54481, upload-time = "2023-05-01T04:11:28.427Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rfc3339-validator"
|
||||
version = "0.1.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/28/ea/a9387748e2d111c3c2b275ba970b735e04e15cdb1eb30693b6b5708c4dbd/rfc3339_validator-0.1.4.tar.gz", hash = "sha256:138a2abdf93304ad60530167e51d2dfb9549521a836871b88d7f4695d0022f6b", size = 5513, upload-time = "2021-05-12T16:37:54.178Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/44/4e421b96b67b2daff264473f7465db72fbdf36a07e05494f50300cc7b0c6/rfc3339_validator-0.1.4-py2.py3-none-any.whl", hash = "sha256:24f6ec1eda14ef823da9e36ec7113124b39c04d50a4d3d3a3c2859577e7791fa", size = 3490, upload-time = "2021-05-12T16:37:52.536Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rich"
|
||||
version = "14.3.4"
|
||||
@@ -6407,6 +6958,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/c2/362f2cc36a7a57b47380061c23fc109c7222c1a544ffd24cda289ba19673/rtree-1.4.0-py3-none-win_amd64.whl", hash = "sha256:ba83efc7b7563905b1bfdfc14490c4bfb59e92e5e6156bdeb6ec5df5117252f4", size = 385221, upload-time = "2025-03-05T23:31:44.537Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ruamel-yaml"
|
||||
version = "0.19.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c7/3b/ebda527b56beb90cb7652cb1c7e4f91f48649fbcd8d2eb2fb6e77cd3329b/ruamel_yaml-0.19.1.tar.gz", hash = "sha256:53eb66cd27849eff968ebf8f0bf61f46cdac2da1d1f3576dd4ccee9b25c31993", size = 142709, upload-time = "2026-01-02T16:50:31.84Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/0c/51f6841f1d84f404f92463fc2b1ba0da357ca1e3db6b7fbda26956c3b82a/ruamel_yaml-0.19.1-py3-none-any.whl", hash = "sha256:27592957fedf6e0b62f281e96effd28043345e0e66001f97683aa9a40c667c93", size = 118102, upload-time = "2026-01-02T16:50:29.201Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "s3transfer"
|
||||
version = "0.16.0"
|
||||
@@ -6555,6 +7115,31 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/02/12c73fd423eb9577b97fc1924966b929eff7074ae6b2e15dd3d30cb9e4ae/segno-1.6.6-py3-none-any.whl", hash = "sha256:28c7d081ed0cf935e0411293a465efd4d500704072cdb039778a2ab8736190c7", size = 76503, upload-time = "2025-03-12T22:12:48.106Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semantic-kernel"
|
||||
version = "1.8.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "aiohttp" },
|
||||
{ name = "defusedxml" },
|
||||
{ name = "jinja2" },
|
||||
{ name = "nest-asyncio" },
|
||||
{ name = "numpy" },
|
||||
{ name = "openai" },
|
||||
{ name = "openapi-core" },
|
||||
{ name = "opentelemetry-api" },
|
||||
{ name = "opentelemetry-sdk" },
|
||||
{ name = "pandas" },
|
||||
{ name = "prance" },
|
||||
{ name = "pybars4" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "pydantic-settings" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a5/7e/001a77e55bcc670a9575394eaf63fb48edd407feb8fb49eeb0fab99c9a5f/semantic_kernel-1.8.0.tar.gz", hash = "sha256:cc468f6717274d287087cebf889eed370d27fe0444bc670322f249e7e771de3d", size = 258621, upload-time = "2024-08-22T17:22:14.46Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/23/73/e011059b5bc92d6095c407750bc6d4bbefe11630fb8521a56c28f96ebc8a/semantic_kernel-1.8.0-py3-none-any.whl", hash = "sha256:ebbe78e3e7858840f83b07f185ae543e31465066636204949936c48ce1db8e2d", size = 455158, upload-time = "2024-08-22T17:22:16.857Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "semantic-version"
|
||||
version = "2.10.0"
|
||||
@@ -6777,6 +7362,11 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/49/21633706dd6feb14cd3f7935fc00b60870ea057686035e1a99ae6d9d9d53/SQLAlchemy-2.0.36-py3-none-any.whl", hash = "sha256:fddbe92b4760c6f5d48162aef14824add991aeda8ddadb3c31d56eb15ca69f8e", size = 1883787, upload-time = "2024-10-15T20:04:30.265Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
asyncio = [
|
||||
{ name = "greenlet" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sse-starlette"
|
||||
version = "2.2.1"
|
||||
@@ -7022,6 +7612,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/af/df/c7891ef9d2712ad774777271d39fdef63941ffba0a9d59b7ad1fd2765e57/tiktoken-0.12.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f61c0aea5565ac82e2ec50a05e02a6c44734e91b51c10510b084ea1b8e633a71", size = 920667, upload-time = "2025-10-06T20:22:34.444Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tinytag"
|
||||
version = "2.2.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/96/59/8a8cb2331e2602b53e4dc06960f57d1387a2b18e7efd24e5f9cb60ea4925/tinytag-2.2.1.tar.gz", hash = "sha256:e6d06610ebe7cd66fd07be2d3b9495914ab32654a5e47657bb8cd44c2484523c", size = 38214, upload-time = "2026-03-15T18:48:01.11Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/34/d50e338631baaf65ec5396e70085e5de0b52b24b28db1ffbc1c6e82190dc/tinytag-2.2.1-py3-none-any.whl", hash = "sha256:ed8b1e6d25367937e3321e054f4974f9abfde1a3e0a538824c87da377130c2b6", size = 32927, upload-time = "2026-03-15T18:47:59.613Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tokenizers"
|
||||
version = "0.22.2"
|
||||
@@ -7295,6 +7894,19 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614, upload-time = "2025-08-25T13:49:24.86Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspect"
|
||||
version = "0.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "mypy-extensions" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/dc/74/1789779d91f1961fa9438e9a8710cdae6bd138c80d7303996933d117264a/typing_inspect-0.9.0.tar.gz", hash = "sha256:b23fc42ff6f6ef6954e4852c1fb512cdd18dbea03134f91f856a95ccc9461f78", size = 13825, upload-time = "2023-05-24T20:25:47.612Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/65/f3/107a22063bf27bdccf2024833d3445f4eea42b2e598abfbd46f6a63b6cb0/typing_inspect-0.9.0-py3-none-any.whl", hash = "sha256:9ee6fc59062311ef8547596ab6b955e1b8aa46242d854bfc78f4f6b0eff35f9f", size = 8827, upload-time = "2023-05-24T20:25:45.287Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
|
||||
Reference in new issue
Block a user